Skip to content

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 the top_issues_kpi_contradiction smoke), frontend && npm test (61/61 passing — added 31 new Vitest cases across KpiTile, RecommendationsPanel, TimelinePanel, CustomerDevicePanel, AlertsFeed, ConnectedSystems), and make test (16/16 backend tests). Before/after screenshots at artifacts/widget-review/before.png and artifacts/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:

  1. 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.
  2. Dead-affordance bugs — six "View all" / "View" controls across four panels are decorative <div> / <span> elements with hover:underline only. No href, no onClick, no keyboard focus.
  3. Data-integrity bugsCustomerDevicePanel hardcodes the "Top Action" string to "Book same-day repair" regardless of card.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:

  1. CLAUDE.md — Browser-First Debugging section + Demo Discipline + ADR pointers.
  2. The active PRD — current source of truth is prd-v2.1.md (most recent edit; supersedes prd.md and prd-v2.md). Skim §5.1 (widgets list) and the ADR appendix.
  3. docs/lessons-learned.md — pay attention to the Stale containers hide UI work lesson; re-run make up after every change.
  4. 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

  ┌─────────────────────────────────────────────────────────────────────┐
  │  Lane A — KPI strip                                                 │
  │  • kpi_delta_polarity ──► kpi_value_kind   (same file, sequential)  │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane B — Top Issues + heatmap (data-shape coupled)                 │
  │  • top_issues_kpi_contradiction (FE+BE)                             │
  │  • heatmap_legend_lies (FE+BE)                                      │
  │  • heatmap_radius_constant (FE only)        ◄── parallel-safe       │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane C — Recommendations panel (single file, sequential within)    │
  │  • recommendations_mock_dishonesty                                  │
  │  • recommendations_disabled_chrome                                  │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane D — Timeline (single file, sequential within)                 │
  │  • timeline_tab_taxonomy                                            │
  │  • timeline_truncation                                              │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane E — Customer device panel (single file, sequential within)    │
  │  • customer_device_top_action_hardcoded                             │
  │  • customer_device_empty_leaks_prd_ref                              │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane F — Alerts (single file, sequential within)                   │
  │  • alerts_silent_fallback                                           │
  │  • alerts_severity_glyph_collision                                  │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane G — My Widgets rail (single file, sequential within)          │
  │  • my_widgets_fixed_height                                          │
  │  • my_widgets_remove_button_collision                               │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane H — Connected systems (single file, sequential within)        │
  │  • connected_systems_no_failure_path                                │
  │  • connected_systems_brand_vs_health                                │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane I — App-level                                                 │
  │  • ws_reconnect_banner_flash                                        │
  ├─────────────────────────────────────────────────────────────────────┤
  │  Lane J — Cross-cutting (parallel across 4 files)                   │
  │  • dead_view_all_links — touches TopIssuesTable, RecommendationsPanel, │
  │    AlertsFeed, TimelinePanel. Each file independent of the others.  │
  └─────────────────────────────────────────────────────────────────────┘

  Lanes A through I are FULLY INDEPENDENT and can be done by parallel agents.
  Lane J is independent of lanes B/C/F/D EXCEPT that the merge order
  matters — whoever merges first owns the import-statement diff. Easiest
  path: do Lane J last as a single small PR after the other lanes land.

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:

1
2
3
4
5
const positive = deltaPct >= 0;
// ...
<span className={positive ? "delta-up" : "delta-down"}>
  {positive ? "▲" : "▼"} {Math.abs(deltaPct).toFixed(...)}
</span>
Evidence (from /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:

{deltaLabel.includes("month") ? "" : "%"}
String-sniff to decide whether to append % 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.


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:

1
2
3
4
<Legend color={SEVERITY_COLOR.critical} label="Critical" />
<Legend color={SEVERITY_COLOR.high} label="High" />
<Legend color={SEVERITY_COLOR.medium} label="Medium" />
<Legend color={SEVERITY_COLOR.low} label="Low" />
Evidence: /v1/dashboard/state returns five regions all severity: "critical". The Medium/Low/High legend rows correspond to nothing on the map.

Fix sketch:

1
2
3
4
5
const presentSeverities = useMemo(
  () => Array.from(new Set(regions.map((r) => r.severity))).sort(severityRank),
  [regions]
);
// then map presentSeverities → Legend rows
Plus seed real diversity in 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.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):

