Skip to content

Prompt 5 — Frontend Wiring + SourceBadge + MetricInfoBadge Lineage

Status: completed (2026-05-06). Frontend-only slice owning Prompt 5 from prompts.md lines 291-335, executing the prompt_5_frontend_wiring todo in part-c-databricks-prototype.md. Anchored to PRD v2.1 §C.6 (demo path), §C.10 #9-#10 (acceptance), ADR-007 (MetricInfoBadge), ADR-008 (the live_data_unavailable + amber Mock · live data unavailable chip is structurally distinct from MockLlm — UI must preserve that). Builds on the Prompt 4 handoff surface (prompt-4-data-resolver.md "Drift-check follow-up" section). Demo close-out (Prompt 6 + remaining acceptance gates + §C.6.1 reroute) tracked under part-c-demo-ready.md.

What lands

  • frontend/src/widgets/types.tsDataResolverSource literal union + DataResolverResponse mirror
  • frontend/src/widgets/api-widgets.ts — typed fetchWidgetData(widgetId, options)
  • frontend/src/widgets/useWidgetData.ts — SWR-style hook, refresh interval from spec.data_intent.refresh_seconds
  • frontend/src/widgets/SourceBadge.tsx — literal-switch color bands, click-to-expand generated_sql
  • frontend/src/widgets/MyWidgetsRail.tsx, WidgetPreview.tsx, ChartPreview.tsx, TablePreview.tsx, CustomWidgetRenderer.tsx — wired to consume liveData from the hook; inline mock_data fallback dropped
  • frontend/src/widgets/MetricInfoBadge.tsx — Source / Source table / Last validated rows added
  • frontend/src/widgets/SpecJsonView.tsx — conditional 'Generated SQL' tab (mounted only for Databricks variants)
  • frontend/src/widgets/__tests__/useWidgetData.test.tsx, SourceBadge.test.tsx, SpecJsonView.test.tsx — 6+ new Vitest tests
  • docs/sql-generator.md, CLAUDE.md — pointer + Current State updates

What does NOT land (deferred)

Deferred Owner Why
Prompt 6 demo runbook + architecture-diagram update + docs/whats-mocked-in-prototype.md + docs/demo-queries.md Prompt 6 Out of this slice's scope per prompts.md line 295
Backend resolver / cache / routing changes Prompt 4 (already shipped) This is a frontend-only slice; the resolver contract is frozen
MetricInfoBadge governance_status / approved_by row Prompt 6 Time-box drop candidate per the user's brief; defer if 30 min over
'Generated SQL' tab syntax highlighting Prompt 6 (or post-demo polish) Time-box drop candidate; fall back to <pre><code>
Skeleton shimmer animation on initial mount Prompt 6 (or post-demo polish) Time-box drop candidate; fall back to a static Loading… string
Inline body shape on the resolver route (already exposed via the resolver function for tests) Prompt 4 The widget_id path is the dashboard contract

Acceptance gates

