Prompt 5 — Frontend Wiring + SourceBadge + MetricInfoBadge Lineage¶
Status: completed (2026-05-06). Frontend-only slice owning Prompt 5 from
prompts.mdlines 291-335, executing theprompt_5_frontend_wiringtodo inpart-c-databricks-prototype.md. Anchored to PRD v2.1 §C.6 (demo path), §C.10 #9-#10 (acceptance), ADR-007 (MetricInfoBadge), ADR-008 (thelive_data_unavailable+ amberMock · live data unavailablechip 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 underpart-c-demo-ready.md.
What lands¶
frontend/src/widgets/types.ts—DataResolverSourceliteral union +DataResolverResponsemirrorfrontend/src/widgets/api-widgets.ts— typedfetchWidgetData(widgetId, options)frontend/src/widgets/useWidgetData.ts— SWR-style hook, refresh interval fromspec.data_intent.refresh_secondsfrontend/src/widgets/SourceBadge.tsx— literal-switch color bands, click-to-expandgenerated_sqlfrontend/src/widgets/MyWidgetsRail.tsx,WidgetPreview.tsx,ChartPreview.tsx,TablePreview.tsx,CustomWidgetRenderer.tsx— wired to consumeliveDatafrom the hook; inlinemock_datafallback droppedfrontend/src/widgets/MetricInfoBadge.tsx— Source / Source table / Last validated rows addedfrontend/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 testsdocs/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 freshAWS_SESSION_TOKEN; one-curl probe against the seeded Databricks-routed widget returnssource='bedrock',live_data_unavailable=false,rows_returned>0. 5 sequential curls captured toartifacts/prompt-5-frontend-wiring/<run-id>/databricks_happy.json+databricks_latency.txtwith p95 <3s. Parentacceptance_dryrun_7flipped →completed. - Gate 2 — Hook contract.
useWidgetData(widgetId, spec)firesPOST /v1/widgets/{id}/dataon mount; refresh interval honorsspec.data_intent.refresh_seconds(verified viavi.useFakeTimers()advancing past the interval and asserting a second fetch). Stale-while-revalidate: error on a refresh keepsdatapopulated. - Gate 3 — SourceBadge color bands match the resolver enum exactly. Literal switch in
SourceBadge.tsx; default branch isassertNever(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 optionaldataprop. Inlinespec.mock_datafallback is dropped whendatais provided; the renderer just renders whatever the resolver returned. ExistingCustomWidgetRenderer.test.tsx+MyWidgetsRail.test.tsxremain green (default props preserve current behavior for the builder-modal preview path). - Gate 5 — 'Generated SQL' tab is conditionally MOUNTED.
SpecJsonView.test.tsxasserts the tab is in the DOM only whensourceis a Databricks variant; forsource='postgres',queryByText('Generated SQL')returnsnull. 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 amberMock · live data unavailablechip while the 7+ synthetic Postgres tiles remain visually identical (parent §C.10 #9). Restoring the real token +make upreturns the tiles to green/purple. Both screenshots inartifacts/prompt-5-frontend-wiring/<run-id>/. - Gate 7 — Test triad.
make test-frontendgreen (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¶
- Stale containers hide UI work. Re-run
make upafter every frontend change before testing the UI. (docs/lessons-learned.md§ Stale containers hide UI work.) - Don't string-sniff
source. Switch on the literal enum with anassertNever(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. mock_datalives server-side now. ADR-008 distinction: when Bedrock or Databricks fails, the resolver returns 200 withlive_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.- 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. - 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). - 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:
useWidgetDatahook works against the resolverSourceBadgerenders the correct color band persourceenum 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¶
prd-v2.1.md§C.6 — demo path additionsprd-v2.1.md§C.10 #7, #9, #10 — acceptance gates this sub-plan rolls up toprompts.mdlines 291-335 — the executable formdocs/adrs/ADR-007.md— MetricInfoBadge contractdocs/adrs/ADR-008.md—live_data_unavailable+ amber chip is structurally distinct from MockLlmdocs/sql-generator.md"Per-widget data resolver" section — graceful-degradation contract table the SourceBadge UI mirrorsdocs/lessons-learned.md— § Resolver-side imports MUST be lazy, § Pydantic dict-compare, § Mocks must be opt-in, never silent fallback, § Stale containers hide UI workdocs/plans/completed/prompt-4-data-resolver.md— the upstream handoff surface (Drift-check follow-up section is the contract)- Parent:
docs/plans/active/part-c-databricks-prototype.md
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 source — postgres → 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¶
- Resolver vocabulary surprise: healthy Databricks path returns
source='bedrock', NOTsource='databricks'. The resolver's response-field docstring listsdatabricksas a possible value but the actual code (backend/app/widgets/data_resolver.py:482) setssource=result.sourcefrom the SQL generator, which usesbedrock(the model that produced the SQL) for the healthy path. Caught when the freshly-built frontend bundle threwError: SourceBadge: unhandled DataResolverSource bedrockfrom theassertNeverdefault branch on first dashboard load. Resolution: extendedDataResolverSourceto include bothbedrock(the runtime value) anddatabricks(the docstring alias), and treat both as the purple "Databricks · live" pill inSourceBadge+ Generated SQL tab inSpecJsonView. The semantic UI label stays "Databricks" — the user shouldn't see the upstream-component name leak through. assertNeverpaid 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 extramake upcycle. Net benefit: a class of "the badge silently lies about provenance" bug is now structurally unreachable.postgres_query_errorwas 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.- Real timers + sleep was the right call. The plan brief said
vi.useFakeTimers(). Lessons-learned § Vitest fake timers break RTLwaitForexplicitly forbids this combination on hooks that mount viarenderHookbecause RTL'swaitForpolls onsetInterval. Switched to the same real-timers + 1.2s sleep pattern asuseDashboardLayout.test.ts. Hook test suite runs in 2.6s total. - Cold path is 39s; steady-state is 56ms. First Databricks-routed call generates SQL via Bedrock + warms the SQL warehouse. Subsequent calls within
cache_secondshit 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. - 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 byredis-cli --scan --pattern 'widget_data:*' | xargs -I {} redis-cli del {}between toggles, thenrefresh:truecurl. 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) listsdatabricksas a possible source value. Runtime emitsbedrock. The frontend type union accepts both as a defensive measure. A short backend follow-up should either (a) rename the SQL-genresult.source='bedrock'to'databricks'to match the docstring, or (b) update the docstring to listbedrockand reservedatabricksas the conceptual alias. Either way, both names should remain in the union so a future enum widening surfaces as a TS compile error inSourceBadge. Captured in this log; not blocking Prompt 6.