mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(insights): mount InsightAdvisorCard on /insights page (wire B5c/d/e + B1b polish to live route)
The /insights page rendered only the text-only InsightStatusCard per section, hiding all of B5c (rec rationale + mini-chart), B5d (confidence meter), B5e (thumbs feedback) and the broader B1b polish from users. Wave-D design review flagged this as CRITICAL C1. Wires the InsightAdvisorCard component into the live route, consuming the existing /api/insights/generate POST. The route already returns the 24h-cached payload without burning a rate-limit token, so a single POST-without-force is functionally a GET-or-generate — no new API surface needed (the reconcile report deferred C1 because it assumed otherwise). The cache is shared with the dashboard preview via the new queryKeys.insightsAdvisor() so a regenerate on either surface refreshes the other without a second LLM call. Per-section InsightStatusCards stay below as supplemental detail; the hero now also wires its regenerate button to the same handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
54bbc68eff
commit
aae968a6d5
6 files changed
+388
-1
No files matched your search
@@ -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
|
||||
* `<InsightAdvisorCard>` is mounted on `/insights` and consumes the
|
||||
* `/api/insights/generate` payload.
|
||||
*
|
||||
* Before this fix the page rendered only `<InsightStatusCard>` (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 });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 `<InsightAdvisorCard>` from B5c/d/e/B1b
|
||||
// instead of the v1.4.15 text-only `<InsightStatusCard>` 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 (
|
||||
<div className="space-y-8">
|
||||
<InsightsPageHero updatedAt={heroUpdatedAt} />
|
||||
<InsightsPageHero
|
||||
updatedAt={advisor.payload?.cachedAt ?? heroUpdatedAt}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
/>
|
||||
|
||||
{/* 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() {
|
||||
<CompareToggle />
|
||||
</div>
|
||||
|
||||
{/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished
|
||||
`<InsightAdvisorCard>` (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 `<InsightStatusCard>`
|
||||
per section, hiding all of B5c/d/e/B1b's polish from users.
|
||||
The per-section status cards stay below as supplemental detail. */}
|
||||
<InsightAdvisorCard
|
||||
title={t("insights.aiOverviewTitle")}
|
||||
insight={advisor.payload?.insights ?? null}
|
||||
loading={advisor.isLoading}
|
||||
error={
|
||||
advisor.regenerateError?.message ?? advisor.error?.message ?? null
|
||||
}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
cachedAt={advisor.payload?.cachedAt ?? null}
|
||||
legacyPayload={advisor.payload?.legacyPayload ?? false}
|
||||
/>
|
||||
|
||||
<InsightsSectionNav />
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
|
||||
@@ -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` (`<InsightAdvisorCard>`) and `/` (`<InsightsCardPreview>`)
|
||||
* 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<InsightAdvisorPayload | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 `<InsightAdvisorCard>`) and `/` (`<InsightsCardPreview>`) 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) =>
|
||||
|
||||
Reference in new issue
Block a user