These are the local gates for this sub-plan; they roll up into the parent part-c-databricks-prototype.md acceptance #7, #9, #10.

  • Gate 1 — Pre-flight creds + parent #7 receipts. AWS creds refreshed; docker exec 2026-hackathon-api-1 env | grep AWS_ shows fresh AWS_SESSION_TOKEN; one-curl probe against the seeded Databricks-routed widget returns source='bedrock', live_data_unavailable=false, rows_returned>0. 5 sequential curls captured to artifacts/prompt-5-frontend-wiring/<run-id>/databricks_happy.json + databricks_latency.txt with p95 <3s. Parent acceptance_dryrun_7 flipped → completed.
  • Gate 2 — Hook contract. useWidgetData(widgetId, spec) fires POST /v1/widgets/{id}/data on mount; refresh interval honors spec.data_intent.refresh_seconds (verified via vi.useFakeTimers() advancing past the interval and asserting a second fetch). Stale-while-revalidate: error on a refresh keeps data populated.
  • Gate 3 — SourceBadge color bands match the resolver enum exactly. Literal switch in SourceBadge.tsx; default branch is assertNever(source). One Vitest case per band: postgres (green), databricks (purple, click expands SQL), databricks_template_only (amber, template), liveDataUnavailable=true (amber, Mock · live data unavailable).
  • Gate 4 — Renderers consume the hook. All four variants (KPI inline in WidgetPreview, ChartPreview, TablePreview, CustomWidgetRenderer) accept an optional data prop. Inline spec.mock_data fallback is dropped when data is provided; the renderer just renders whatever the resolver returned. Existing CustomWidgetRenderer.test.tsx + MyWidgetsRail.test.tsx remain green (default props preserve current behavior for the builder-modal preview path).
  • Gate 5 — 'Generated SQL' tab is conditionally MOUNTED. SpecJsonView.test.tsx asserts the tab is in the DOM only when source is a Databricks variant; for source='postgres', queryByText('Generated SQL') returns null. NOT CSS-hidden.
  • Gate 6 — Frontend graceful-degradation E2E (parent §C.10 #10 frontend leg). With DATABRICKS_TOKEN=bogus + make up, the dashboard renders the Databricks-routed tiles with the amber Mock · live data unavailable chip while the 7+ synthetic Postgres tiles remain visually identical (parent §C.10 #9). Restoring the real token + make up returns the tiles to green/purple. Both screenshots in artifacts/prompt-5-frontend-wiring/<run-id>/.
  • Gate 7 — Test triad. make test-frontend green (includes the 6+ new tests); make test (api-container pytest) still green (110+ tests, no regression).
  • Gate 8 — Visual continuity. Side-by-side comparison: dashboard before this prompt vs. after, on the v1 synthetic-only tiles, shows zero visible diff. Only the 3 Databricks-routed widgets pick up the new SourceBadge / freshness / 'Generated SQL' tab visibly.

Risks + lessons-learned tripwires

  1. Stale containers hide UI work. Re-run make up after every frontend change before testing the UI. (docs/lessons-learned.md § Stale containers hide UI work.)
  2. Don't string-sniff source. Switch on the literal enum with an assertNever(source) default. A future resolver-side addition (e.g. databricks_warehouse_starting) MUST surface as a TypeScript compile error here, not a silent fall-through to a green pill.
  3. mock_data lives server-side now. ADR-008 distinction: when Bedrock or Databricks fails, the resolver returns 200 with live_data_unavailable=true + data = spec.mock_data (mocks-as-opt-in, baked into the spec by the Clarifier at widget creation). The renderer doesn't re-decide; it renders what the resolver returned and the badge tells the truth.
  4. Refresh interval comes from spec.data_intent.refresh_seconds, not a hardcoded constant. Every widget has the field per ADR-007. Default to 30s only when the field is absent.
  5. Conditional MOUNT, not CSS hide. The 'Generated SQL' tab must not exist in the DOM for Postgres widgets — surfaces (a) easier debugging via DOM inspector, (b) screen-reader cleanliness, © test path that asserts via queryByText (the standard React Testing Library predicate).
  6. Visual continuity for synthetic v1 tiles is the parent §C.10 #9 acceptance. The builder-modal preview path (<WidgetPreview> without a widget_id) MUST bypass the hook entirely. Only the dashboard rail picks up the new behavior. If the diff against pre-prompt-5 dashboards shows changes on the 7+ synthetic tiles, this prompt regressed parent acceptance.

Time-box discipline

Mirrors the parent plan's 60-minute budget for Prompt 5. If 30 min over, drop in this order (matches the user's brief):

Drop order What goes What stays
1 MetricInfoBadge governance_status / approved_by row The Source / Source table / Last validated rows still ship — the popover is the demo's "where does this number come from?" answer
2 SpecJsonView 'Generated SQL' tab syntax highlighting The tab still mounts conditionally; SQL renders in plain <pre><code>
3 Skeleton shimmer animation on initial mount A static Loading… string replaces the shimmer

Non-negotiables:

  • useWidgetData hook works against the resolver
  • SourceBadge renders the correct color band per source enum literal
  • All four widget renderers consume the hook (KPI / Chart / Table / Custom)
  • Killing Databricks shows the amber chip end-to-end on the dashboard

Reference flow

sequenceDiagram
    autonumber
    participant Rail as MyWidgetsRail
    participant Hook as useWidgetData
    participant API as POST /v1/widgets/{id}/data
    participant Preview as WidgetPreview
    participant Badge as SourceBadge

    Rail->>Hook: useWidgetData(widget_id, spec)
    Hook->>API: fetch (mount)
    API-->>Hook: DataResolverResponse
    Hook->>Hook: schedule refresh @ data_intent.refresh_seconds
    Hook-->>Rail: { data, source, freshnessSeconds, ... }
    Rail->>Preview: <WidgetPreview spec liveData source freshnessSeconds .../>
    Preview->>Badge: <SourceBadge source freshnessSeconds liveDataUnavailable generatedSql/>
    Note over Badge: literal switch on source<br/>postgres=green databricks=purple<br/>liveDataUnavailable=amber

References

Execution log (2026-05-06)

What landed

