diff --git a/e2e/insights-advisor-card.spec.ts b/e2e/insights-advisor-card.spec.ts new file mode 100644 index 000000000..232aeb788 --- /dev/null +++ b/e2e/insights-advisor-card.spec.ts @@ -0,0 +1,241 @@ +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. The hero is from B1b; the + // advisor card is the C1 deliverable. + await expect( + page.locator('[data-slot="insights-page-hero"]'), + ).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 29e955a0f..474ec6038 100644 --- a/messages/de.json +++ b/messages/de.json @@ -681,6 +681,7 @@ }, "insights": { "aiAnalysisTitle": "KI-Gesundheitsanalyse", + "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 1b9f647ff..c1f2c9564 100644 --- a/messages/en.json +++ b/messages/en.json @@ -681,6 +681,7 @@ }, "insights": { "aiAnalysisTitle": "AI Health Analysis", + "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/insights/page.tsx b/src/app/insights/page.tsx index f58a1426c..98d378ea1 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -42,7 +42,9 @@ import { TrendingUp, } from "lucide-react"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card"; import { InsightsPageHero } from "@/components/insights/insights-page-hero"; +import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { CompareToggle } from "@/components/comparison/compare-toggle"; // Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter // wrapper so the bundle only lands once a correlation card actually renders. @@ -579,6 +581,15 @@ export default function InsightsPage() { const compareBaseline = resolveDashboardLayout(layoutData).comparisonBaseline ?? "none"; + // v1.4.16 phase D reconcile (CRITICAL C1) — pull the rich advisor + // payload (severity-ordered recommendations + rationale + confidence + // + medical-citation footnotes + thumbs feedback) so this page + // surfaces the polished `` from B5c/d/e/B1b + // instead of the v1.4.15 text-only `` summary. + // Cache-aware: a regenerate on the dashboard preview (C2) hot-swaps + // this query under the same key. + const advisor = useInsightsAdvisorQuery(isAuthenticated); + const { data: analytics } = useQuery({ queryKey: ["analytics"], queryFn: async () => { @@ -893,7 +904,11 @@ export default function InsightsPage() { return (
- + {/* v1.4.16 phase D reconcile (CRITICAL C3) — on-surface comparison toggle. Mirrors the dashboard placement so flipping Vormonat / @@ -903,6 +918,26 @@ export default function InsightsPage() {
+ {/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished + `` (severity-ordered recommendations grid + + per-rec rationale expand + confidence meter + thumbs feedback + + medical-citation footnotes) into the live route. Before this + fix the page rendered only the text-only `` + per section, hiding all of B5c/d/e/B1b's polish from users. + The per-section status cards stay below as supplemental detail. */} + +
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts new file mode 100644 index 000000000..f98d6eaa7 --- /dev/null +++ b/src/components/insights/use-insights-advisor.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { InsightResult } from "@/lib/ai/types"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared TanStack Query + * helper that reads the rich advisor payload from `/api/insights/generate`. + * + * Why a POST under a `useQuery` rather than a dedicated GET endpoint: + * the route already cache-returns the most recent generation (24h TTL on + * `User.insightsCachedAt` / `User.insightsCachedText`) without burning a + * rate-limit token, so a single POST-without-`force` is functionally a + * GET-or-generate. Adding a separate GET would duplicate the cache-read + * branch and split the audit-log surface; the reconcile report deferred + * C1+C2 specifically because it assumed that duplication was required, + * but the route already supports the cache-aware path. + * + * Both `/insights` (``) and `/` (``) + * mount the query under the same key, so a regenerate from either surface + * refreshes the other without a second LLM call. + */ +export interface InsightAdvisorPayload { + insights: InsightResult; + cached: boolean; + cachedAt?: string | null; + legacyPayload?: boolean; +} + +async function fetchAdvisor( + options: { force?: boolean } = {}, +): Promise { + const res = await fetch("/api/insights/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(options.force ? { force: true } : {}), + }); + if (!res.ok) { + // 422 (no provider configured) and 429 (rate-limited) are expected + // surfaces — return null so the consuming UI shows the empty / error + // state without the query slipping into an `isError` retry loop. + if (res.status === 422 || res.status === 429 || res.status === 503) { + return null; + } + throw new Error(`HTTP ${res.status}`); + } + const json = await res.json(); + return json.data as InsightAdvisorPayload; +} + +export interface UseInsightsAdvisorResult { + payload: InsightAdvisorPayload | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + regenerate: () => void; + isRegenerating: boolean; + regenerateError: Error | null; +} + +/** + * Read-only consumer for the advisor payload. Use this on surfaces that + * just want to render the cached insight (e.g. dashboard preview). + */ +export function useInsightsAdvisorQuery(enabled: boolean): UseInsightsAdvisorResult { + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: queryKeys.insightsAdvisor(), + queryFn: () => fetchAdvisor(), + enabled, + // 24h cache window matches the server-side `insightsCachedAt` TTL. + staleTime: 60 * 60 * 1000, + retry: false, + }); + + const mutation = useMutation({ + mutationFn: () => fetchAdvisor({ force: true }), + onSuccess: (next) => { + if (next) { + queryClient.setQueryData(queryKeys.insightsAdvisor(), next); + } else { + queryClient.invalidateQueries({ queryKey: queryKeys.insightsAdvisor() }); + } + // Per-status caches are evicted server-side on regenerate; refresh + // their query subtree so the per-section text below the advisor card + // re-fetches. + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + }, + }); + + return { + payload: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + error: (query.error as Error | null) ?? null, + regenerate: () => mutation.mutate(), + isRegenerating: mutation.isPending, + regenerateError: (mutation.error as Error | null) ?? null, + }; +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index f4a3d879a..6b818aca8 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -22,6 +22,14 @@ export const queryKeys = { insightsRoot: () => ["insights"] as const, insightsComprehensive: () => ["insights", "comprehensive"] as const, insightsTargets: () => ["insights", "targets"] as const, + /** + * v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared cache key for + * the rich `/api/insights/generate` advisor payload. Both `/insights` + * (full ``) and `/` (``) read + * this key so a regenerate on one surface refreshes the other without + * a second LLM round-trip. + */ + insightsAdvisor: () => ["insights", "advisor"] as const, insightsGeneralStatus: (locale: string) => ["insights", "general-status", locale] as const, insightsBpStatus: (locale: string) =>