From 8a5b6de27e23fea3cbf35074d9be12e10775e9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 03:33:01 +0200 Subject: [PATCH] fix(dashboard): mount InsightsCardPreview on root page (wire B1b dashboard preview to live route) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polished InsightsCardPreview component shipped in B1b commit 5 (d2cdf9d) was an orphan with zero non-test imports. The dashboard read no advisor payload, so the top severity-ordered AI recommendation + ring confidence meter + "View all" CTA was unreachable from any live route. Wave-D design review flagged this as CRITICAL C2. Wires the preview into the root dashboard page above the chart row, sharing the advisor cache key with /insights so a regenerate on either surface refreshes the other without a second LLM call. The preview self-hides when the user has no provider configured (route 422 → null) or the advisor returns zero recommendations. Adds insightsPreview to DASHBOARD_WIDGET_IDS so the layout toggle persists through Settings → Dashboard. Pins the widget into DEFAULT_DASHBOARD_LAYOUT (visible: true, tileVisible: false) so users discover the feature without hunting through settings, and adds a contract test mirroring the A5 widget-enum-drift guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/insights-card-preview.spec.ts | 170 ++++++++++++++++++ messages/de.json | 1 + messages/en.json | 1 + src/app/page.tsx | 32 ++++ .../settings/dashboard-layout-section.tsx | 4 + src/lib/__tests__/dashboard-layout.test.ts | 9 + src/lib/dashboard-layout.ts | 16 ++ 7 files changed, 233 insertions(+) create mode 100644 e2e/insights-card-preview.spec.ts diff --git a/e2e/insights-card-preview.spec.ts b/e2e/insights-card-preview.spec.ts new file mode 100644 index 000000000..2efc0d3fe --- /dev/null +++ b/e2e/insights-card-preview.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * v1.4.16 phase D reconcile (CRITICAL C2) — proves the polished + * `` is mounted on the root dashboard `/`. + * + * Before this fix the component shipped in B1b commit 5 (`d2cdf9d`) + * was an orphan with zero non-test imports. The dashboard read no + * advisor payload, so the top severity-ordered AI recommendation + + * confidence ring + "View all" CTA was unreachable from any live + * route. This spec asserts the card now renders when the layout + * toggle is active and the advisor returns at least one rec. + */ +test.describe("insights card preview on dashboard (C2)", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + // Populated analytics so the dashboard clears its empty-state and + // renders charts + tile strip. + await page.route("**/api/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + summaries: { + WEIGHT: { + latest: 78.5, + avg7: 78.2, + avg30: 77.9, + slope30: { slope: -0.05, direction: "down" }, + count: 30, + }, + }, + bpInTargetPct: null, + glucoseByContext: {}, + }, + error: null, + }), + }), + ); + + await page.route("**/api/mood/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], summary: { count: 0 } }, + error: null, + }), + }), + ); + + // Layout enables the insights preview widget so the gate is open. + await page.route("**/api/dashboard/widgets", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + version: 1, + widgets: [ + { id: "weight", visible: true, tileVisible: true, order: 0 }, + { + id: "insightsPreview", + visible: true, + tileVisible: false, + order: 14, + }, + ], + comparisonBaseline: "none", + }, + error: null, + }), + }), + ); + + await page.route("**/api/measurements*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { measurements: [], meta: { total: 0 } }, + error: null, + }), + }), + ); + + // Advisor payload — populated with one urgent rec carrying a + // confidence score so the preview renders the rec text + the + // ring confidence meter inline. + await page.route("**/api/insights/generate", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + insights: { + insightType: "general", + summary: "Weekly summary.", + classification: "gut", + classificationLabel: "Good", + findings: [], + correlations: [], + primaryRecommendation: "", + recommendations: [ + { + id: "rec-urgent-1", + text: "Schedule a follow-up reading this week.", + severity: "urgent", + confidence: 82, + rationale: { + dataWindow: "last7days", + comparedTo: "your 90-day median", + deviation: "Sys BP averaged 138 over the past week", + }, + metricSource: { + type: "bloodPressureSys", + timeRange: "last7days", + summary: "Sys BP avg 138", + }, + }, + ], + dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, + disclaimer: "Not medical advice.", + }, + cached: true, + cachedAt: new Date().toISOString(), + legacyPayload: false, + }, + error: null, + }), + }), + ); + }); + + test("dashboard renders InsightsCardPreview with top recommendation", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // Preview is the C2 deliverable — `data-slot="insights-card-preview"` + // is the stable hook the component exposes. + await expect( + page.locator('[data-slot="insights-card-preview"]'), + ).toBeVisible({ timeout: 10_000 }); + + // The urgent rec text from the mock payload renders inside the + // preview's compact tile. + await expect( + page.getByText("Schedule a follow-up reading this week."), + ).toBeVisible({ timeout: 10_000 }); + + // "View all" CTA points at /insights so the user can drill in. + await expect( + page.locator('[data-slot="insights-card-view-all"]'), + ).toBeVisible({ timeout: 10_000 }); + + // The ring-variant ConfidenceMeter renders inline next to the rec + // text — its slot is the same one used on /insights, so a single + // selector confirms wiring. + await expect( + page.locator( + '[data-slot="insights-card-preview"] [data-slot="confidence-meter"]', + ), + ).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/messages/de.json b/messages/de.json index 474ec6038..e96f8a7ca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -229,6 +229,7 @@ "bpInTarget": "BD im Zielbereich", "days30Short": "30T", "medications": "Medikamente", + "insightsPreview": "KI-Insights-Vorschau", "lastMeasurement": "Letzter Messwert", "noMeasurements": "Noch keine Messwerte", "trend": "Trend", diff --git a/messages/en.json b/messages/en.json index c1f2c9564..0e88b4ee6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -229,6 +229,7 @@ "bpInTarget": "BP in Target", "days30Short": "30d", "medications": "Medications", + "insightsPreview": "AI Insights preview", "lastMeasurement": "Last measurement", "noMeasurements": "No measurements yet", "trend": "Trend", diff --git a/src/app/page.tsx b/src/app/page.tsx index ebde5fc6b..a9ce6e27b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -46,6 +46,8 @@ import { GettingStartedChecklist } from "@/components/onboarding/getting-started import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { CompareToggle } from "@/components/comparison/compare-toggle"; +import { InsightsCardPreview } from "@/components/insights/insights-card"; +import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; const HealthChart = dynamic( () => @@ -208,6 +210,17 @@ export default function DashboardPage() { enabled: isAuthenticated, }); + // v1.4.16 phase D reconcile (CRITICAL C2) — pull the rich advisor + // payload so the dashboard can surface a compact preview of the top + // severity-ordered AI recommendations + ring confidence meter + + // "View all" CTA. Shares the cache with /insights via the + // queryKeys.insightsAdvisor() key so a regenerate on either surface + // refreshes the other without a second LLM round-trip. Returns null + // when the user has no provider configured (route 422 → null), so + // the preview self-hides without burning rate-limit tokens on + // unconfigured accounts. + const advisor = useInsightsAdvisorQuery(isAuthenticated); + const w = data?.summaries?.WEIGHT; const sys = data?.summaries?.BLOOD_PRESSURE_SYS; const dia = data?.summaries?.BLOOD_PRESSURE_DIA; @@ -297,6 +310,12 @@ export default function DashboardPage() { // the layout-toggle gate here. No data-floor check (the empty card is // intentional — Marc wants the user to discover the feature). const showAchievementsCard = isChartVisible("achievements"); + // v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard + // insights preview by the layout toggle. The component itself + // returns null when the advisor payload is missing or has no + // recommendations, so the gate is sufficient — no extra data-floor + // check needed. + const showInsightsPreview = isChartVisible("insightsPreview"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the @@ -1094,6 +1113,19 @@ export default function DashboardPage() { ))} )} + {/* v1.4.16 phase D reconcile (CRITICAL C2) — dashboard + preview of the polished AI recommendations surface. + Pinned above the chart row (out-of-band from the sorted + charts[] array) so it stays at the top regardless of + widget reorder operations. Self-hides when the user has + no provider configured OR no recommendations to surface + (`` returns null), so the preview + doesn't paint an empty card on first visit. */} + {showInsightsPreview && ( + + )} {charts.map((entry) => (
{entry.node} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 9fd963db6..e6a7b42dc 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -43,6 +43,10 @@ const WIDGET_LABEL_KEYS: Record = { bpInTarget: "dashboard.bpInTarget", oxygenSaturation: "measurements.typeOxygenSaturation", achievements: "achievements.title", + // v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview tile + // for the polished `` (top severity-ordered AI + // recommendations + ring confidence meter + "View all" CTA). + insightsPreview: "dashboard.insightsPreview", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 8e4acc5dd..96d97ea36 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -141,6 +141,15 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { expect(ids).toContain("achievements"); }); + it("includes the insightsPreview widget (v1.4.16 phase D reconcile C2)", () => { + // CRITICAL C2: dashboard preview of the polished AI recommendations + // surface. Same Zod-enum-drift trap as A5 — if the widget id isn't + // listed here, the API PUT silently 422s and the layout toggle + // never persists. Pin the contract to catch a regression early. + const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id); + expect(ids).toContain("insightsPreview"); + }); + it("survives a round-trip through serialize → resolve unchanged", () => { // The API persists `serializeDashboardLayout(parsed.data)` and // reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`. diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 28f9e8850..320028afc 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -29,6 +29,11 @@ export const DASHBOARD_WIDGET_IDS = [ "bpInTarget", "oxygenSaturation", "achievements", + // v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview of the + // top severity-ordered AI recommendations + "View all" CTA. Has only + // a `visible` (chart-row) surface; tileVisible is forced false because + // there's no tile-strip representation for it. + "insightsPreview", ] as const; export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; @@ -124,6 +129,17 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // `tileVisible` is forced false because there is no tile surface // for this widget; only the chart-row card. { id: "achievements", visible: true, tileVisible: false, order: 13 }, + // v1.4.16 phase D reconcile (CRITICAL C2) — AI insights preview + // anchored at the top of the chart row. Default visible so users + // discover the feature without hunting through Settings → + // Dashboard; self-gates on having a generated payload AND at least + // one recommendation (`` returns null + // otherwise). `tileVisible` forced false because the preview lives + // in the chart row, not the tile strip. The dashboard page + // renders this entry independently of the sorted `charts[]` array + // so it stays pinned above charts even if the user reorders other + // widgets; only the boolean visibility flag is consumed. + { id: "insightsPreview", visible: true, tileVisible: false, order: 14 }, ], };