From 52edf85f0285debea8797b2300f479a372eeee1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 01:19:14 +0200 Subject: [PATCH] chore(insights): retire the InsightAdvisorCard surface Drop the InsightAdvisorCard component, its four integration tests under src/components/insights/__tests__, the page-level mount on /insights, the orphan i18n keys (advisorTitle, aiOverviewTitle), the e2e advisor-card spec, the dashboard.spec.ts advisor assertion and every related comment. The InsightsTabStrip regenerate icon stays untouched as the sole power-user refresh affordance. The iOS-consumed POST /api/insights/generate endpoint and its request/response shape stay byte-stable. --- e2e/dashboard.spec.ts | 102 +-- e2e/insights-advisor-card.spec.ts | 245 ------ messages/de.json | 2 - messages/en.json | 2 - messages/es.json | 2 - messages/fr.json | 2 - messages/it.json | 2 - messages/pl.json | 2 - src/app/__tests__/insights-polish.test.ts | 45 +- src/app/insights/page.tsx | 9 - .../__tests__/insight-advisor-card.test.tsx | 323 -------- .../insight-citation-footnote.test.tsx | 164 ---- ...ation-card-confidence-integration.test.tsx | 179 ---- .../recommendation-card-integration.test.tsx | 193 ----- .../recommendations-grid-integration.test.tsx | 170 ---- .../insights/insight-advisor-card.tsx | 776 ------------------ .../insights/use-insights-advisor.ts | 6 +- .../insights/__tests__/chart-tokens.test.ts | 10 +- src/lib/query-keys.ts | 7 +- 19 files changed, 32 insertions(+), 2209 deletions(-) delete mode 100644 e2e/insights-advisor-card.spec.ts delete mode 100644 src/components/insights/__tests__/insight-advisor-card.test.tsx delete mode 100644 src/components/insights/__tests__/insight-citation-footnote.test.tsx delete mode 100644 src/components/insights/__tests__/recommendation-card-confidence-integration.test.tsx delete mode 100644 src/components/insights/__tests__/recommendation-card-integration.test.tsx delete mode 100644 src/components/insights/__tests__/recommendations-grid-integration.test.tsx delete mode 100644 src/components/insights/insight-advisor-card.tsx diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 53465f41c..944b6ac39 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -110,83 +110,13 @@ test.describe("authenticated dashboard render", () => { }); }); - // The /insights mother page gates the InsightAdvisorCard on - // `/api/insights/comprehensive` returning a non-null payload - // (`totalMeasurements` is the gate). Mock it so the page renders - // the card surface instead of the EmptyState CTA. - await page.route("**/api/insights/comprehensive", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - summaries: {}, - bmi: 24.2, - bmiClassification: null, - bpClassification: null, - bpPctInTarget: 78, - bpTargets: { sysLow: 120, sysHigh: 140, diaLow: 80, diaHigh: 90 }, - weightBpCorrelation: null, - scatterData: [], - bpMedicationCorrelation: null, - bpMedicationScatterData: [], - medications: [], - alerts: [], - hasOpenAiKey: true, - dataSpanDays: 90, - totalMeasurements: 25, - moodSummary: null, - moodBpCorrelation: null, - moodBpScatterData: [], - moodWeightCorrelation: null, - moodWeightScatterData: [], - moodPulseCorrelation: null, - moodPulseScatterData: [], - }, - error: null, - }), - }), - ); - - // InsightAdvisorCard reads `/api/insights/generate` (a POST that - // doubles as cache-aware read). Mock a minimal-but-valid - // InsightResult so the card renders the populated body branch with - // the `data-slot="insight-summary"` container the assertion below - // pins on. The exact prose is intentionally NOT asserted — AI - // Insights are HealthLog's differentiator and the rendered text - // depends on provider + cache state. The contract under test is - // "an insight card with a populated body is visible", not "the - // string X is on screen". - await page.route("**/api/insights/generate", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - insights: { - insightType: "general", - summary: "Blood-pressure trend is stable; continue routine.", - classification: "gut", - classificationLabel: "Good", - findings: [], - correlations: [], - primaryRecommendation: "Keep up the current routine.", - recommendations: [], - dataQuality: { - coverage: "good", - gaps: [], - confidence: "hoch", - }, - disclaimer: "Not medical advice.", - }, - cached: true, - cachedAt: new Date().toISOString(), - legacyPayload: false, - }, - error: null, - }), - }), - ); + // v1.4.28 retired the InsightAdvisorCard surface. Coach drawer + // remains the conversational entry point on /insights, but the + // bottom-of-page card with its own summary container is gone. The + // dashboard render assertion below no longer touches /insights, so + // we don't need to mock /api/insights/comprehensive or + // /api/insights/generate any more (the iOS-consumed POST endpoint + // is preserved server-side; only the web mount retired). }); test("dashboard tile strip + chart + insight card render", async ({ @@ -219,20 +149,10 @@ test.describe("authenticated dashboard render", () => { page.locator(".recharts-wrapper, .recharts-surface").first(), ).toBeVisible({ timeout: 10_000 }); - // AI insight card: visit the dedicated /insights page (the - // mother surface that mounts the InsightAdvisorCard). We assert - // on the populated-card's stable structural hook - // (`data-slot="insight-summary"`) rather than a literal piece of - // AI prose. AI Insights are evidence-grounded and dynamic by - // design — the rendered text changes per provider + cache state - // and never repeats verbatim, so pinning a literal substring is - // brittle and contradicts the differentiator. The contract under - // test is "the insight card mounts its summary body when the - // payload arrives", which is exactly what the slot signals. - await page.goto("/insights", { waitUntil: "domcontentloaded" }); - await expect( - page.locator('[data-slot="insight-summary"]'), - ).toBeVisible({ timeout: 10_000 }); + // v1.4.28 — the bottom-of-page InsightAdvisorCard retired; the + // dashboard spec no longer asserts on its summary slot. The hero + // strip and the Coach drawer carry the differentiator on /insights + // and are covered by their own specs. // Filter out a small set of known-noise messages — we care about // real React/JS uncaught errors only. 404s from optional assets diff --git a/e2e/insights-advisor-card.spec.ts b/e2e/insights-advisor-card.spec.ts deleted file mode 100644 index 10c6b62c2..000000000 --- a/e2e/insights-advisor-card.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { expect, test } from "@playwright/test"; - -import { STORAGE_STATE_PATH } from "./setup/global-setup"; - -/** - * v1.4.16 phase D reconcile (CRITICAL C1) — proves the polished - * `` is mounted on `/insights` and consumes the - * `/api/insights/generate` payload. - * - * Before this fix the page rendered only `` (text- - * only summary per section); the rec card surface from B5c/d/e/B1b - * (severity-ordered grid + per-rec rationale + confidence meter + - * thumbs feedback + medical-citation footnote) was reachable only from - * unit tests. This spec asserts the live route now reaches it. - */ -test.describe("insights advisor card on /insights (C1)", () => { - test.use({ storageState: STORAGE_STATE_PATH }); - - test.beforeEach(async ({ page }) => { - // Stub the analytics + comprehensive endpoints with a small but - // populated dataset so the page clears the `if (!data)` empty - // gate and renders the body. - 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, - }, - BLOOD_PRESSURE_SYS: { - latest: 124, - avg7: 122, - avg30: 121, - slope30: { slope: 0.1, direction: "flat" }, - count: 25, - }, - BLOOD_PRESSURE_DIA: { - latest: 80, - avg7: 79, - avg30: 78, - slope30: { slope: 0.05, direction: "flat" }, - count: 25, - }, - PULSE: { - latest: 68, - avg7: 70, - avg30: 71, - slope30: { slope: -0.2, direction: "down" }, - count: 25, - }, - }, - bpInTargetPct: 78, - glucoseByContext: {}, - }, - error: null, - }), - }), - ); - - await page.route("**/api/insights/comprehensive", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - summaries: {}, - bmi: 24.2, - bmiClassification: null, - bpClassification: null, - bpPctInTarget: 78, - bpTargets: { sysLow: 120, sysHigh: 140, diaLow: 80, diaHigh: 90 }, - weightBpCorrelation: null, - scatterData: [], - bpMedicationCorrelation: null, - bpMedicationScatterData: [], - medications: [], - alerts: [], - hasOpenAiKey: true, - dataSpanDays: 90, - totalMeasurements: 25, - moodSummary: null, - moodBpCorrelation: null, - moodBpScatterData: [], - moodWeightCorrelation: null, - moodWeightScatterData: [], - moodPulseCorrelation: null, - moodPulseScatterData: [], - }, - error: null, - }), - }), - ); - - // Per-status cards still render below the advisor card; mock them - // to a simple connected payload so the section bodies don't bail. - for (const slug of [ - "general-status", - "blood-pressure-status", - "weight-status", - "pulse-status", - "bmi-status", - "mood-status", - "medication-compliance-status", - ]) { - await page.route(`**/api/insights/${slug}*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - hasProvider: true, - text: `Mock ${slug} text.`, - summary: `Mock ${slug} text.`, - cached: true, - updatedAt: new Date().toISOString(), - medications: [], - }, - error: null, - }), - }), - ); - } - - 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 }, - ], - 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, - }), - }), - ); - - // The advisor query — return a populated InsightResult with one - // urgent rec carrying rationale + confidence so the rec card has - // something to render. - await page.route("**/api/insights/generate", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - insights: { - insightType: "general", - summary: "Your blood pressure has improved slightly.", - classification: "gut", - classificationLabel: "Good", - findings: [ - { - label: "BP avg dropped 2 mmHg", - value: "120/78", - assessment: "positive", - }, - ], - correlations: [], - primaryRecommendation: "Keep up the current routine.", - recommendations: [ - { - id: "rec-1", - text: "Consider reducing sodium intake on weekends.", - severity: "important", - confidence: 78, - rationale: { - dataWindow: "last30days", - comparedTo: "your 90-day median", - deviation: "BP averages drift up on weekends", - }, - metricSource: { - type: "bloodPressureSys", - timeRange: "last30days", - summary: "Sys BP avg 124 vs 121 weekly median", - }, - }, - ], - dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, - disclaimer: "Not medical advice.", - }, - cached: true, - cachedAt: new Date().toISOString(), - legacyPayload: false, - }, - error: null, - }), - }), - ); - }); - - test("renders InsightAdvisorCard with severity-ordered recommendation grid", async ({ - page, - }) => { - await page.goto("/insights", { waitUntil: "domcontentloaded" }); - - // Hero + advisor card both render. v1.4.20 B1 replaced the legacy - // `` (data-slot="insights-page-hero") with the - // condensed `` (data-slot="insights-hero-strip"); the - // advisor card is the C1 deliverable below it. - await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible( - { - timeout: 10_000, - }, - ); - - // The recommendation card surface from B5c — `data-slot` markers - // are pinned in tests, look for the rec text the mock provided. - await expect( - page.getByText("Consider reducing sodium intake on weekends."), - ).toBeVisible({ timeout: 10_000 }); - - // ConfidenceMeter is mounted in the rec-card slot; B5d marks the - // component with `data-slot="confidence-meter"`. - await expect( - page.locator('[data-slot="confidence-meter"]').first(), - ).toBeVisible({ timeout: 10_000 }); - - // Summary surface from the advisor card carries the prose. - await expect( - page.getByText("Your blood pressure has improved slightly."), - ).toBeVisible({ timeout: 10_000 }); - }); -}); diff --git a/messages/de.json b/messages/de.json index 85c334109..968538471 100644 --- a/messages/de.json +++ b/messages/de.json @@ -894,8 +894,6 @@ "notAvailableForCompliance": "Vergleich für Compliance nicht verfügbar" }, "insights": { - "advisorTitle": "Persönlicher Berater", - "aiOverviewTitle": "Persönlicher Berater", "emptyTitle": "Noch nicht genug Daten", "emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.", "emptyAddMeasurement": "Messwert erfassen", diff --git a/messages/en.json b/messages/en.json index 825fc8add..d877884e6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -894,8 +894,6 @@ "notAvailableForCompliance": "Comparison N/A for compliance" }, "insights": { - "advisorTitle": "Personal health advisor", - "aiOverviewTitle": "Personal advisor", "noAnalysisYet": "No analysis yet.", "emptyTitle": "Not enough data yet", "emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.", diff --git a/messages/es.json b/messages/es.json index fcd9146e3..ac7d4c25a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -894,8 +894,6 @@ "notAvailableForCompliance": "Comparison N/A for compliance" }, "insights": { - "advisorTitle": "Asesor personal de salud", - "aiOverviewTitle": "Personal advisor", "noAnalysisYet": "No analysis yet.", "emptyTitle": "Not enough data yet", "emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.", diff --git a/messages/fr.json b/messages/fr.json index ff0afd091..65b92f416 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -894,8 +894,6 @@ "notAvailableForCompliance": "Comparison N/A for compliance" }, "insights": { - "advisorTitle": "Conseiller santé personnel", - "aiOverviewTitle": "Personal advisor", "noAnalysisYet": "No analysis yet.", "emptyTitle": "Not enough data yet", "emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.", diff --git a/messages/it.json b/messages/it.json index 781ff5a8e..7acbd1cea 100644 --- a/messages/it.json +++ b/messages/it.json @@ -894,8 +894,6 @@ "notAvailableForCompliance": "Comparison N/A for compliance" }, "insights": { - "advisorTitle": "Consulente personale di salute", - "aiOverviewTitle": "Personal advisor", "noAnalysisYet": "No analysis yet.", "emptyTitle": "Not enough data yet", "emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.", diff --git a/messages/pl.json b/messages/pl.json index ef9e1bcd2..0be73af6f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -894,8 +894,6 @@ "notAvailableForCompliance": "Comparison N/A for compliance" }, "insights": { - "advisorTitle": "Osobisty doradca zdrowotny", - "aiOverviewTitle": "Personal advisor", "noAnalysisYet": "No analysis yet.", "emptyTitle": "Not enough data yet", "emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.", diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index cbdbb6533..864666549 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -12,19 +12,12 @@ import { join } from "node:path"; * The underlying `compareBaseline` value is still consumed by * every dashboard chart; only the on-surface affordance is gone. * 2. The `/insights` page renders exactly ONE page-level refresh - * affordance — the hero's regenerate button. The advisor card - * no longer carries its own onRegenerate handler that - * duplicated the hero one and again surfaced "Analyse starten" - * in the empty-state CTA. The per-recommendation Regenerate - * stays — that's per-card, not page-level. + * affordance — the regenerate icon on ``. The + * hero strip no longer takes an onRegenerate prop. v1.4.28 retired + * the InsightAdvisorCard surface entirely. * 3. The `/insights` page does NOT render the small BP / Weight / * Pulse trend tiles — those duplicate the dashboard tile-strip. - * 4. The `/insights` page does NOT pass the `aiOverviewTitle` - * ("Persönlicher Berater") subtitle into ``. - * The CardTitle ("KI-Gesundheitsanalyse") is sufficient framing; - * the second subtitle was an empty-section placeholder the maintainer - * called "Titel da aber NICHTS PASSIERT". - * 5. The `stripChartTokens()` matcher catches lowercase + mixed- + * 4. The `stripChartTokens()` matcher catches lowercase + mixed- * case AI-emitted tokens (`metric:blood_pressure_sweet_spot`), * so the literal token text never reaches the DOM. The * *render-side* parse matcher stays uppercase-only — only the @@ -100,22 +93,13 @@ describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => { }); describe("v1.4.19 A3 — /insights polish", () => { - it("does NOT mount the legacy advisor-card regenerate handler", () => { + it("does NOT mount anywhere (v1.4.28 retire)", () => { const src = load(INSIGHTS_PATH); - // The advisor card no longer carries its own onRegenerate handler - // that duplicated the hero one. v1.4.20 phase B1 added a second - // wiring (the empty-state CTA), which only renders - // while the briefing payload is null — its purpose is "generate - // today's briefing" rather than "refresh", and it disappears the - // moment a briefing exists. The original v1.4.19 ratchet was the - // advisor-card duplication; that one stays banned. - // - // We extract the InsightAdvisorCard JSX block (everything up to - // the closing `/>`) and assert no `onRegenerate` prop appears - // inside it. - const advisorBlock = src.match(/]*\/>/); - expect(advisorBlock).not.toBeNull(); - expect(advisorBlock?.[0]).not.toContain("onRegenerate"); + // v1.4.28 retired the InsightAdvisorCard surface. The component + // file, every test fixture and the page-level mount are gone; + // Coach drawer is the sole conversational entry point on /insights. + expect(src).not.toContain("InsightAdvisorCard"); + expect(src).not.toContain("insight-advisor-card"); }); it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W4)", () => { @@ -152,15 +136,6 @@ describe("v1.4.19 A3 — /insights polish", () => { expect(src).not.toMatch(/", () => { - const src = load(INSIGHTS_PATH); - // The "Persönlicher Berater" / "Personal advisor" string was a - // dead title above an empty-state body when the user had no - // provider configured. Removed; the CardTitle inside the advisor - // card already says "KI-Gesundheitsanalyse". - expect(src).not.toContain('t("insights.aiOverviewTitle")'); - }); - it("does NOT contain raw `metric:` template strings in static prose", () => { const src = load(INSIGHTS_PATH); // The chart-token pattern (`metric:WEIGHT`) is reserved for AI diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index c166a6e70..dfb164401 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -9,7 +9,6 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; -import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card"; import { HeroStrip } from "@/components/insights/hero-strip"; import { DailyBriefing } from "@/components/insights/daily-briefing"; import { TrendsRow } from "@/components/insights/trends-row"; @@ -208,14 +207,6 @@ export default function InsightsPage() { )} - - ); } diff --git a/src/components/insights/__tests__/insight-advisor-card.test.tsx b/src/components/insights/__tests__/insight-advisor-card.test.tsx deleted file mode 100644 index 4853afe37..000000000 --- a/src/components/insights/__tests__/insight-advisor-card.test.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -// HealthChart pulls in TanStack Query + recharts. We don't care about its -// internal rendering — we only need to know *that it was rendered* and -// with which `types[]` (which is the chart's metric handle, since the -// real component takes `types: string[]` not `metric: string`). -vi.mock("@/components/charts/health-chart", () => ({ - HealthChart: ({ types, title }: { types: string[]; title: string }) => ( -
- chart:{title} -
- ), -})); - -// Stub TanStack Query — InsightAdvisorCard itself doesn't query, but -// nested clients (and the HealthChart mock above replaces the real one) -// pull from this. Safe no-op. -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: null, isLoading: false }), - useMutation: () => ({ - mutate: vi.fn(), - isPending: false, - isSuccess: false, - isError: false, - error: null, - }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { id: "test-user", username: "tester", role: "USER" }, - isAuthenticated: true, - isLoading: false, - }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { InsightAdvisorCard } from "../insight-advisor-card"; -import type { InsightResult } from "@/lib/ai/types"; - -function buildInsight(overrides: Partial = {}): InsightResult { - return { - insightType: "general", - summary: "All good", - classification: "gut", - classificationLabel: "Good", - findings: [ - { - label: "Heart rate stable", - value: "72 bpm", - assessment: "positive", - }, - ], - correlations: [], - primaryRecommendation: "Keep going", - recommendations: [], - dataQuality: { - coverage: "good", - gaps: [], - confidence: "hoch", - }, - disclaimer: "Not a substitute for medical advice.", - ...overrides, - }; -} - -function render(insight: InsightResult) { - return renderToStaticMarkup( - - - , - ); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe(" — inline chart tokens", () => { - it("renders an inline HealthChart for an allowlisted token in summary", () => { - const html = render( - buildInsight({ - summary: "BP looking great metric:BLOOD_PRESSURE_SYS today", - }), - ); - - // Chart is rendered with the token's metric as the type. - expect(html).toContain('data-testid="health-chart"'); - expect(html).toContain('data-types="BLOOD_PRESSURE_SYS"'); - - // Token literal is stripped from the visible prose. - expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS"); - - // Surrounding prose survives unchanged. - expect(html).toContain("BP looking great"); - expect(html).toContain("today"); - }); - - it("drops hallucinated tokens silently and still strips the literal", () => { - const html = render( - buildInsight({ - summary: "metric:NUKE try this", - }), - ); - - // No chart rendered. - expect(html).not.toContain('data-testid="health-chart"'); - - // Token literal is gone from the DOM. - expect(html).not.toContain("metric:NUKE"); - - // Visible text is the surrounding prose only. - expect(html).toContain("try this"); - }); - - it("treats injection attempts as plain text — no script, no markup", () => { - const html = render( - buildInsight({ - summary: "metric:WEIGHT' onclick='alert(1)'", - }), - ); - - // The chart still renders for the safe metric prefix. - expect(html).toContain('data-testid="health-chart"'); - expect(html).toContain('data-types="WEIGHT"'); - - // The trailing junk is preserved as escaped text — no script tag, - // no live `onclick` attribute survives the React text path. - expect(html).not.toContain("]*\sonclick\s*=/i); - - // The escaped remnant still appears in the prose so the user can see - // what the model emitted (alert(1) is now inert HTML-encoded text). - expect(html).toContain("alert(1)"); - }); - - it("renders an inline chart attached to a hero finding token", () => { - const html = render( - buildInsight({ - findings: [ - { - label: "Pulse trending down metric:PULSE", - value: "68 bpm", - assessment: "positive", - }, - ], - }), - ); - - expect(html).toContain('data-types="PULSE"'); - expect(html).not.toContain("metric:PULSE"); - expect(html).toContain("Pulse trending down"); - }); - - it("renders inline charts for secondary findings without leaking tokens", () => { - const html = render( - buildInsight({ - findings: [ - { - label: "Hero finding", - value: "120/80", - assessment: "neutral", - }, - { - label: "Weight stable metric:WEIGHT", - value: "82 kg", - assessment: "positive", - }, - ], - }), - ); - - expect(html).toContain('data-types="WEIGHT"'); - expect(html).not.toContain("metric:WEIGHT"); - expect(html).toContain("Weight stable"); - }); -}); - -describe(" — summary typography polish (B1b)", () => { - it("paints the summary slot with a constrained max-width for readability", () => { - const html = render( - buildInsight({ - summary: "Your BP averaged 132/84 over the last 30 days.", - }), - ); - expect(html).toMatch(/data-slot="insight-summary"/); - // The summary slot carries a Tailwind max-w-* class so prose - // doesn't stretch full-bleed on a wide viewport. - expect(html).toMatch(/max-w-(prose|2xl|3xl)/); - }); - - it("uses the polished prose typography (text-base, leading-relaxed)", () => { - const html = render( - buildInsight({ - summary: "All metrics within target.", - }), - ); - // A larger font + relaxed line-height for the page-anchored - // summary; the per-card content uses text-sm. - expect(html).toMatch( - /data-slot="insight-summary"[^>]*class="[^"]*text-base/, - ); - expect(html).toMatch( - /data-slot="insight-summary"[^>]*class="[^"]*leading-relaxed/, - ); - }); - - it("renders summary inline charts as mini sparklines (data-mini=true)", () => { - const html = render( - buildInsight({ - summary: "BP averaging 132/84 metric:BLOOD_PRESSURE_SYS today", - }), - ); - // The summary's inline-chart wrapper must carry `data-mini="true"` - // so the chart shrinks to sparkline form. Per-finding charts use - // the default detail mode. - expect(html).toMatch(/data-slot="insight-summary"[\s\S]*?data-mini="true"/); - }); -}); - -describe(" — polished loading / empty / error states (B1b)", () => { - it("loading state shows a skeleton that mirrors the final layout", () => { - const html = renderToStaticMarkup( - - - , - ); - // A discoverable skeleton slot replaces the spinner-only loading state. - expect(html).toMatch(/data-slot="insight-skeleton"/); - // 3 placeholder rec rows match the final card grid (so the page - // doesn't visually jump when content lands). - expect(html).toMatch(/data-slot="insight-skeleton-card"/); - }); - - it("empty state shows a friendly illustration + CTA", () => { - const html = renderToStaticMarkup( - - {}} - /> - , - ); - expect(html).toMatch(/data-slot="insight-empty-state"/); - // The Start-Analysis button stays — that's the CTA. - expect(html).toContain("Start analysis"); - }); - - it("error state surfaces a retry button when onRegenerate is provided", () => { - const html = renderToStaticMarkup( - - {}} - /> - , - ); - expect(html).toMatch(/data-slot="insight-error-state"/); - expect(html).toContain("Something went wrong"); - expect(html).toMatch(/data-slot="insight-retry-button"/); - }); - - it("renders the legacy v1.4.14 payload without crashing (regenerate CTA)", () => { - // Reproduces the maintainer's production /insights crash on 2026-05-10: - // - // TypeError: Cannot read properties of undefined (reading 'replace') - // at stripChartTokens(insight.summary) - // - // The cached blob in the DB predates v1.4.16's strict insight schema - // (no `summary`, no `recommendations[]`, no `findings[]`, no - // `dataQuality`, no `disclaimer` — it carries the old `changed`, - // `stable`, `drivers`, `nextSteps`, `confidence`, `limitations` - // shape). The route's `safeParse(parsed)` fails and falls through - // to `insights = parsed`, so the legacy blob reaches the card with - // `summary === undefined`. The card must surface a regenerate CTA - // and skip rendering the prose, NOT crash. - const legacy = { - changed: "Long-term improvement on weight and BP.", - stable: "Pulse remains stable.", - drivers: "Weight reduction may have contributed.", - nextSteps: "Keep going.", - confidence: "hoch", - limitations: "Correlations don't imply causation.", - } as unknown as InsightResult; - - const html = renderToStaticMarkup( - - {}} - /> - , - ); - - // No crash means the test got here. Surface the regenerate CTA so - // the user has a one-click escape from the legacy state. - expect(html).toContain('data-slot="insight-legacy-payload-cta"'); - // F-06 (v1.4.19): the CTA copy says "regenerate", so the button - // label must say "Regenerate" too — not "Start analysis". - expect(html).toContain("Regenerate"); - }); - - it("error state without onRegenerate hides the retry button", () => { - const html = renderToStaticMarkup( - - - , - ); - expect(html).toContain("No retry available"); - expect(html).not.toContain('data-slot="insight-retry-button"'); - }); -}); diff --git a/src/components/insights/__tests__/insight-citation-footnote.test.tsx b/src/components/insights/__tests__/insight-citation-footnote.test.tsx deleted file mode 100644 index b989d8dc2..000000000 --- a/src/components/insights/__tests__/insight-citation-footnote.test.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -// HealthChart pulls in TanStack Query + recharts. Stub it for the -// same reasons the sibling card test does. -vi.mock("@/components/charts/health-chart", () => ({ - HealthChart: ({ types, title }: { types: string[]; title: string }) => ( -
- chart:{title} -
- ), -})); - -vi.mock("@/components/charts/mood-chart", () => ({ - MoodChart: () =>
, -})); - -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: null, isLoading: false }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { InsightAdvisorCard } from "../insight-advisor-card"; -import type { InsightResult } from "@/lib/ai/types"; -import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references"; - -const knownRef = MEDICAL_REFERENCES[0]; - -function buildInsight(overrides: Partial = {}): InsightResult { - return { - insightType: "general", - summary: "All good", - classification: "gut", - classificationLabel: "Good", - findings: [ - { - label: "Heart rate stable", - value: "72 bpm", - assessment: "positive", - }, - ], - correlations: [], - primaryRecommendation: "Keep going", - recommendations: [], - dataQuality: { - coverage: "good", - gaps: [], - confidence: "hoch", - }, - disclaimer: "Not a substitute for medical advice.", - ...overrides, - }; -} - -function render(insight: InsightResult, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe(" — recommendation citation footnote (B5a)", () => { - it("renders no footnote for legacy string recommendations", () => { - const html = render( - buildInsight({ - recommendations: ["Continue daily logging."], - }), - ); - expect(html).toContain("Continue daily logging."); - expect(html).not.toContain('data-slot="insight-recommendation-source"'); - expect(html).not.toContain("Source:"); - }); - - it("renders no footnote for structured rec WITHOUT a referenceId", () => { - const html = render( - buildInsight({ - recommendations: [{ text: "Keep an eye on your sleep." }], - }), - ); - expect(html).toContain("Keep an eye on your sleep."); - expect(html).not.toContain('data-slot="insight-recommendation-source"'); - }); - - it("renders the footnote with EN title + URL when referenceId resolves", () => { - const html = render( - buildInsight({ - recommendations: [ - { - text: "Aim for a target below 140/90.", - referenceId: knownRef.id, - }, - ], - }), - "en", - ); - expect(html).toContain('data-slot="insight-recommendation-source"'); - expect(html).toContain(`data-reference-id="${knownRef.id}"`); - expect(html).toContain(knownRef.title); - expect(html).toContain(`${knownRef.publishedYear}`); - expect(html).toContain(knownRef.org); - expect(html).toContain(`href="${knownRef.url}"`); - expect(html).toContain('target="_blank"'); - expect(html).toContain('rel="noreferrer"'); - expect(html).toContain("Source:"); - }); - - it("renders the German title under de locale", () => { - const html = render( - buildInsight({ - recommendations: [ - { - text: "Aim for a target below 140/90.", - referenceId: knownRef.id, - }, - ], - }), - "de", - ); - expect(html).toContain(knownRef.titleDe); - // German "Source" label - expect(html).toContain("Quelle:"); - }); - - it("silently drops the footnote for an unknown referenceId (defence in depth)", () => { - // The schema would normally reject this payload, but if a stale - // payload sneaks through, the UI must not render a broken link. - const html = render( - buildInsight({ - recommendations: [ - { - text: "Aim for something.", - referenceId: "fabricated-2099", - }, - ], - }), - ); - expect(html).toContain("Aim for something."); - expect(html).not.toContain('data-slot="insight-recommendation-source"'); - expect(html).not.toContain("Source:"); - }); - - it("renders multiple recs, each with their own footnote when applicable", () => { - const second = MEDICAL_REFERENCES[1] ?? knownRef; - const html = render( - buildInsight({ - recommendations: [ - { text: "First rec", referenceId: knownRef.id }, - { text: "Second rec", referenceId: second.id }, - "Third plain rec", - ], - }), - ); - expect(html).toContain("First rec"); - expect(html).toContain("Second rec"); - expect(html).toContain("Third plain rec"); - expect(html).toContain(knownRef.url); - if (second !== knownRef) expect(html).toContain(second.url); - // The plain string rec produces no footnote. - const matches = html.match(/data-slot="insight-recommendation-source"/g); - expect(matches?.length ?? 0).toBe(second !== knownRef ? 2 : 2); - }); -}); diff --git a/src/components/insights/__tests__/recommendation-card-confidence-integration.test.tsx b/src/components/insights/__tests__/recommendation-card-confidence-integration.test.tsx deleted file mode 100644 index fdc8b3a29..000000000 --- a/src/components/insights/__tests__/recommendation-card-confidence-integration.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -vi.mock("@/components/charts/health-chart", () => ({ - HealthChart: () =>
, -})); - -vi.mock("@/components/charts/mood-chart", () => ({ - MoodChart: () =>
, -})); - -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: null, isLoading: false }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { InsightAdvisorCard } from "../insight-advisor-card"; -import type { InsightResult } from "@/lib/ai/types"; -import { generateInsight } from "@/lib/ai/generate-insight"; -import { MockAIProvider } from "@/lib/ai/mock-client"; - -/** - * v1.4.16 phase B5d — full-flow integration. - * - * MockAIProvider returns a payload where the model claims a high - * `confidence: 99`. The wrapper MUST overwrite that with the - * deterministic `computeConfidence()` value, which (with the default - * resolver: n=9, recencyDays=0, ratio=null) yields: - * - * nScore = 10 + 10*log10(9) ≈ 19.54 - * recency = 30 (recencyDays=0) - * signal = 15 (null) - * total ≈ 64.54 → 65 - * - * After generation, we mount InsightAdvisorCard with the parsed payload - * and assert the rendered ConfidenceMeter band corresponds to the - * server-computed value (medium/yellow), NOT the model's claimed 99 - * (which would have rendered the green high band). - */ - -const strictPayloadWithModelConfidence = { - summary: "BP runs slightly above baseline.", - recommendations: [ - { - id: "rec-1", - text: "Discuss home BP log with your physician.", - severity: "important", - // Model claims very high confidence — wrapper overrides. - confidence: 99, - metricSource: { - type: "bloodPressure", - timeRange: "last7days", - summary: "avg 138/86 across 9 readings", - n: 9, - }, - rationale: { - dataWindow: "last7days", - comparedTo: "your 90-day median (122/78)", - deviation: "+16/+8 mmHg above baseline over 9 of 9 readings", - }, - }, - ], - citations: [ - { - type: "bloodPressure", - timeRange: "last7days", - summary: "avg 138/86 across 9 readings", - }, - ], - warnings: [], -}; - -describe("end-to-end — generateInsight() confidence override flows into the card", () => { - it("renders the deterministic confidence (NOT the model-supplied 99) on the card", async () => { - const provider = new MockAIProvider({ - responses: JSON.stringify(strictPayloadWithModelConfidence), - }); - - const outcome = await generateInsight(provider, { - systemPrompt: "system", - userPrompt: "user", - }); - - const rec = outcome.parsed.recommendations[0]; - // Server-computed value at n=9, recencyDays=0, ratio=null = 65 - expect(rec.confidence).toBe(65); - expect(rec.confidence).not.toBe(99); - - const insight: InsightResult = { - insightType: "general", - summary: outcome.parsed.summary, - classification: "grenzwertig", - classificationLabel: "Borderline", - findings: [], - correlations: [], - recommendations: outcome.parsed.recommendations.map((r) => ({ - text: r.text, - referenceId: r.referenceId, - rationale: r.rationale, - metricSource: r.metricSource, - severity: r.severity, - id: r.id, - confidence: r.confidence, - })), - dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, - disclaimer: "Not a substitute for medical advice.", - }; - - const html = renderToStaticMarkup( - - - , - ); - - // Score 65 → medium / yellow band, NOT high / green. - expect(html).toContain('data-confidence-band="medium"'); - expect(html).not.toContain('data-confidence-band="high"'); - // aria-label must announce the SERVER-computed score, not 99. - expect(html).toContain('aria-label="Confidence: 65 of 100"'); - expect(html).not.toContain("99 of 100"); - }); - - it("falls into the draft band when sample count is too thin (n<3)", async () => { - const tinyN = { - ...strictPayloadWithModelConfidence, - recommendations: [ - { - ...strictPayloadWithModelConfidence.recommendations[0], - // Model still claims 99 — wrapper still discards. - confidence: 99, - metricSource: { - ...strictPayloadWithModelConfidence.recommendations[0].metricSource, - n: 1, - }, - }, - ], - }; - const provider = new MockAIProvider({ - responses: JSON.stringify(tinyN), - }); - const outcome = await generateInsight(provider, { - systemPrompt: "s", - userPrompt: "u", - }); - - expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual( - 15, - ); - - const insight: InsightResult = { - insightType: "general", - summary: "x", - classification: "gut", - classificationLabel: "Good", - findings: [], - correlations: [], - recommendations: outcome.parsed.recommendations.map((r) => ({ - text: r.text, - rationale: r.rationale, - metricSource: r.metricSource, - severity: r.severity, - id: r.id, - confidence: r.confidence, - })), - dataQuality: { coverage: "good", gaps: [], confidence: "gering" }, - disclaimer: "x", - }; - - const html = renderToStaticMarkup( - - - , - ); - - expect(html).toContain('data-confidence-band="draft"'); - expect(html).toMatch(/Draft/); - }); -}); diff --git a/src/components/insights/__tests__/recommendation-card-integration.test.tsx b/src/components/insights/__tests__/recommendation-card-integration.test.tsx deleted file mode 100644 index fffc69b07..000000000 --- a/src/components/insights/__tests__/recommendation-card-integration.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -vi.mock("@/components/charts/health-chart", () => ({ - HealthChart: ({ - types, - title, - mini, - windowOverride, - }: { - types: string[]; - title: string; - mini?: boolean; - windowOverride?: string; - }) => ( -
- chart:{title} -
- ), -})); - -vi.mock("@/components/charts/mood-chart", () => ({ - MoodChart: ({ mini }: { mini?: boolean }) => ( -
- ), -})); - -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: null, isLoading: false }), - useMutation: () => ({ - mutate: vi.fn(), - isPending: false, - isSuccess: false, - isError: false, - error: null, - }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { id: "test-user", username: "tester", role: "USER" }, - isAuthenticated: true, - isLoading: false, - }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { InsightAdvisorCard } from "../insight-advisor-card"; -import type { InsightResult } from "@/lib/ai/types"; -import { generateInsight } from "@/lib/ai/generate-insight"; -import { MockAIProvider } from "@/lib/ai/mock-client"; -import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references"; - -/** - * v1.4.16 phase B5c — full-flow integration. - * - * Mocks an AIProvider that returns a structured payload with - * rationale on every rec, runs `generateInsight()` to validate + - * parse, then feeds the parsed payload through InsightAdvisorCard. - * Asserts the advisor card surfaces the rec text + rationale rows - * via the RecommendationCard subcomponent. - * - * The advisor card consumes the legacy `InsightResult` shape, not - * the strict `AIInsightResponse` — but the new field schema is - * optional on the UI type, so a recommendation flowing in as a - * structured object with rationale renders the expand-card. - */ - -const knownRefId = MEDICAL_REFERENCES[0].id; - -const strictPayload = { - summary: "Your blood pressure is elevated against your own baseline.", - recommendations: [ - { - id: "rec-1", - text: "Discuss home BP log with your physician.", - severity: "important", - metricSource: { - type: "bloodPressure", - timeRange: "last7days", - summary: "avg 138/86 across 9 readings", - n: 9, - }, - rationale: { - dataWindow: "last7days", - comparedTo: "your 90-day median (122/78)", - deviation: "+16/+8 mmHg above baseline over 9 of 9 readings", - }, - referenceId: knownRefId, - }, - ], - citations: [ - { - type: "bloodPressure", - timeRange: "last7days", - summary: "avg 138/86 across 9 readings", - }, - ], - warnings: [], -}; - -describe("end-to-end — generateInsight() + RecommendationCard render", () => { - it("strict payload from MockAIProvider passes parse + flows through to RecommendationCard rationale rows", async () => { - const provider = new MockAIProvider({ - responses: JSON.stringify(strictPayload), - }); - - const outcome = await generateInsight(provider, { - systemPrompt: "system", - userPrompt: "user", - }); - - expect(outcome.parsed.recommendations).toHaveLength(1); - const rec = outcome.parsed.recommendations[0]; - expect(rec.rationale).toEqual({ - dataWindow: "last7days", - comparedTo: "your 90-day median (122/78)", - deviation: "+16/+8 mmHg above baseline over 9 of 9 readings", - }); - - // Wrap as an InsightResult (legacy UI-shape) — only the - // recommendations need to carry the new fields for the rec card - // to render the expand panel. - const insight: InsightResult = { - insightType: "general", - summary: outcome.parsed.summary, - classification: "grenzwertig", - classificationLabel: "Borderline", - findings: [], - correlations: [], - recommendations: outcome.parsed.recommendations.map((r) => ({ - text: r.text, - referenceId: r.referenceId, - rationale: r.rationale, - metricSource: r.metricSource, - severity: r.severity, - id: r.id, - })), - dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, - disclaimer: "Not a substitute for medical advice.", - }; - - const html = renderToStaticMarkup( - - - , - ); - - // The rec card is collapsed by default — assert chevron present. - expect(html).toContain("Discuss home BP log"); - expect(html).toMatch(/aria-expanded="false"/); - // Severity badge - expect(html).toContain("important"); - }); - - it("legacyPayload flag surfaces the regenerate CTA", () => { - const insight: InsightResult = { - insightType: "general", - summary: "x", - classification: "gut", - classificationLabel: "Good", - findings: [], - correlations: [], - recommendations: ["Plain string rec"], - dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, - disclaimer: "Disclaimer", - }; - - const html = renderToStaticMarkup( - - {}} - /> - , - ); - - expect(html).toContain('data-slot="insight-legacy-payload-cta"'); - expect(html).toContain("Insights updated"); - }); -}); diff --git a/src/components/insights/__tests__/recommendations-grid-integration.test.tsx b/src/components/insights/__tests__/recommendations-grid-integration.test.tsx deleted file mode 100644 index a6b37959a..000000000 --- a/src/components/insights/__tests__/recommendations-grid-integration.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { InsightAdvisorCard } from "../insight-advisor-card"; -import type { InsightResult, InsightRecommendation } from "@/lib/ai/types"; - -/** - * v1.4.16 phase B1b — full-flow integration: rationale (B5c) + - * confidence (B5d) + feedback (B5e) + grid wrapper (B1b) all paint - * together inside InsightAdvisorCard. - * - * The B5c report flagged that an e2e couldn't be added because - * `/insights` doesn't currently mount InsightAdvisorCard in the - * production tree. This integration test exercises the same surface - * via SSR and pins the cross-feature interaction so the rec card - * shell + per-card affordances all render in the right slots when - * the polished grid sits between InsightAdvisorCard and the - * RecommendationCard. - */ - -vi.mock("@/components/charts/health-chart", () => ({ - HealthChart: ({ - types, - mini, - windowOverride, - }: { - types: string[]; - mini?: boolean; - windowOverride?: string; - }) => ( -
- ), -})); -vi.mock("@/components/charts/mood-chart", () => ({ - MoodChart: ({ mini }: { mini?: boolean }) => ( -
- ), -})); -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: null, isLoading: false }), - useMutation: () => ({ - mutate: vi.fn(), - isPending: false, - isSuccess: false, - isError: false, - error: null, - }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { id: "test-user", username: "tester", role: "USER" }, - isAuthenticated: true, - isLoading: false, - }), -})); - -function rec( - id: string, - severity: "info" | "suggestion" | "important" | "urgent", - text: string, - confidence: number, -): InsightRecommendation { - return { - id, - text, - severity, - confidence, - rationale: { - dataWindow: "last7days", - comparedTo: "your 90-day median", - deviation: "moderate deviation", - }, - metricSource: { - type: "weight", - timeRange: "last7days", - summary: "84.2 kg avg over 7 days", - }, - }; -} - -function buildInsight(recommendations: InsightRecommendation[]): InsightResult { - return { - insightType: "general", - summary: "Test summary", - classification: "gut", - classificationLabel: "Good", - findings: [], - correlations: [], - primaryRecommendation: "", - recommendations, - dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, - disclaimer: "Not medical advice.", - }; -} - -describe("InsightAdvisorCard + RecommendationsGrid full flow (B1b)", () => { - it("severity-orders the grid then paints confidence + feedback slots per card", () => { - const recs = [ - rec("a", "info", "info-text", 22), - rec("b", "urgent", "urgent-text", 88), - rec("c", "suggestion", "suggestion-text", 60), - ]; - const html = renderToStaticMarkup( - - - , - ); - - // Grid wrapper present. - expect(html).toMatch(/data-slot="rec-grid"/); - - // urgent appears before info in the rendered HTML. - expect(html.indexOf("urgent-text")).toBeLessThan(html.indexOf("info-text")); - expect(html.indexOf("urgent-text")).toBeGreaterThan(-1); - - // Each card carries the named slots from B5c/d/e. - expect(html).toMatch(/data-slot="rec-confidence-slot"/); - expect(html).toMatch(/data-slot="confidence-meter"/); - - // Severity-coloured borders all rendered (red, purple, cyan for the - // three severities we shipped). - expect(html).toMatch(/border-l-dracula-red/); - expect(html).toMatch(/border-l-dracula-purple/); - expect(html).toMatch(/border-l-dracula-cyan/); - - // 3 stagger indices on the wrapper rows. - expect(html).toMatch(/data-stagger-index="0"/); - expect(html).toMatch(/data-stagger-index="1"/); - expect(html).toMatch(/data-stagger-index="2"/); - }); - - it("confidence below 25 swaps the meter for a draft pill (still respects sort order)", () => { - const recs = [ - rec("a", "important", "important-text", 80), - rec("b", "urgent", "urgent-text", 12), // draft - ]; - const html = renderToStaticMarkup( - - - , - ); - - // Urgent rec still sorts first even with low confidence. - expect(html.indexOf("urgent-text")).toBeLessThan( - html.indexOf("important-text"), - ); - // Draft pill renders for confidence < 25. - expect(html).toMatch(/data-slot="confidence-meter"/); - expect(html).toContain("Draft"); - }); - - it("renders an empty grid when there are no recommendations", () => { - const html = renderToStaticMarkup( - - - , - ); - // Grid is unmounted entirely when recs.length is 0. - expect(html).not.toContain('data-slot="rec-grid"'); - // Recommendations heading not rendered without content underneath. - expect(html).not.toContain("Recommendations"); - }); -}); diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx deleted file mode 100644 index 2a3f3f684..000000000 --- a/src/components/insights/insight-advisor-card.tsx +++ /dev/null @@ -1,776 +0,0 @@ -"use client"; - -import { useState } from "react"; -import type { InsightResult, InsightFinding } from "@/lib/ai/types"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Sparkles, - RefreshCw, - Loader2, - CheckCircle2, - Minus, - AlertCircle, - AlertTriangle, - ChevronDown, - ChevronUp, - Info, -} from "lucide-react"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { useAuth } from "@/hooks/use-auth"; -import { HealthChart } from "@/components/charts/health-chart"; -import { MoodChart } from "@/components/charts/mood-chart"; -import { - parseChartTokens, - stripChartTokens, - tokenKind, - tokenToMetric, - type ChartToken, -} from "@/lib/insights/chart-tokens"; -import { RecommendationsGrid } from "./recommendations-grid"; - -// ─── Types ──────────────────────────────────────────────── - -interface InsightAdvisorCardProps { - /** - * v1.4.19 A3 — optional subtitle below the CardTitle. The - * `/insights` page surface no longer passes a subtitle (the - * CardTitle alone is enough framing); the prop stays optional so - * existing test fixtures + the dashboard preview keep their - * supplementary line. When omitted the `

` element is dropped - * entirely so the empty card no longer renders an "orphan - * Persönlicher Berater" title above an empty body. - */ - title?: string; - icon?: React.ReactNode; - insight: InsightResult | null; - loading?: boolean; - error?: string | null; - onRegenerate?: () => void; - regenerating?: boolean; - cachedAt?: string | null; - /** - * v1.4.16 phase B5c — set when the cached payload predates B5c - * rationale. The card surfaces a one-time CTA pointing at - * regenerate; user-initiated only. - */ - legacyPayload?: boolean; -} - -// ─── Classification Colors ─────────────────────────────── - -const CLASSIFICATION_STYLES: Record = - { - optimal: { - badge: - "bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15", - border: "border-l-dracula-green", - }, - gut: { - badge: - "bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15", - border: "border-l-dracula-cyan", - }, - grenzwertig: { - badge: - "bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15", - border: "border-l-dracula-yellow", - }, - erhoht: { - badge: - "bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15", - border: "border-l-dracula-orange", - }, - kritisch: { - badge: - "bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15", - border: "border-l-dracula-red", - }, - }; - -// ─── Assessment Icons ───────────────────────────────────── - -function AssessmentIcon({ - assessment, -}: { - assessment: InsightFinding["assessment"]; -}) { - switch (assessment) { - case "positive": - return ; - case "neutral": - return ; - case "attention": - return ; - case "warning": - return ; - } -} - -// ─── Hero Finding ───────────────────────────────────────── - -const HERO_STYLES: Record< - InsightFinding["assessment"], - { wrapper: string; icon: string; label: string } -> = { - positive: { - wrapper: - "from-dracula-green/15 via-dracula-green/5 to-transparent border-l-dracula-green", - icon: "text-dracula-green", - label: "text-dracula-green", - }, - neutral: { - wrapper: - "from-dracula-cyan/12 via-dracula-cyan/4 to-transparent border-l-dracula-cyan", - icon: "text-dracula-cyan", - label: "text-dracula-cyan", - }, - attention: { - wrapper: - "from-dracula-orange/15 via-dracula-orange/5 to-transparent border-l-dracula-orange", - icon: "text-dracula-orange", - label: "text-dracula-orange", - }, - warning: { - wrapper: - "from-dracula-red/15 via-dracula-red/5 to-transparent border-l-dracula-red", - icon: "text-dracula-red", - label: "text-dracula-red", - }, -}; - -function HeroFinding({ finding }: { finding: InsightFinding }) { - const { t } = useTranslations(); - const style = HERO_STYLES[finding.assessment]; - const labelKey = - finding.assessment === "positive" - ? "insights.heroFindingPositive" - : finding.assessment === "warning" - ? "insights.heroFindingWarning" - : finding.assessment === "attention" - ? "insights.heroFindingAttention" - : "insights.heroFindingNeutral"; - const labelTokens = parseChartTokens(finding.label); - const cleanLabel = stripChartTokens(finding.label); - const guidelineTokens = finding.guideline - ? parseChartTokens(finding.guideline) - : []; - const cleanGuideline = finding.guideline - ? stripChartTokens(finding.guideline) - : ""; - return ( -

-
-
-
- -
-
-

- {t(labelKey)} -

-

{cleanLabel}

- {cleanGuideline && ( -

- {cleanGuideline} -

- )} -
-
- - {finding.value} - -
- {(labelTokens.length > 0 || guidelineTokens.length > 0) && ( -
- -
- )} -
- ); -} - -// ─── Confidence Colors ──────────────────────────────────── - -const CONFIDENCE_STYLES: Record = { - hoch: "text-dracula-green", - mittel: "text-dracula-yellow", - gering: "text-dracula-orange", -}; - -const CONFIDENCE_LABEL_KEYS: Record = { - hoch: "insights.confidenceHoch", - mittel: "insights.confidenceMittel", - gering: "insights.confidenceGering", -}; - -// ─── Inline Chart Renderer ──────────────────────────────── -// -// Expand a list of allowlisted ChartTokens into HealthChart instances. -// HealthChart already understands the `MeasurementType`-style strings -// (WEIGHT, BLOOD_PRESSURE_SYS, …); the synthetic MOOD and COMPLIANCE -// tokens map to the dedicated chart components shipped elsewhere — for -// now we render them as a HealthChart with the token's metric name as -// the type, which the chart will gracefully render empty if there's no -// matching data, instead of silently swallowing the user's expectation. -// -// Tokens are deduped per call so the prose `metric:WEIGHT … metric:WEIGHT` -// doesn't render the same chart twice in a row. - -const INLINE_CHART_TITLE_KEYS: Record = { - WEIGHT: "charts.weight", - BLOOD_PRESSURE_SYS: "charts.systolic", - BLOOD_PRESSURE_DIA: "charts.diastolic", - PULSE: "charts.pulse", - BODY_FAT: "charts.bodyFat", - SLEEP_DURATION: "charts.sleep", - ACTIVITY_STEPS: "charts.steps", - BLOOD_GLUCOSE: "measurements.typeBloodGlucose", - TOTAL_BODY_WATER: "charts.bodyWater", - BONE_MASS: "charts.boneMass", - OXYGEN_SATURATION: "charts.spo2", - MOOD: "insights.navMood", - COMPLIANCE: "insights.navMedication", -}; - -function InlineCharts({ - tokens, - mini = false, -}: { - tokens: ChartToken[]; - /** - * v1.4.16 phase B1b — render the embedded charts as compact - * sparklines (no range tabs, h-[140px]). The summary surface uses - * this so the prose stays the headline; the finding-card surface - * keeps the full chart so the user can interact with the data. - */ - mini?: boolean; -}) { - const { t } = useTranslations(); - const { user } = useAuth(); - if (tokens.length === 0) return null; - - // Dedup while preserving the order the model emitted them. - const seen = new Set(); - const unique: ChartToken[] = []; - for (const token of tokens) { - if (!seen.has(token)) { - seen.add(token); - unique.push(token); - } - } - - return ( -
- {unique.map((token) => { - const metric = tokenToMetric(token); - const kind = tokenKind(token); - const titleKey = INLINE_CHART_TITLE_KEYS[metric]; - const title = titleKey ? t(titleKey) : metric; - return ( -
- {kind === "mood" ? ( - - ) : ( - - )} -
- ); - })} -
- ); -} - -// ─── Section Separator ──────────────────────────────────── - -// ─── Section Separator ──────────────────────────────────── - -function SectionSeparator({ label }: { label: string }) { - return ( -
- {label} -
-
- ); -} - -// ─── Main Component ─────────────────────────────────────── - -export function InsightAdvisorCard({ - title, - icon, - insight, - loading = false, - error = null, - onRegenerate, - regenerating = false, - cachedAt, - legacyPayload = false, -}: InsightAdvisorCardProps) { - const { t } = useTranslations(); - const fmt = useFormatters(); - const [dataQualityOpen, setDataQualityOpen] = useState(false); - - const classStyle = insight - ? (CLASSIFICATION_STYLES[insight.classification] ?? - CLASSIFICATION_STYLES.gut) - : CLASSIFICATION_STYLES.gut; - - // ── Loading State ───────────────────────────────────── - // v1.4.16 phase B1b — skeleton mirrors the final card layout (header - // pulse + 3 placeholder rec rows) so the page doesn't visually jump - // when content lands. Animation keys off Tailwind `animate-pulse`, - // which `prefers-reduced-motion: reduce` already silences via the - // `motion-reduce:animate-none` utility on the same elements. - if (loading && !insight) { - return ( - - -
- {icon ?? } - - {t("insights.advisorTitle")} - -
- {title &&

{title}

} -
- -
-
-
- {[0, 1, 2].map((i) => ( -
- ))} -
- - - ); - } - - // ── Empty / No Data State ───────────────────────────── - if (!insight) { - return ( - - -
-
- {icon ?? } - - {t("insights.advisorTitle")} - -
-
- {title &&

{title}

} -
- - {error && ( -
-
- - {error} -
- {onRegenerate && ( - - )} -
- )} - {!error && ( -
-
-
-

- {t("insights.noAnalysisYet")} -

- {onRegenerate && ( - - )} -
- )} -
-
- ); - } - - // ── Legacy-Payload State ────────────────────────────── - // v1.4.17 hotfix: when the cached blob predates the v1.4.16 strict - // schema (no `summary`, no `recommendations[]`, no `findings[]` — - // the v1.4.14 `{changed, stable, drivers, ...}` shape), the rich-card - // render path below would crash on `stripChartTokens(undefined)`. - // Surface the regenerate CTA in a self-contained card and skip the - // rich body entirely. The `legacyPayload` prop is the explicit - // signal from the API; we also defensively short-circuit when the - // shape doesn't carry the modern `summary` string at all. - const isUnrenderable = - typeof insight.summary !== "string" || - !Array.isArray(insight.findings) || - !Array.isArray(insight.recommendations); - if (legacyPayload || isUnrenderable) { - return ( - - -
-
- {icon ?? } - - {t("insights.advisorTitle")} - -
- {onRegenerate && ( - - )} -
- {title &&

{title}

} -
- -
- -
-

{t("insights.recommendation.legacyPayloadCta")}

- {onRegenerate && ( - - )} -
-
-
-
- ); - } - - // ── Full Insight Card ───────────────────────────────── - return ( - - {/* Header */} - -
-
- {icon ?? } - - {t("insights.advisorTitle")} - -
-
- - {insight.classificationLabel ?? insight.classification} - - {onRegenerate && ( - - )} -
-
- {title &&

{title}

} -
- - - {/* Error banner */} - {error && ( -
- - {error} -
- )} - - {/* Primary Recommendation — prose first, then any inline charts the - * model emitted via `metric:` tokens. Tokens are stripped from - * the visible text so they never leak into the hero card. */} - {insight.primaryRecommendation && ( -
-

- {t("insights.keyTakeaway")} -

-

- {stripChartTokens(insight.primaryRecommendation)} -

- -
- )} - - {/* Summary — prose first, then any allowlisted charts the model - * inlined via `metric:` tokens. Tokens are dropped from the - * visible text so the literal substring never reaches the DOM. - * - * v1.4.16 phase B1b — typography polish: larger font (text-base), - * relaxed line-height, max-w-prose so the summary doesn't span - * full-bleed on a wide viewport. Wraps the inline-chart strip - * in the same constrained column so the rendered sparklines - * align with the prose. */} -
-

{stripChartTokens(insight.summary)}

- -
- - {/* Findings — top finding gets a hero treatment, rest are a compact list */} - {insight.findings.length > 0 && ( -
- - {/* v1.4 — pull the first finding into a severity-tinted hero - * card. The model is instructed to put the most clinically - * relevant finding first; we let the UI honour that by giving - * it more weight than the secondary findings. - */} - - {insight.findings.length > 1 && ( -
- {insight.findings.slice(1).map((finding, i) => { - const labelTokens = parseChartTokens(finding.label); - const guidelineTokens = finding.guideline - ? parseChartTokens(finding.guideline) - : []; - const tokens = [...labelTokens, ...guidelineTokens]; - return ( -
-
-
- - - {stripChartTokens(finding.label)} - -
- - {finding.value} - -
- {finding.guideline && ( -

- {stripChartTokens(finding.guideline)} -

- )} - {tokens.length > 0 && ( -
- -
- )} -
- ); - })} -
- )} -
- )} - - {/* Correlations */} - {insight.correlations.length > 0 && ( -
- -
- {insight.correlations.map((corr, i) => ( -
- {corr.factor} - ↔ - {corr.effect} - - {corr.confidence} - -
- ))} -
-
- )} - - {/* v1.4.16 phase B5c — legacy-payload CTA. Surfaced when the - * cached payload predates the rationale field; pointing the - * user at the regenerate button. Click-handler is owned by - * the parent (the regenerate button). */} - {legacyPayload && onRegenerate && ( -
- -
-

{t("insights.recommendation.legacyPayloadCta")}

- -
-
- )} - - {/* Recommendations — v1.4.16 phase B1b polish: severity-ordered - * 2-col responsive grid via . Each card - * keeps the Oura-style expand reveal from B5c and gains a - * severity-coloured left border + hover-lift + staggered - * fade-in around its outer shell. */} - {insight.recommendations.length > 0 && ( -
- - -
- )} - - {/* Data Quality (collapsible) */} - {insight.dataQuality && ( -
- - - {dataQualityOpen && ( -
-

{insight.dataQuality.coverage}

- {insight.dataQuality.gaps.length > 0 && ( -
-

{t("insights.dataGaps")}

-
    - {insight.dataQuality.gaps.map((gap, i) => ( -
  • {gap}
  • - ))} -
-
- )} -
- )} -
- )} - - {cachedAt && ( -

- {t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)} -

- )} - - {/* Disclaimer */} -
- -

{insight.disclaimer}

-
-
-
- ); -} diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index beb37b6ae..e4aca3bb1 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -23,9 +23,9 @@ import { queryKeys } from "@/lib/query-keys"; * C1+C2 specifically because it assumed that duplication was required, * but the route already supports the cache-aware path. * - * `/insights` (``) and any other surface that mounts - * the query under the same key share the cache, so a regenerate from - * one surface refreshes the others without a second LLM call. + * Every surface that mounts the query under the same key shares the + * cache, so a regenerate from one surface refreshes the others without + * a second LLM call. */ export interface InsightAdvisorPayload { insights: InsightResult; diff --git a/src/lib/insights/__tests__/chart-tokens.test.ts b/src/lib/insights/__tests__/chart-tokens.test.ts index 59bb2704c..aeb86855a 100644 --- a/src/lib/insights/__tests__/chart-tokens.test.ts +++ b/src/lib/insights/__tests__/chart-tokens.test.ts @@ -228,11 +228,11 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => { }); it("includes metric:MOOD now that is wired into the renderer", () => { - // v1.4.3 enabled `metric:MOOD`. The renderer in - // `` branches on `tokenKind(token) === "mood"` - // and mounts the dedicated, self-fetching `` instead of - // the generic `` (which Zod-validates against - // `measurementTypeEnum` and would silently render empty). + // v1.4.3 enabled `metric:MOOD`. The chart-token renderer branches + // on `tokenKind(token) === "mood"` and mounts the dedicated, + // self-fetching `` instead of the generic `` + // (which Zod-validates against `measurementTypeEnum` and would + // silently render empty). expect(ALLOWED_CHART_TOKENS).toContain("metric:MOOD"); }); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index eb7a09b31..7980d0f41 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -24,10 +24,9 @@ export const queryKeys = { insightsTargets: () => ["insights", "targets"] as const, /** * Shared cache key for the rich `/api/insights/generate` advisor - * payload. `/insights` (full ``) and any other - * surface that subscribes under this key share the same cache so a - * regenerate on one surface refreshes the others without a second - * LLM round-trip. + * payload. Every surface that subscribes under this key shares the + * same cache so a regenerate on one surface refreshes the others + * without a second LLM round-trip. */ insightsAdvisor: () => ["insights", "advisor"] as const, insightsBpStatus: (locale: string) =>