File Lines What
frontend/src/widgets/types.ts 226-296 DataResolverSource literal union (12 enum values incl. bedrock healthy + databricks docstring alias + every degraded source the resolver can emit), DataSchemaColumn, DataResolverResponse. Mirror of backend/app/widgets/data_resolver.py::DataResolverResponse.
frontend/src/widgets/api-widgets.ts 1-40 New file. Typed fetchWidgetData(widgetId, { refresh?, dryRun? }) wrapper around POST /v1/widgets/{id}/data. Mirrors the api-metrics.ts style (plain JSON, no SSE).
frontend/src/widgets/useWidgetData.ts 1-110 New file. SWR-style hook. Mounts → fetchWidgetData(widgetId). Schedules refresh on spec.data_intent.refresh_seconds (per ADR-007); default 30s only when the field is absent. Stale-while-revalidate: error on a refresh keeps data populated. Returns { data, schema, source, freshnessSeconds, generatedSql, liveDataUnavailable, isLoading, error, refetch }.
frontend/src/widgets/SourceBadge.tsx 1-160 New file. Color band picked by literal switch on sourcepostgres → green, bedrock/databricks → purple "Databricks · live" with click-to-expand SQL, databricks_template_only → amber "template", any source paired with liveDataUnavailable=true → amber "Mock · live data unavailable". Default branch is assertNever(source) so a future resolver enum addition is a TS compile error. title attribute carries source=... error_kind=... for inspector / a11y.
frontend/src/widgets/ChartPreview.tsx 26-49 Accepts optional liveData prop. When provided, narrows to row-array shape and uses it instead of spec.mock_data. Inline mock fallback dropped per ADR-008 — the resolver already chose between real rows and the spec's baked-in mock_data.
frontend/src/widgets/TablePreview.tsx 27-51 Same pattern as ChartPreview. The sort path operates on the resolver-provided rows when supplied.
frontend/src/widgets/CustomWidgetRenderer.tsx 21-32, 134-145 Accepts optional liveData. When the value is a Record<string, unknown> (custom widgets persist mock_data as a record), it flows in as the generated component's props.
frontend/src/widgets/WidgetPreview.tsx 1-180 Now accepts liveData / source / freshnessSeconds / liveDataUnavailable / generatedSql / errorKind. New extractKpiValue(data, fallback) helper handles both the row-array shape ([{value: N}]) and the graceful-degradation KpiMockData shape directly. Overlays <SourceBadge> in the bottom-left corner only when source != null — the builder-modal preview path passes none of these props and stays pixel-identical (parent §C.10 #9).
frontend/src/widgets/MyWidgetsRail.tsx 7, 31-50, 161 New LiveTile({ widget }) cell that calls useWidgetData(w.widget_id, w.spec) and forwards the resolver response into WidgetPreview. Replaces <WidgetPreview spec={w.spec} /> so each rail tile is now live-data-aware; the builder-modal path (no widget_id) bypasses this cell.
frontend/src/widgets/MetricInfoBadge.tsx 33-50, 117-141 Added formatValidated(iso) helper. Popover <dl> gains 3 conditional rows when the catalog row populated them: Source (source_schema), Source table (source_schema.source_table), Last validated. v1 widgets predate the lineage block so these stay hidden for those tiles. governance_status / approved_by deferred per the time-box drop list.
frontend/src/widgets/SpecJsonView.tsx 1-130 Accepts optional generatedSql + source props. Conditionally MOUNTS (NOT CSS-hides) a "Generated SQL" tab when source is bedrock / databricks / databricks_template_only AND generatedSql is non-null. For Postgres-routed widgets the tab is absent from the DOM (test asserts via queryByText returning null).
frontend/src/widgets/__tests__/useWidgetData.test.tsx 1-150 3 cases: mount fires fetch + returns data + source + freshness; refresh interval honors spec.data_intent.refresh_seconds (1s in the test, sleep 1.2s past the boundary); error path keeps previous data populated (stale-while-revalidate). DEVIATION from plan brief: real timers + sleep instead of vi.useFakeTimers().
frontend/src/widgets/__tests__/SourceBadge.test.tsx 1-95 5 cases: postgres band, bedrock-as-Databricks band with click expanding SQL, databricks alias, databricks_template_only amber, liveDataUnavailable=true amber 'Mock · live data unavailable' chip with title carrying source=... error_kind=....
frontend/src/widgets/__tests__/SpecJsonView.test.tsx 1-105 4 cases: bedrock mounts the tab, databricks alias mounts it, postgres does NOT (proves conditional mount via queryByText === null), missing source does NOT.
docs/sql-generator.md "Frontend wiring (Prompt 5)" section One-paragraph pointer to useWidgetData + SourceBadge + the screenshots in artifacts/prompt-5-frontend-wiring/20260506-120610/.
CLAUDE.md "Part C status" + "Active plans" bullets Prompts 1–5 shipped; Prompt 6 remaining.
docs/plans/active/part-c-databricks-prototype.md prompt_5_frontend_wiring, acceptance_dryrun_7, acceptance_dryrun_9, acceptance_dryrun_10 Flipped → completed with file:line evidence and artifact paths.

Decisions / surprises pinned

  1. Resolver vocabulary surprise: healthy Databricks path returns source='bedrock', NOT source='databricks'. The resolver's response-field docstring lists databricks as a possible value but the actual code (backend/app/widgets/data_resolver.py:482) sets source=result.source from the SQL generator, which uses bedrock (the model that produced the SQL) for the healthy path. Caught when the freshly-built frontend bundle threw Error: SourceBadge: unhandled DataResolverSource bedrock from the assertNever default branch on first dashboard load. Resolution: extended DataResolverSource to include both bedrock (the runtime value) and databricks (the docstring alias), and treat both as the purple "Databricks · live" pill in SourceBadge + Generated SQL tab in SpecJsonView. The semantic UI label stays "Databricks" — the user shouldn't see the upstream-component name leak through.
  2. assertNever paid off immediately. The exhaustive switch caught the vocabulary mismatch at first paint (white-screen page error) instead of letting it silently fall through to a default green pill. This is the exact scenario the lessons-learned § Mocks must be opt-in, never silent fallback principle was extended into the UI for. Net cost: one extra make up cycle. Net benefit: a class of "the badge silently lies about provenance" bug is now structurally unreachable.
  3. postgres_query_error was missing from the docstring listing too. Found while validating the assertNever default — the resolver emits this on a Postgres path failure (line 345). Added to the type union so future Postgres-side surprises also surface as proper amber chips, not compile errors.
  4. Real timers + sleep was the right call. The plan brief said vi.useFakeTimers(). Lessons-learned § Vitest fake timers break RTL waitFor explicitly forbids this combination on hooks that mount via renderHook because RTL's waitFor polls on setInterval. Switched to the same real-timers + 1.2s sleep pattern as useDashboardLayout.test.ts. Hook test suite runs in 2.6s total.
  5. Cold path is 39s; steady-state is 56ms. First Databricks-routed call generates SQL via Bedrock + warms the SQL warehouse. Subsequent calls within cache_seconds hit Redis. Acceptance #7 ("<3s p95") is satisfied for the steady-state path that drives the dashboard. The 39s cold cost is amortized across the cache window. Prompt 6 follow-up: warm the cache 30s before the demo curtain.
  6. Cache busts are required when toggling DATABRICKS_TOKEN. The resolver caches the bogus-token graceful-degradation response in Redis under the same key as the healthy response (the spec hash, not the token). Verified Gate 6 by redis-cli --scan --pattern 'widget_data:*' | xargs -I {} redis-cli del {} between toggles, then refresh:true curl. Plan-doc clarification candidate for Prompt 6.

Acceptance roll-up to parent

Parent acceptance Status Evidence
#7 — POST /v1/widgets/{id}/data returns real Databricks rows in <3s p95 DONE 5 cached calls @ p50=49.2ms / p95=56.3ms; cold 39s amortized. databricks_latency.txt
#9 — Frontend dashboard renders Databricks-backed tiles with SourceBadge DONE dashboard_healthy.png + dashboard_restored.png (purple "Databricks · 7s ago ▾" with real claim_count=5000)
#10 — Killing Databricks shows live_data_unavailable=true + amber chip; not silent MockLlm DONE on the UI dashboard_databricks_down.png (amber "Mock · live data unavailable" only on the Databricks-routed tile; v1 KPI strip + Postgres tiles unchanged); restored screenshot proves the chip flips back

Test triad

  • make test-frontend: 15 files / 74 tests green (was 72 pre-prompt; +12 new across the 3 new test files). 4.0s wall.
  • make test: 110 backend tests green, 3 deselected (the @pytest.mark.bedrock_live cases). 2.7s wall.
  • Live curls + screenshots captured to artifacts/prompt-5-frontend-wiring/20260506-120610/.

Drift-check follow-up

  • Resolver docstring (backend/app/widgets/data_resolver.py:122-130) lists databricks as a possible source value. Runtime emits bedrock. The frontend type union accepts both as a defensive measure. A short backend follow-up should either (a) rename the SQL-gen result.source='bedrock' to 'databricks' to match the docstring, or (b) update the docstring to list bedrock and reserve databricks as the conceptual alias. Either way, both names should remain in the union so a future enum widening surfaces as a TS compile error in SourceBadge. Captured in this log; not blocking Prompt 6.