Dashboard widget fixes — Executive Overview review (2026-05-06)¶
Status: completed (2026-05-06). All 19 widget bugs + the cross-cutting dead-link demotion + the app-level WS reconnect banner suppression landed. Validation gate green:
make up,bash scripts/verify-acceptance.sh(now includes thetop_issues_kpi_contradictionsmoke),frontend && npm test(61/61 passing — added 31 new Vitest cases acrossKpiTile,RecommendationsPanel,TimelinePanel,CustomerDevicePanel,AlertsFeed,ConnectedSystems), andmake test(16/16 backend tests). Before/after screenshots atartifacts/widget-review/before.pngandartifacts/widget-review/after.png(1024×600). No new ADRs were needed — every fix aligns with ADR-008's "no silent demo-data" stance and existing lessons-learned entries.
Why this plan exists¶
A walkthrough of the dashboard surfaced three classes of bug:
- Honesty bugs — the UI tells a false story. KPI deltas painted green when the underlying signal is bad. Recommendation cards labeled "AI Recommended" with no live data. Alerts feed silently substituting hardcoded stubs when the API returns empty. Heatmap legend listing severity tiers that don't exist in the data. These directly contradict the framing in
CLAUDE.md("No mocks in production code") and the spirit of ADR-008 (mock-as-opt-in builder mode). They are P0 because they can mislead a real operator. - Dead-affordance bugs — six "View all" / "View" controls across four panels are decorative
<div>/<span>elements withhover:underlineonly. No href, no onClick, no keyboard focus. - Data-integrity bugs —
CustomerDevicePanelhardcodes the "Top Action" string to "Book same-day repair" regardless ofcard.recommendation.top_action, so an operator approving a fraud-review recommendation sees and approves a "repair" label. This is the most concretely dangerous bug in the set.
Plus a fistful of ergonomic issues (fixed widget heights, severity-icon collision, brittle string-sniff suffix logic, WS reconnect banner flashing on cold load).
Cross-references to existing lessons (READ BEFORE STARTING)¶
Three already-captured lessons in docs/lessons-learned.md directly govern fixes in this plan. They are the law; this plan must comply, not deviate.
| Lesson | Applies to | Compliance constraint for this plan |
|---|---|---|
| Mocks must be opt-in, never silent fallback (line 206) | recommendations_mock_dishonesty (D.1), alerts_silent_fallback (G.1) |
These two fixes are direct applications of this lesson, not new opinions. The lesson explicitly says "the mock is a demo prop, not a fallback strategy" and that demo data must be visually impossible to confuse with live data. The "Sample data" pill pattern in the fix sketches mirrors the existing OFFLINE MODE pill pattern the lesson endorses for the Header. |
| Edit-mode gates destructive controls; view mode hides them (line 218) | my_widgets_remove_button_collision (H.2) |
The lesson is explicit: "Remove top-left, MetricInfoBadge top-right" — opposite corners. Any fix that consolidates Remove + drag handle into a single top-bar MUST keep MetricInfoBadge on its own corner (top-right of the card body) to preserve the misclick safety property. The view-mode-hides-Remove behavior is also non-negotiable. |
Don't double-stack h-72 on parent and child cards (line 229) |
my_widgets_fixed_height (H.1) |
The lesson rules: inside a flex flex-col parent that constrains height, use h-full on children — never a fixed h-…. My H.1 proposal changes the PARENT card's height per WidgetSpec variant; the inner WidgetPreview wrappers MUST stay at h-full. If you find yourself wanting to set a fixed pixel height inside the preview, you're regressing this lesson — stop. |
If your fix surfaces a NEW lesson worth promoting (see "Lessons-learned candidates" at the bottom), append it. Don't rediscover existing ones.
Reading order before you start¶
In order:
CLAUDE.md— Browser-First Debugging section + Demo Discipline + ADR pointers.- The active PRD — current source of truth is
prd-v2.1.md(most recent edit; supersedesprd.mdandprd-v2.md). Skim §5.1 (widgets list) and the ADR appendix. docs/lessons-learned.md— pay attention to the Stale containers hide UI work lesson; re-runmake upafter every change.- This file — start with the §"Per-issue spec" matching the todo you're working on.
Scope summary¶
| File | Touched by todos |
|---|---|
frontend/src/components/KpiTile.tsx |
kpi_delta_polarity, kpi_value_kind |
frontend/src/dashboard/sections.tsx |
kpi_delta_polarity (per-KPI direction lookup lives here unless a backend MetricDefinition.direction field is added) |
frontend/src/components/TopIssuesTable.tsx |
top_issues_kpi_contradiction, dead_view_all_links |
frontend/src/components/UsHeatmap.tsx |
heatmap_legend_lies, heatmap_radius_constant |
frontend/src/components/RecommendationsPanel.tsx |
recommendations_mock_dishonesty, recommendations_disabled_chrome, dead_view_all_links |
frontend/src/components/TimelinePanel.tsx |
timeline_tab_taxonomy, timeline_truncation, dead_view_all_links |
frontend/src/components/CustomerDevicePanel.tsx |
customer_device_empty_leaks_prd_ref, customer_device_top_action_hardcoded |
frontend/src/components/AlertsFeed.tsx |
alerts_silent_fallback, alerts_severity_glyph_collision, dead_view_all_links |
frontend/src/widgets/MyWidgetsRail.tsx |
my_widgets_fixed_height, my_widgets_remove_button_collision |
frontend/src/components/ConnectedSystems.tsx |
connected_systems_no_failure_path, connected_systems_brand_vs_health |
frontend/src/App.tsx |
ws_reconnect_banner_flash |
backend/app/seed.py (maybe) |
top_issues_kpi_contradiction, heatmap_legend_lies if we choose to seed real diversity rather than soften the FE empty states |
backend/app/metrics/schemas.py (optional) |
kpi_delta_polarity if we add direction to MetricDefinition rather than a frontend lookup |
No db/init.sql changes. No docker-compose.yml. No Makefile. Optional: one new lesson appended to docs/lessons-learned.md if we conclude that "color-coded deltas without per-metric direction" is a class of bug worth flagging once.
Parallelization map¶
Suggested ordering for a single agent doing them serially: P0 first across lanes (kpi_delta_polarity → top_issues_kpi_contradiction → heatmap_legend_lies → recommendations_mock_dishonesty → timeline_tab_taxonomy → customer_device_top_action_hardcoded → alerts_silent_fallback → connected_systems_no_failure_path), then P1, then P2, then the validation gate.
Per-issue spec¶
Each issue carries: location, current behavior, observed evidence, fix sketch, and an acceptance check. Do not skip the evidence — it's the receipt that the bug is real and not just an opinion.
A. KPI Strip — KpiTile.tsx¶
A.1 — kpi_delta_polarity (P0)¶
Location: frontend/src/components/KpiTile.tsx:21-36.
Current behavior: delta polarity comes solely from deltaPct >= 0:
/v1/dashboard/state, run 2026-05-06):
- active_issues.value: 1286, delta_pct: 12.0 → renders green ▲ 12%
- claims_in_progress.value: 642, delta_pct: 8.0 → renders green ▲ 8%
For an ops command center, more active issues = worse, not better. The most-glanced widgets on the dashboard are currently telling a false story.
Fix sketch (pick one):
- Option 1 (frontend-only, fast): add directionGood: "up" | "down" | "neutral" to KpiTile's props; in dashboard/sections.tsx:217-273 KpiTileById, hardcode the per-KPI direction (active_issues: "down", claims_in_progress: "down", nba_taken_pct: "up", cost_avoided_mtd: "up", csat: "up"). Compute goodDelta = (directionGood === "up" && deltaPct >= 0) || (directionGood === "down" && deltaPct < 0) and gate delta-up/delta-down on that, while keeping the arrow glyph tied to sign so direction-of-change stays readable.
- Option 2 (backend + frontend, correct): add direction: Literal["up_good", "down_good", "neutral"] to MetricDefinition (it already has unit; this is the natural sibling). Seed the 10 catalog metrics in backend/app/metrics/seed.py with the right directions. Plumb it through to the tile. Requires updating any custom widgets that already reference MetricDefinition and a small backfill on the metrics_catalog table (or a migration via _ensure_metrics_table).
Recommendation: Option 1 for the hackathon timeframe. Add a TODO comment at the lookup site pointing to Option 2 as the principled follow-up. Note in the comment that this should eventually move to MetricDefinition.direction per ADR-007's "every widget carries a metric" principle.
Acceptance: Take the dashboard screenshot via cursor-ide-browser MCP. Active Issues tile renders red ▼ 12% OR red ▲ 12% (preserve sign-based arrow, change color), Cost Avoided MTD stays green ▲ 18, NBA Taken stays green ▲ 5%, CSAT stays green ▲ 0.3. Add a Vitest snapshot in frontend/src/components/__tests__/ (create file) covering up_good vs down_good with positive and negative deltas.
A.2 — kpi_value_kind (P1)¶
Location: frontend/src/components/KpiTile.tsx:32-34.
Current behavior:
% to the delta. Works today because CSAT (1–5 scale) and Cost Avoided MTD use delta_label = "vs last month" and want no %, while the 15m KPIs do want %. But the moment a label changes to "vs prior month" or "month-over-month", the suffix logic breaks silently.
Fix sketch: add valueKind: "percent" | "score" | "currency" | "count" to props. In KpiTileById, derive from the existing per-KPI knowledge (csat → "score", cost_avoided_mtd → "currency", nba_taken_pct → "percent", others → "count"). Gate the % suffix on valueKind === "percent". If Option 2 of A.1 is taken, derive valueKind from MetricDefinition.unit (which already enumerates count|percent|currency|minutes|rating|ratio in backend/app/metrics/schemas.py:37).
Acceptance: Existing rendered output is byte-identical (no visible change). Vitest snapshot covers each valueKind. Grep deltaLabel.includes returns zero hits in the file.
B. Top Issues — TopIssuesTable.tsx¶
B.1 — top_issues_kpi_contradiction (P0)¶
Location: frontend/src/components/TopIssuesTable.tsx:7,19-21 (panel title + empty-state copy) and the backend aggregator (look in backend/app/dashboard/routes.py for the top_issues field; cross-reference backend/app/dashboard_state.py).
Current behavior: Panel title: Top Issues (Last 15 minutes). Empty state: No active issues in the last 15 minutes. KPI directly above: Active Issues 1,286 ▲ 12%.
Evidence: /v1/dashboard/state returns top_issues: [] and kpis.active_issues.value: 1286 simultaneously.
Fix sketch (pick one):
- Backend fix: make the top_issues aggregator share the same window semantics as kpis.active_issues. If active_issues is "all open issue_sessions" (no time bound), then so should top_issues be — drop (Last 15 minutes) from the panel title. If active_issues is windowed, fix the seed/aggregator so the rolling 15m window has rollups when there are 1,286 active issues.
- Frontend fix (cheaper): make the empty-state copy explicit when both signals are present: "0 issues in this 15m window — last issue X minutes ago" rather than the ambiguous "No active issues" that contradicts the tile two inches above. Requires the API to expose last_issue_at (or compute from events).
Recommendation: backend fix — the underlying drift will keep biting other widgets (heatmap windowing also smells off). Track which window the dashboard uses and put it in the API response (window_seconds: 900) so the FE can label everything consistently.
Acceptance: With seeded data, the table renders ≥ 3 rows when active_issues > 0. With zero issues, the empty state is unambiguous. Add an assertion to scripts/verify-acceptance.sh: top_issues length ≥ 1 when active_issues.value > 100.
B.2 — see dead_view_all_links lane J below¶
C. US Heatmap — UsHeatmap.tsx¶
C.1 — heatmap_legend_lies (P0)¶
Location: frontend/src/components/UsHeatmap.tsx:197-205.
Current behavior: Legend hardcodes four severity tiers regardless of the data shape:
/v1/dashboard/state returns five regions all severity: "critical". The Medium/Low/High legend rows correspond to nothing on the map.
Fix sketch:
backend/app/seed.py: a couple of low/medium regions alongside the criticals, so the legend has multiple rows to render even on a fresh demo.
Acceptance: With current seed, legend renders one row (Critical). After diversifying the seed, legend renders 2-4 rows matching the unique severities present. Visual: take the screenshot and confirm.
C.2 — heatmap_radius_constant (P1)¶
Location: frontend/src/components/UsHeatmap.tsx:142-146.
Current behavior: const radius = 6 + (r.count / maxCount) * 14; — when counts cluster near maxCount, every dot is ~max radius.
Evidence: Seed counts [258, 257, 257, 257, 257] → all dots render at ≈19-20px radius. The header pill says "View by Volume" but volume isn't visible.
Fix sketch: swap linear scaling for Math.log1p(count)-based scaling: const radius = 6 + Math.log1p(r.count) * 3; with a sanity cap at e.g. 26px. This makes a 10-issue region visibly smaller than a 250-issue region, while a 250-issue region is still visibly smaller than a 2,500-issue one.
Acceptance: With seed counts [10, 50, 100, 250], radius values are visually distinct (≈13, 18, 20, 22 px). Snapshot test.
D. AI Recommended Next Best Actions — RecommendationsPanel.tsx¶
D.1 — recommendations_mock_dishonesty (P0)¶
Location: frontend/src/components/RecommendationsPanel.tsx:42-83 and the API source for the recommendations field.
Current behavior: Every recommendation in /v1/dashboard/state carries mock: true. Cards render with full chrome (badge, icon, title, subtitle, chevron) and only a 10px ink-400 caption "visual stub" hints at the truth.
Evidence: /v1/dashboard/state shows all four recommendations as mock: true. Header copy: "AI Recommended Next Best Actions".
Fix sketch: add a panel-level honest-cue when recommendations.every((r) => r.mock):
Acceptance: Visual confirmation — the pill appears when all recs are mock and disappears when any real issue_session_id is present. Vitest covering both paths.
D.2 — recommendations_disabled_chrome (P1)¶
Location: frontend/src/components/RecommendationsPanel.tsx:62-75.
Current behavior: Disabled rows still render the chevron and the row is <button disabled>. Hover styling is correctly conditional, but the chevron at line 75 is unconditional.
Fix sketch: drop the chevron when !isLive, render as <div role="presentation"> instead of <button disabled> so screen readers don't announce "unavailable button" four times in a row, and lower title opacity to 70%.
Acceptance: Manual screen-reader pass (VoiceOver) confirms no "dimmed button" announcements; Vitest covers isLive toggling chevron presence.
E. Unified Customer & Device Timeline — TimelinePanel.tsx¶
E.1 — timeline_tab_taxonomy (P0)¶
Location: frontend/src/components/TimelinePanel.tsx:5-25.
Current behavior: TabKey lists six values: all, crm, device_telemetry, claims_portal, decision_service, repair_ops. sourceTag map covers eight sources, adding payments, outcome_service, dashboard. Filter is events.filter((e) => e.source === active). Result: events with source: "payments" show under "All Signals" but disappear from every named tab.
Fix sketch (pick one):
- Static fold: map payments and outcome_service events to be visible under the "Ops" tab; map dashboard to "Customer". Keep TabKey set unchanged but make the filter (e) => routeForSource(e.source) === active.
- Dynamic tabs: derive the tab list from the union of events.map(e => e.source), sorted into a stable order. More flexible but loses the labeled grouping.
Recommendation: static fold. The labels in the UI (Customer, Device, Claims, Interactions, Ops) are ones the operator already knows; dynamic source values would surface raw outcome_service strings.
Acceptance: Vitest covering: an event with source: "payments" appears under "Ops" tab. An event with source: "outcome_service" appears under "Ops" tab. An event with source: "dashboard" appears under "Customer" tab.
E.2 — timeline_truncation (P2)¶
Location: frontend/src/components/TimelinePanel.tsx:55.
Current behavior: filtered.slice(0, 12) — silent truncation, no count footer.
Fix sketch: add a footer <div className="border-t border-ink-100 px-4 py-2 text-[11px] text-ink-500">Showing {Math.min(12, filtered.length)} of {filtered.length}</div>. Optionally add a "Load more" button that bumps a local state cap. Keep the total list virtualized eventually if the source data grows beyond ~100.
Acceptance: Visual — when filtered.length > 12, the footer shows e.g. "Showing 12 of 27".
F. Customer & Device Detail — CustomerDevicePanel.tsx¶
F.1 — customer_device_top_action_hardcoded (P0 — data-integrity bug)¶
Location: frontend/src/components/CustomerDevicePanel.tsx:73-75.
Current behavior:
"Book same-day repair" is hardcoded and renders for every recommendation regardless of card.recommendation.top_action.
Evidence: /v1/dashboard/state recommendations[].top_action includes send_battery_offer, escalate_for_fraud_review, expedite_replacement_shipping, customer_update. None of those are repairs. Operator sees "Book same-day repair → Approve" then clicks Approve on a fraud escalation.
Fix sketch:
recommendation.title because it's already the operator-facing label used in the recommendations panel — keeping the two views in sync is the goal. Fall back to the lookup, then to a humanized version.
Acceptance: Vitest covering each top_action value renders the matching label. Manual: pick a fraud recommendation in the live dashboard, confirm panel header reads "Escalate for fraud review" not "Book same-day repair".
F.2 — customer_device_empty_leaks_prd_ref (P1)¶
Location: frontend/src/components/CustomerDevicePanel.tsx:12-18.
Current behavior: Empty-state copy: "Click a recommendation card to load the customer + device snapshot here, matching the PRD §5.1 right-panel pattern."
Fix sketch: replace with "Pick a recommendation on the left to load the customer's device, claim, and triage details here.". Optional polish: a faint preview/skeleton showing the device + customer + action card outlines.
Acceptance: Grep the codebase for PRD § returns zero hits in frontend/src/.
G. Alerts & Activity Feed — AlertsFeed.tsx¶
G.1 — alerts_silent_fallback (P0)¶
Location: frontend/src/components/AlertsFeed.tsx:17-23,40-62.
Current behavior: Empty alerts array silently triggers <DefaultAlerts /> which renders four hardcoded rows ("Spike in screen damage claims in CA — 23% increase", etc.) styled identically to live data.
Evidence: /v1/dashboard/state returns alerts: []; UI shows four "alerts" anyway. The CA spike contradicts the heatmap (no CA-specific spike in the data).
Fix sketch (pick one):
- Honest stub: keep <DefaultAlerts /> for visual richness during demos but render a panel-level "Sample alerts" pill (mirror the Recommendations fix D.1) when stubs are showing.
- Honest empty: drop <DefaultAlerts /> entirely. Render "No alerts in the last 15 minutes — last fired X minutes ago" (requires an API field for last alert time).
Recommendation: honest stub. The dashboard looks visually empty without alert content during a quiet demo, and the seed data isn't dense enough to generate organic alerts every demo run. The pill makes the masquerade impossible to miss. This is a direct application of Mocks must be opt-in, never silent fallback (lessons-learned.md line 206) — the lesson is about LLM mocks, but the principle generalizes to any demo data masquerading as live, and ADR-008's "OFFLINE MODE pill" pattern is the exact UX precedent we're mirroring here.
Acceptance: With API alerts: [], panel renders "Sample alerts" pill + four stub rows. With API alerts: [...real], no pill, real rows. Vitest covering both branches.
G.2 — alerts_severity_glyph_collision (P1)¶
Location: frontend/src/components/AlertsFeed.tsx:4-8,21-32.
Current behavior: severityIcon["critical"] and severityIcon["warning"] both use the glyph "!". Different background colors only.
Fix sketch: distinct glyphs — !/⚠/i (or icon SVGs for bg-rose critical, bg-amber warning, bg-sky info). Verify chip color combos against WCAG large-text 4.5:1.
Acceptance: Visual + a quick contrast check via the browser MCP devtools (or just compute the ratios in a comment).
H. My Widgets rail — MyWidgetsRail.tsx¶
H.1 — my_widgets_fixed_height (P1)¶
Location: frontend/src/widgets/MyWidgetsRail.tsx:81-87.
Current behavior: Every card forced to h-72 (288px) regardless of WidgetSpec variant.
Fix sketch: sniff w.spec.type and choose the PARENT card height:
- kpi → h-32
- chart → h-72
- table → min-h-72 and let it grow
- custom → keep h-72 (no good signal in ComponentSpec without a render-then-measure pass)
Render heights become semantic instead of one-size-fits-all. Verify the dashboard layout (grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3) doesn't break when row-heights become inhomogeneous (CSS grid handles this — tallest in row determines row height; that's fine).
Constraint (from Don't double-stack h-72 on parent and child cards): the inner WidgetPreview wrappers MUST continue to use h-full, not the parent's pixel height. If you change a child to h-32 to "match" the new parent KPI height, you regress the lesson — flex-1 + h-full is the only correct pattern. Read the lesson before touching this file.
Acceptance: Add a custom KPI widget via the Add Widget Clarifier flow. It should render at h-32 with the value taking the full width. Add a custom chart widget — h-72 unchanged.
H.2 — my_widgets_remove_button_collision (P2)¶
Location: frontend/src/widgets/MyWidgetsRail.tsx:95-108 plus SortableSlot.tsx:81-98.
Current behavior: Remove button is absolute left-2 top-10. Drag handle is absolute left-2 top-2 h-6 (ends at 32px). 8px gap works on the default viewport, but two stacked absolute pills competing for top-left feels fragile when the card title wraps or on small viewports.
Fix sketch: put both controls in a single relatively-positioned top-bar inside the card body (above <WidgetPreview />), shown only in edit mode:
SortableSlot's built-in handle placement still makes sense — may need a handlePosition: "inline" variant.
Constraints (from Edit-mode gates destructive controls; view mode hides them):
- MetricInfoBadge MUST stay on the opposite corner from Remove. The lesson is explicit that destructive controls must not sit near the metric (i) badge — a top-bar with Remove inside it preserves this property as long as MetricInfoBadge stays at right-2 top-2 of the card body. Do NOT move MetricInfoBadge into the top-bar.
- Remove MUST stay invisible in view mode. The top-bar wrapper itself should only render when editMode is true.
- The dashed brand-blue outline on every card during edit mode (currently from border border-dashed border-brand-400 ring-2 ring-brand-100 on the card) must be preserved; the lesson says this visual mode-state cue is non-negotiable.
Acceptance: Manual at 1440x900, 1024x600, and 768x800 viewports — Remove and drag handle never overlap, both are reachable, MetricInfoBadge (i) is at least 200px away from Remove on every viewport, and view mode shows neither Remove nor the top-bar.
I. Connected Systems strip — ConnectedSystems.tsx¶
I.1 — connected_systems_no_failure_path (P0)¶
Location: frontend/src/components/ConnectedSystems.tsx:14-32.
Current behavior: "All systems operational" pill is hardcoded green. Per-system "Connected" text is hardcoded text-emerald-600. Component reads NOTHING from system.status.
Evidence: Inspect the ConnectedSystem type (look in frontend/src/api.ts) — the field exists. The renderer ignores it.
Fix sketch:
Acceptance: Force one connected system to degraded in seed/API for a test; pill goes amber, that system's text goes amber. Force one to down; pill goes rose, count is correct.
I.2 — connected_systems_brand_vs_health (P2)¶
Location: frontend/src/components/ConnectedSystems.tsx:3-10,22.
Current behavior: dotByKey colors the small square dot by per-system brand (slack purple, confluence sky). Same dot is used regardless of status.
Fix sketch: add a second small element — either a brand glyph (small SVG icon for slack, confluence) and use the dot itself for health (green/amber/rose), OR drop the brand color and make the dot the health indicator.
Acceptance: If health-only — dot color matches STATUS_LABEL[s.status].cls. If both — brand glyph is a small monochrome svg, status dot is colored.
J. Cross-cutting — dead_view_all_links (P1, parallel-safe across 4 files)¶
Location:
- frontend/src/components/TopIssuesTable.tsx:37-39 — "View all issues →"
- frontend/src/components/RecommendationsPanel.tsx:47 — "View all"
- frontend/src/components/AlertsFeed.tsx:15 — "View all"
- frontend/src/components/TimelinePanel.tsx:65 — per-row "View"
Current behavior: Each is a <div>/<span>/<button type="button"> with hover:underline but no handler, no href, no keyboard semantics where there should be.
Fix sketch (pick one for each):
- Wire it: add a real handler. The hackathon doesn't have a "view all issues" page, so the realistic options are scrolling to a pre-existing detail panel, opening a modal, or routing to a placeholder route.
- Demote it: if no destination exists, render as text-ink-400 cursor-default with no underline-on-hover. Don't pretend to be interactive.
Recommendation: demote everything except the timeline per-row "View" (which could be wired to open CustomerDevicePanel for the related issue if e.entity_ref exists). Doing four fake-link removals is a tiny diff and matches the rest of the dashboard's honesty pass.
Acceptance: Click each affordance — either it does something, or it has no hover state at all. Keyboard tab order skips the demoted ones.
K. App-level — App.tsx¶
K.1 — ws_reconnect_banner_flash (P2)¶
Location: frontend/src/App.tsx:69-99,150-154.
Current behavior:
onerror calls ws.close() which triggers setWsStatus("reconnecting") for ~250ms, flashing the amber banner before the second attempt connects.
Fix sketch: only set "reconnecting" when attempt >= 1 (i.e. an actual retry has been scheduled). Also: track whether we've EVER successfully received a message and suppress the banner until then. Resulting code:
http://localhost:3080 ten times. Banner should appear zero times. Then docker compose stop api, banner appears within ~2s. Restart api, banner clears.
Validation gate — validation_smoke todo¶
Before declaring this plan done:
- Re-run
make up(the plan touches frontend source; the Stale containers hide UI work lesson applies — never trust the running stack after a source edit without rebuild). - Capture the baseline first. The original review session screenshots live in tmp (
/var/folders/.../cursor/screenshots/dashboard-full.png) and will not survive. Before starting any fix, the next agent shouldmkdir -p artifacts/widget-review/, navigate tohttp://localhost:3080via cursor-ide-browser MCP at viewport 1024×600, and saveartifacts/widget-review/before.pngso there's a real "before" to compare against. Then after fixes: captureartifacts/widget-review/after.pngat the same viewport and confirm:- KPI strip: Active Issues + Claims in Progress deltas render in semantically correct color (red for bad-up).
- Top Issues table: either populated, or has unambiguous 0-window copy.
- Heatmap legend: renders only severities present in
regions. - Recommendations panel: "Sample data" pill present when all recs are mock.
- Alerts feed: "Sample data" pill present when API returns
alerts: []. - Customer device panel (with selected recommendation): top_action title matches the recommendation, not "Book same-day repair".
- Connected systems: per-system + rollup colors react to seeded status.
- WS reconnect banner: does not flash on cold load.
bash scripts/verify-acceptance.shmust stay green (it covers PRD §15 acceptance criteria; nothing here breaks them).cd frontend && npm test— Vitest must stay green; new tests added per §A.1, §A.2, §D.2, §E.1, §F.1, §G.1, §I.1 must pass.cd backend && pytest -q(viamake test) if any backend changes landed (Option 2 for KPI direction, top_issues seed/aggregator fix, heatmap diversity seed).
Out of scope (intentionally)¶
- The custom widgets rail's
WidgetPreview/CustomWidgetRendererinternals — those are governed by ADR-006 and ADR-007 and have their own active plans/lessons. - The Add Widget Clarifier modal — covered by ADR-005, ADR-007, ADR-008.
- Replacing the heatmap library —
react-simple-mapsis fine; only the encoding/legend/seed need work. - Anything in
prd-v2.1.mdPart B (Forward-Looking v2 Enhancements) — Kafka/Databricks/Mosaic AI is not the target here. - New ADRs — every fix here aligns with existing ADRs (especially ADR-008 "no silent demo-data masquerade"). If a fix surfaces a real architectural deviation, write the ADR per CLAUDE.md "Document deviations".
Suggested commit topology¶
One PR per lane keeps reviews tight. Suggested titles:
dashboard: KPI delta polarity + value-kind props (lane A)dashboard: align Top Issues window with Active Issues KPI (lane B.1)dashboard: heatmap legend reflects data + log-scaled radii (lane C)dashboard: honest mock-data pill on Recommendations + Alerts (lanes D.1, G.1 — could split)dashboard: Recommendations chevron only when interactive (lane D.2)dashboard: Timeline tab taxonomy fold + Showing N of M (lane E)dashboard: bind Customer Device top_action label + neutral empty copy (lane F)dashboard: Alerts severity glyphs distinct + WCAG contrast (lane G.2)dashboard: variant-aware MyWidgets card heights + edit-mode top-bar (lane H)dashboard: ConnectedSystems renders real status (lane I)dashboard: demote dead "View all" affordances (lane J)dashboard: suppress WS reconnect banner on cold-load false alarm (lane K)
Twelve PRs is a lot; an alternative is one PR per priority (P0 / P1 / P2). Pick whatever fits the team's review cadence.
Lessons-learned candidates¶
If any of the following come true during execution, append to docs/lessons-learned.md:
- "KPI tile colors must derive from per-metric direction, not delta sign" — surfaces every time someone adds a new bad-up metric and forgets to flip the color.
- "Empty-state copy must not contradict an adjacent populated tile" — generalize from the Top Issues / Active Issues collision.
- "Decorative buttons in
<button type='button'>clothing make accessibility audits fail" — surfaces from the Lane J fixes.