1
2
3
4
5
6
7
{recommendations.length > 0 && recommendations.every((r) => r.mock) ? (
  <span title="These cards are layout placeholders — the recommender wakes up when an issue session opens."
        className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-800">
    <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
    Sample data
  </span>
) : null}
Mirrors the Header's "Offline mode" treatment so the visual language is consistent. Cite Mocks must be opt-in, never silent fallback and ADR-008 in the inline comment — the lesson explicitly says the user must never be able to confuse mock and live paths, and that's exactly the bug here for the recommendations panel.

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:

1
2
3
<div className="text-[11px] font-semibold uppercase tracking-wide text-ink-500">Top Action</div>
<div className="text-sm font-semibold text-ink-900">Book same-day repair</div>
<div className="mt-2 text-[12px] text-ink-500">Triage score {card.triage_score.toFixed(2)}</div>
The string "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:

const TOP_ACTION_LABEL: Record<string, string> = {
  book_same_day_repair: "Book same-day repair",
  send_battery_offer: "Send battery replacement offer",
  escalate_for_fraud_review: "Escalate for fraud review",
  expedite_replacement_shipping: "Expedite replacement shipping",
  customer_update: "Send customer update",
  // fall-through: humanize unknown via top_action.replace(/_/g, " ")
};
const topActionLabel = card.recommendation?.title
  ?? TOP_ACTION_LABEL[card.recommendation?.top_action ?? ""]
  ?? humanize(card.recommendation?.top_action ?? "Action");
Prefer 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: - kpih-32 - charth-72 - tablemin-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:

1
2
3
4
5
6
{editMode ? (
  <div className="flex items-center justify-between gap-2 border-b border-dashed border-brand-300 bg-brand-50/50 px-2 py-1">
    <DragHandle .../>
    <RemoveButton .../>
  </div>
) : null}
This trades one absolute layer for a deterministic flex layout. Consider whether 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:

const STATUS_LABEL: Record<string, { text: string; cls: string }> = {
  connected: { text: "Connected", cls: "text-emerald-600" },
  degraded: { text: "Degraded", cls: "text-amber-600" },
  down: { text: "Down", cls: "text-rose-600" },
};
// per-system
<div className={STATUS_LABEL[s.status]?.cls ?? "text-ink-500"}>
  {STATUS_LABEL[s.status]?.text ?? s.status}
</div>
// rollup pill
const allOk = systems.every(s => s.status === "connected");
const anyDown = systems.some(s => s.status === "down");
const pillCls = allOk ? "bg-emerald-100 text-emerald-700"
  : anyDown ? "bg-rose-100 text-rose-700"
  : "bg-amber-100 text-amber-700";
const pillText = allOk ? "All systems operational"
  : anyDown ? `${systems.filter(s => s.status === "down").length} down`
  : "Degraded";

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.


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:

1
2
3
4
5
6
7
8
9
const ws = new WebSocket(wsUrl("/v1/dashboard/stream"));
wsRef.current = ws;
setWsStatus("live");
ws.onmessage = () => { void load(); };
ws.onclose = () => {
  if (cancelled) return;
  setWsStatus("reconnecting");
  // ...
};
On the first failed handshake (which can happen during a cold load if the page renders faster than the WS upgrade completes), 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:
1
2
3
4
5
6
7
ws.onclose = () => {
  if (cancelled) return;
  if (attempt >= 1) setWsStatus("reconnecting");
  const delay = Math.min(3000, 500 + attempt * 250);
  attempt += 1;
  window.setTimeout(connect, delay);
};
Acceptance: Hard-reload 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:

  1. 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).
  2. 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 should mkdir -p artifacts/widget-review/, navigate to http://localhost:3080 via cursor-ide-browser MCP at viewport 1024×600, and save artifacts/widget-review/before.png so there's a real "before" to compare against. Then after fixes: capture artifacts/widget-review/after.png at 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.
  3. bash scripts/verify-acceptance.sh must stay green (it covers PRD §15 acceptance criteria; nothing here breaks them).
  4. 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.
  5. cd backend && pytest -q (via make 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 / CustomWidgetRenderer internals — 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-maps is fine; only the encoding/legend/seed need work.
  • Anything in prd-v2.1.md Part 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.