Compare commits

...

2 Commits

Author SHA1 Message Date
Dmitry_Tkachenko da991101e4 Merge pull request 'feat(insights): collapsible Alert categories + Watch list accordions' (#1) from feature/insights-accordions into main
Reviewed-on: #1
2026-08-29 12:10:19 +03:00
Dmytro Tkachenko 3de14164fc feat(insights): collapsible Alert categories + Watch list accordions
The PM Insights "Alert categories" and "Watch list" sections are now
accordions: each heading is a button (chevron + category-count badge,
aria-expanded/aria-controls) that collapses/expands its card grid. Both
default open; the per-card ticket expand and the Per-PM roll-up table
are unchanged.

- Section accordion component + sections state in Insights/index.tsx
- ChevronIcon that rotates with the section's open state
- .grid[hidden] rule so a collapsed grid actually hides (beats .grid
  on specificity + source order)
- bump 2.3.0 -> 2.3.1 (root + client), CHANGELOG [2.3.1]
- engineer artifact + INDEX line; verifier-log PASS entry

verifier PASS (verifier-log 2026-08-29 12:07): reproduced tsc + vite
build; confirmed the [hidden] collapse in the compiled CSS; no
per-card regression; no new dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEsaHQx8cXr1hFrKU42UK6
2026-08-29 12:09:36 +03:00
9 changed files with 112 additions and 10 deletions
+10
View File
@@ -4,6 +4,16 @@ All notable changes to **FORGE** are documented here.
Format based on [Keep a Changelog](https://keepachangelog.com/); this project uses Format based on [Keep a Changelog](https://keepachangelog.com/); this project uses
[Semantic Versioning](https://semver.org/). [Semantic Versioning](https://semver.org/).
## [2.3.1] — 2026-08-29
### Changed
- **PM Insights — collapsible sections.** The **Alert categories** and **Watch list**
sections are now accordions: each heading is a button (chevron + a category-count
badge, `aria-expanded`/`aria-controls`) that collapses/expands its card grid. Both
default to open; the per-card ticket expand and the **Per-PM roll-up** table are
unchanged. Adds a `ChevronIcon` (rotates with the section) and a `.grid[hidden]` rule
so a collapsed grid actually hides.
## [2.3.0] — 2026-08-27 ## [2.3.0] — 2026-08-27
### Added ### Added
+1
View File
@@ -1,4 +1,5 @@
# Artifact index # Artifact index
- [engineer-20260829-120746](engineer-20260829-120746.md) — PM Insights: "Alert categories" + "Watch list" now collapsible accordions (new `Section`, `ChevronIcon`, `.grid[hidden]` collapse, `aria-expanded`/`aria-controls`). v2.3.1. verifier PASS.
- [engineer-20260827-190753](engineer-20260827-190753.md) — Jira statusDurations+movements + chart #17 (Active tab), /api/analytics/jira-durations, seed-enriched from board_state. v2.3.0. - [engineer-20260827-190753](engineer-20260827-190753.md) — Jira statusDurations+movements + chart #17 (Active tab), /api/analytics/jira-durations, seed-enriched from board_state. v2.3.0.
- [engineer-20260827-185932](engineer-20260827-185932.md) — Extension Jira sync (SNOW→Jira, attach-only /api/sync/jira, custom-domain + PAT). v2.2.0. Live-verified attach-only. - [engineer-20260827-185932](engineer-20260827-185932.md) — Extension Jira sync (SNOW→Jira, attach-only /api/sync/jira, custom-domain + PAT). v2.2.0. Live-verified attach-only.
@@ -0,0 +1,31 @@
# engineer — PM Insights: Alert categories + Watch list as accordions
**Date:** 2026-08-29 · v2.3.1 · branch `feature/insights-accordions`
## Built
- **Client** (`pages/Insights/index.tsx`): new `Section({title, count, open, onToggle, children})`
accordion — a `<button className={sectionHead} aria-expanded aria-controls>` (chevron + title +
a category-count badge) over a `<div id={bodyId} className={grid} hidden={!open}>`. The two
sections **Alert categories** and **Watch list** are wrapped in it. Added `sections` state
(`{alerts, watch}`, both default **open**) + `toggleSection`. The per-card `open` state (which
ticket group is expanded) and the **Per-PM roll-up** table are untouched — Per-PM stays a plain
`<h2>` (not requested).
- **Icons** (`components/icons/index.tsx`): added `ChevronIcon` (right caret, shared `base()`
pattern), rotated 90° via CSS when its section is open.
- **Styles** (`Insights.module.scss`): `.section` / `.sectionHead` / `.chev` / `.sectionTitle` /
`.sectionCount` reusing the old `.h2` typographic treatment; `.sectionHead[aria-expanded='true']
.chev { transform: rotate(90deg) }`; and `.grid[hidden] { display: none }` — required, since
`.grid { display: grid }` would otherwise beat the native `hidden` attribute (specificity 0,2,0
vs 0,1,0).
- **Version/docs**: root + client `package.json` 2.3.0 → 2.3.1; CHANGELOG `[2.3.1]`.
## Verified
- client `tsc --noEmit` clean (no errors outside node_modules); `vite build` OK (139 modules).
- **verifier PASS** (see `verifier-log.md` 2026-08-29 12:07): independently reproduced tsc + build;
grepped the compiled bundle to confirm `._grid…[hidden]{display:none}` co-exists with
`._grid…{display:grid}` and wins on specificity **and** source order; `aria-controls` always
resolves (body is `hidden`, not unmounted); slugs `insights-alert-categories` /
`insights-watch-list` are distinct; per-card expand byte-for-byte unchanged → no regression;
no `package.json` dep change.
## Next: done.
+1
View File
@@ -16,3 +16,4 @@
- 2026-08-27 19:02 · engineer · Extension Jira-sync (SNOW+Jira attach-only) · VERDICT: PASS · re-ran: server tsc 0, node --check bg/options/popup OK, tests 9 pass (6 client+3 root), live psql TEMP-TABLE smoke of exact attachJira UPDATE (real RITM2653436 untouched) · probed: Jira payload with top-level "status"+SQLi string lands ONLY inside jira jsonb (ticket status stays closed, table survives)→no clobber/no injection; '||' merge preserves omitted keys (extraKept=KEEPME, assignee=Old Person); jira_key WFN-100→605 via COALESCE; non-matching number→UPDATE 0 (no stub); endpoint requireToken not session (401 w/o bearer, index.ts:241); schema additive-only (jira JSONB pre-existing, all ALTER ADD COLUMN IF NOT EXISTS) · PASS - 2026-08-27 19:02 · engineer · Extension Jira-sync (SNOW+Jira attach-only) · VERDICT: PASS · re-ran: server tsc 0, node --check bg/options/popup OK, tests 9 pass (6 client+3 root), live psql TEMP-TABLE smoke of exact attachJira UPDATE (real RITM2653436 untouched) · probed: Jira payload with top-level "status"+SQLi string lands ONLY inside jira jsonb (ticket status stays closed, table survives)→no clobber/no injection; '||' merge preserves omitted keys (extraKept=KEEPME, assignee=Old Person); jira_key WFN-100→605 via COALESCE; non-matching number→UPDATE 0 (no stub); endpoint requireToken not session (401 w/o bearer, index.ts:241); schema additive-only (jira JSONB pre-existing, all ALTER ADD COLUMN IF NOT EXISTS) · PASS
- 2026-08-27 19:11 · engineer · Jira statusDurations+movements+chart #17 · VERDICT: PASS · re-ran: server+client tsc=0, root vitest 3/3, client vitest 6/6, live SQL agg on tickets(jira?statusDurations)=73 rows, booted dist+curl endpoint · probed: 401 unauth (got 401), min-2 filter drops Cancelled n=1, ordering known-workflow-first + extras avgDays-desc, ms/86.4M day math matches SQL exactly (UAT 16.14d/21, Closed 23.51d/16, InProgress 4.90d/37), guards NaN/<=0, client null-safe (jiraDur=[] + length>0 gate), static SQL no user input, additive jsonb no DDL · PASS - 2026-08-27 19:11 · engineer · Jira statusDurations+movements+chart #17 · VERDICT: PASS · re-ran: server+client tsc=0, root vitest 3/3, client vitest 6/6, live SQL agg on tickets(jira?statusDurations)=73 rows, booted dist+curl endpoint · probed: 401 unauth (got 401), min-2 filter drops Cancelled n=1, ordering known-workflow-first + extras avgDays-desc, ms/86.4M day math matches SQL exactly (UAT 16.14d/21, Closed 23.51d/16, InProgress 4.90d/37), guards NaN/<=0, client null-safe (jiraDur=[] + length>0 gate), static SQL no user input, additive jsonb no DDL · PASS
- 2026-08-29 11:20 · devops · SSH NAS host → mycloud.dp.ua:2323 · VERDICT: PASS · re-ran: grep 192.168.50.2 (only comment+artifact), grep NAS_HOST (all mycloud), bash -n push-to-nas.sh OK · probed: all ssh/scp/rsync hops use $NAS_HOST not hardcoded IP; port 2323 preserved; illustrative comment+historical artifact intentional · PASS - 2026-08-29 11:20 · devops · SSH NAS host → mycloud.dp.ua:2323 · VERDICT: PASS · re-ran: grep 192.168.50.2 (only comment+artifact), grep NAS_HOST (all mycloud), bash -n push-to-nas.sh OK · probed: all ssh/scp/rsync hops use $NAS_HOST not hardcoded IP; port 2323 preserved; illustrative comment+historical artifact intentional · PASS
- 2026-08-29 12:07 · engineer · Insights alert/watch sections as accordions · VERDICT: PASS · re-ran: tsc -p (clean), vite build (139 modules, exit 0), grep compiled CSS (.grid[hidden]{display:none} present, specificity/order beats .grid), token defs in globals.scss · probed: collapse hides grid via hidden+override; aria-controls id always-rendered (no dangling ref when collapsed); unique slugs alert-categories/watch-list (no dup id); per-card open state diff-unchanged (no regression); chevron rotate(90deg) bound to aria-expanded=true · PASS
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "forge-client", "name": "forge-client",
"private": true, "private": true,
"version": "2.3.0", "version": "2.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+8
View File
@@ -60,6 +60,14 @@ export function CloseIcon({ size = 18 }: IconProps) {
); );
} }
export function ChevronIcon({ size = 14 }: IconProps) {
return (
<svg {...base(size)}>
<path d="m9 18 6-6-6-6" />
</svg>
);
}
export function SpreadsheetIcon({ size = 15 }: IconProps) { export function SpreadsheetIcon({ size = 15 }: IconProps) {
return ( return (
<svg {...base(size)}> <svg {...base(size)}>
@@ -17,12 +17,49 @@
margin-top: $s-4; margin-top: $s-4;
} }
.section { display: flex; flex-direction: column; gap: $s-3; }
.sectionHead {
display: flex;
align-items: center;
gap: $s-2;
width: 100%;
margin-top: $s-4;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
&:hover { color: var(--text); }
}
.chev {
display: inline-flex;
color: var(--text-dim);
transition: transform 0.15s ease;
}
.sectionHead[aria-expanded='true'] .chev { transform: rotate(90deg); }
.sectionHead:hover .chev { color: var(--text-muted); }
.sectionTitle { flex: 0 0 auto; }
.sectionCount {
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
padding: 1px 7px;
border-radius: 999px;
background: var(--surface-alt);
color: var(--text-muted);
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: $s-3; gap: $s-3;
align-items: start; align-items: start;
} }
.grid[hidden] { display: none; }
.card { .card {
background: var(--surface); background: var(--surface);
+22 -8
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import PageHeader from '../../components/PageHeader'; import PageHeader from '../../components/PageHeader';
import KpiTile from '../../components/charts/KpiTile'; import KpiTile from '../../components/charts/KpiTile';
import { ExternalIcon } from '../../components/icons'; import { ExternalIcon, ChevronIcon } from '../../components/icons';
import { getInsights } from '../../services/analytics.service'; import { getInsights } from '../../services/analytics.service';
import type { InsightsResponse, AlertGroup, InsightTicket } from '../../types/analytics.types'; import type { InsightsResponse, AlertGroup, InsightTicket } from '../../types/analytics.types';
import styles from './Insights.module.scss'; import styles from './Insights.module.scss';
@@ -15,6 +15,8 @@ export default function Insights({ refreshKey }: { refreshKey: number }) {
const [data, setData] = useState<InsightsResponse | null>(null); const [data, setData] = useState<InsightsResponse | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [open, setOpen] = useState<string | null>(null); const [open, setOpen] = useState<string | null>(null);
const [sections, setSections] = useState({ alerts: true, watch: true });
const toggleSection = (key: 'alerts' | 'watch') => setSections(s => ({ ...s, [key]: !s[key] }));
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -40,15 +42,13 @@ export default function Insights({ refreshKey }: { refreshKey: number }) {
<KpiTile label="PO escalations" value={`${data.waitingPo.levels.l1}·${data.waitingPo.levels.l2}·${data.waitingPo.levels.l3}`} tone="warning" hint="L1 · L2 · L3" /> <KpiTile label="PO escalations" value={`${data.waitingPo.levels.l1}·${data.waitingPo.levels.l2}·${data.waitingPo.levels.l3}`} tone="warning" hint="L1 · L2 · L3" />
</div> </div>
<h2 className={styles.h2}>Alert categories</h2> <Section title="Alert categories" count={alertGroups.length} open={sections.alerts} onToggle={() => toggleSection('alerts')}>
<div className={styles.grid}>
{alertGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} showProblem />)} {alertGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} showProblem />)}
</div> </Section>
<h2 className={styles.h2}>Watch list</h2> <Section title="Watch list" count={infoGroups.length} open={sections.watch} onToggle={() => toggleSection('watch')}>
<div className={styles.grid}>
{infoGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} />)} {infoGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} />)}
</div> </Section>
<h2 className={styles.h2}>Per-PM roll-up</h2> <h2 className={styles.h2}>Per-PM roll-up</h2>
<div className={styles.pmCard}> <div className={styles.pmCard}>
@@ -73,6 +73,20 @@ export default function Insights({ refreshKey }: { refreshKey: number }) {
); );
} }
function Section({ title, count, open, onToggle, children }: { title: string; count: number; open: boolean; onToggle: () => void; children: ReactNode }) {
const bodyId = `insights-${title.replace(/\s+/g, '-').toLowerCase()}`;
return (
<section className={styles.section}>
<button className={styles.sectionHead} onClick={onToggle} aria-expanded={open} aria-controls={bodyId}>
<span className={styles.chev}><ChevronIcon size={14} /></span>
<span className={styles.sectionTitle}>{title}</span>
<span className={styles.sectionCount}>{count}</span>
</button>
<div id={bodyId} className={styles.grid} hidden={!open}>{children}</div>
</section>
);
}
function AlertCard({ g, open, onToggle, showProblem }: { g: AlertGroup; open: boolean; onToggle: () => void; showProblem?: boolean }) { function AlertCard({ g, open, onToggle, showProblem }: { g: AlertGroup; open: boolean; onToggle: () => void; showProblem?: boolean }) {
const shown = showProblem ? g.tickets.filter((_, i) => i < g.problematic || open) : g.tickets; const shown = showProblem ? g.tickets.filter((_, i) => i < g.problematic || open) : g.tickets;
return ( return (
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "forge", "name": "forge",
"version": "2.3.0", "version": "2.3.1",
"description": "FORGE 2.0 — ServiceNow ticket analytics portal (RITM/SCTASK) with roles, PM insights, and a Chrome sync extension", "description": "FORGE 2.0 — ServiceNow ticket analytics portal (RITM/SCTASK) with roles, PM insights, and a Chrome sync extension",
"main": "dist/index.js", "main": "dist/index.js",
"private": true, "private": true,