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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:19:14 +02:00
1 parent cad53a68a3
commit 52edf85f02
19 files changed
+32 -2209

No files matched your search

+11 -91
View File
@@ -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
-245
View File
@@ -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
* `<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. v1.4.20 B1 replaced the legacy
// `<InsightsPageHero>` (data-slot="insights-page-hero") with the
// condensed `<HeroStrip>` (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 });
});
});
-2
View File
@@ -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",
-2
View File
@@ -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.",
-2
View File
@@ -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.",
-2
View File
@@ -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.",
-2
View File
@@ -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.",
-2
View File
@@ -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.",
+10 -35
View File
@@ -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 `<InsightsTabStrip>`. 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 `<InsightAdvisorCard>`.
* 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 <InsightAdvisorCard> 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 <DailyBriefing> 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(/<InsightAdvisorCard\b[^>]*\/>/);
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(/<TrendCard[\s\n]/);
});
it("does NOT pass the `aiOverviewTitle` placeholder subtitle to <InsightAdvisorCard>", () => {
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:<name>` template strings in static prose", () => {
const src = load(INSIGHTS_PATH);
// The chart-token pattern (`metric:WEIGHT`) is reserved for AI
-9
View File
@@ -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() {
)}
<TrendsRow annotations={advisor.payload?.trendAnnotations ?? null} />
<InsightAdvisorCard
insight={advisor.payload?.insights ?? null}
loading={advisor.isLoading}
error={advisor.error?.message ?? null}
cachedAt={advisor.payload?.cachedAt ?? null}
legacyPayload={advisor.payload?.legacyPayload ?? false}
/>
</div>
);
}
@@ -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 }) => (
<div data-testid="health-chart" data-types={types.join(",")}>
chart:{title}
</div>
),
}));
// 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> = {}): 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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Test card" insight={insight} />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<InsightAdvisorCard> — 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("<script");
expect(html).not.toMatch(/<[^>]*\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("<InsightAdvisorCard> — 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("<InsightAdvisorCard> — polished loading / empty / error states (B1b)", () => {
it("loading state shows a skeleton that mirrors the final layout", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Loading test" insight={null} loading />
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Empty test"
insight={null}
onRegenerate={() => {}}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Error test"
insight={null}
error="Something went wrong"
onRegenerate={() => {}}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Test card"
insight={legacy}
legacyPayload
onRegenerate={() => {}}
/>
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Error test"
insight={null}
error="No retry available"
/>
</I18nProvider>,
);
expect(html).toContain("No retry available");
expect(html).not.toContain('data-slot="insight-retry-button"');
});
});
@@ -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 }) => (
<div data-testid="health-chart" data-types={types.join(",")}>
chart:{title}
</div>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart" />,
}));
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> = {}): 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(
<I18nProvider initialLocale={locale}>
<InsightAdvisorCard title="Test card" insight={insight} />
</I18nProvider>,
);
}
describe("<InsightAdvisorCard> — 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);
});
});
@@ -1,179 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: () => <div data-testid="health-chart-mock" />,
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart-mock" />,
}));
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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Test" insight={insight} />
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="Test" insight={insight} />
</I18nProvider>,
);
expect(html).toContain('data-confidence-band="draft"');
expect(html).toMatch(/Draft/);
});
});
@@ -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;
}) => (
<div
data-testid="health-chart-mock"
data-types={types.join(",")}
data-mini={mini ? "true" : "false"}
data-window={windowOverride ?? ""}
>
chart:{title}
</div>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: ({ mini }: { mini?: boolean }) => (
<div data-testid="mood-chart-mock" data-mini={mini ? "true" : "false"} />
),
}));
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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Test"
insight={insight}
legacyPayload={false}
/>
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Test"
insight={insight}
legacyPayload
onRegenerate={() => {}}
/>
</I18nProvider>,
);
expect(html).toContain('data-slot="insight-legacy-payload-cta"');
expect(html).toContain("Insights updated");
});
});
@@ -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;
}) => (
<div
data-testid="health-chart"
data-types={types.join(",")}
data-mini={mini ? "true" : "false"}
data-window={windowOverride ?? ""}
/>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: ({ mini }: { mini?: boolean }) => (
<div data-testid="mood-chart" data-mini={mini ? "true" : "false"} />
),
}));
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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="full flow" insight={buildInsight(recs)} />
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="draft" insight={buildInsight(recs)} />
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<InsightAdvisorCard title="empty" insight={buildInsight([])} />
</I18nProvider>,
);
// 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");
});
});
@@ -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 `<p>` 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<string, { badge: string; border: string }> =
{
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 <CheckCircle2 className="text-dracula-green h-4 w-4 shrink-0" />;
case "neutral":
return <Minus className="text-muted-foreground h-4 w-4 shrink-0" />;
case "attention":
return <AlertCircle className="text-dracula-orange h-4 w-4 shrink-0" />;
case "warning":
return <AlertTriangle className="text-dracula-red h-4 w-4 shrink-0" />;
}
}
// ─── 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 (
<div
data-slot="insight-hero-finding"
className={`rounded-lg border-l-2 bg-gradient-to-br ${style.wrapper} px-4 py-3.5`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<div className={`mt-0.5 shrink-0 ${style.icon}`}>
<AssessmentIcon assessment={finding.assessment} />
</div>
<div className="min-w-0 space-y-1">
<p
className={`text-xs font-semibold tracking-widest uppercase ${style.label}`}
>
{t(labelKey)}
</p>
<p className="text-sm leading-snug font-medium">{cleanLabel}</p>
{cleanGuideline && (
<p className="text-muted-foreground text-xs leading-snug">
{cleanGuideline}
</p>
)}
</div>
</div>
<span className="shrink-0 text-base leading-snug font-semibold tabular-nums">
{finding.value}
</span>
</div>
{(labelTokens.length > 0 || guidelineTokens.length > 0) && (
<div className="mt-3">
<InlineCharts tokens={[...labelTokens, ...guidelineTokens]} />
</div>
)}
</div>
);
}
// ─── Confidence Colors ────────────────────────────────────
const CONFIDENCE_STYLES: Record<string, string> = {
hoch: "text-dracula-green",
mittel: "text-dracula-yellow",
gering: "text-dracula-orange",
};
const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
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<string, string> = {
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<ChartToken>();
const unique: ChartToken[] = [];
for (const token of tokens) {
if (!seen.has(token)) {
seen.add(token);
unique.push(token);
}
}
return (
<div data-slot="insight-inline-charts" className="space-y-3">
{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 (
<div
key={token}
data-slot="insight-inline-chart"
data-metric={metric}
data-mini={mini ? "true" : "false"}
>
{kind === "mood" ? (
<MoodChart mini={mini} userTimezone={user?.timezone} />
) : (
<HealthChart
types={[metric]}
title={title}
mini={mini}
userTimezone={user?.timezone}
/>
)}
</div>
);
})}
</div>
);
}
// ─── Section Separator ────────────────────────────────────
// ─── Section Separator ────────────────────────────────────
function SectionSeparator({ label }: { label: string }) {
return (
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
<span>{label}</span>
<div className="bg-border h-px flex-1" />
</div>
);
}
// ─── 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 (
<Card data-slot="insight-skeleton">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted/60 h-4 w-3/4 animate-pulse rounded-md motion-reduce:animate-none" />
<div className="bg-muted/50 h-3 w-2/3 animate-pulse rounded-md motion-reduce:animate-none" />
<div className="grid gap-3 lg:grid-cols-2">
{[0, 1, 2].map((i) => (
<div
key={i}
data-slot="insight-skeleton-card"
className="bg-muted/40 h-20 animate-pulse rounded-lg motion-reduce:animate-none"
style={{ animationDelay: `${i * 100}ms` }}
/>
))}
</div>
</CardContent>
</Card>
);
}
// ── Empty / No Data State ─────────────────────────────
if (!insight) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent>
{error && (
<div
data-slot="insight-error-state"
className="border-destructive/30 bg-destructive/10 text-destructive flex flex-col items-start gap-3 rounded-lg border p-4 text-sm"
>
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
{onRegenerate && (
<Button
variant="outline"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insight-retry-button"
className="gap-1.5 self-start"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("insights.refreshAnalysis")}
</Button>
)}
</div>
)}
{!error && (
<div
data-slot="insight-empty-state"
className="flex flex-col items-center gap-3 py-6 text-center"
>
<div className="bg-dracula-purple/10 flex h-12 w-12 items-center justify-center rounded-full">
<Sparkles
className="text-dracula-purple h-6 w-6"
aria-hidden="true"
/>
</div>
<p className="text-muted-foreground max-w-prose text-sm">
{t("insights.noAnalysisYet")}
</p>
{onRegenerate && (
<Button
variant="outline"
onClick={onRegenerate}
disabled={regenerating}
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
{t("insights.startAnalysis")}
</Button>
)}
</div>
)}
</CardContent>
</Card>
);
}
// ── 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 (
<Card data-slot="insight-legacy-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
{onRegenerate && (
<Button
variant="ghost"
size="icon"
onClick={onRegenerate}
disabled={regenerating}
title={t("insights.refreshAnalysis")}
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent>
<div
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-sm"
data-slot="insight-legacy-payload-cta"
>
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-2">
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
{onRegenerate && (
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3 w-3" />
)}
{t("insights.heroRegenerate")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}
// ── Full Insight Card ─────────────────────────────────
return (
<Card className="animate-insight-in">
{/* Header */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.advisorTitle")}
</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge className={classStyle.badge}>
{insight.classificationLabel ?? insight.classification}
</Badge>
{onRegenerate && (
<Button
variant="ghost"
size="icon"
onClick={onRegenerate}
disabled={regenerating}
title={t("insights.refreshAnalysis")}
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
</CardHeader>
<CardContent className="space-y-5">
{/* Error banner */}
{error && (
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Primary Recommendation — prose first, then any inline charts the
* model emitted via `metric:<TYPE>` tokens. Tokens are stripped from
* the visible text so they never leak into the hero card. */}
{insight.primaryRecommendation && (
<div className="border-dracula-purple bg-dracula-purple/5 space-y-3 rounded-md border-l-2 px-4 py-3">
<p className="text-dracula-purple mb-1 text-xs font-medium tracking-widest uppercase">
{t("insights.keyTakeaway")}
</p>
<p className="text-sm">
{stripChartTokens(insight.primaryRecommendation)}
</p>
<InlineCharts
tokens={parseChartTokens(insight.primaryRecommendation)}
/>
</div>
)}
{/* Summary — prose first, then any allowlisted charts the model
* inlined via `metric:<TYPE>` 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. */}
<div
data-slot="insight-summary"
className="text-foreground/90 max-w-prose space-y-3 text-base leading-relaxed"
>
<p>{stripChartTokens(insight.summary)}</p>
<InlineCharts tokens={parseChartTokens(insight.summary)} mini />
</div>
{/* Findings — top finding gets a hero treatment, rest are a compact list */}
{insight.findings.length > 0 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.findingsTitle")} />
{/* 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.
*/}
<HeroFinding finding={insight.findings[0]} />
{insight.findings.length > 1 && (
<div className="space-y-2 pt-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 (
<div key={i}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<AssessmentIcon assessment={finding.assessment} />
<span className="text-sm">
{stripChartTokens(finding.label)}
</span>
</div>
<span className="text-sm font-medium tabular-nums">
{finding.value}
</span>
</div>
{finding.guideline && (
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
{stripChartTokens(finding.guideline)}
</p>
)}
{tokens.length > 0 && (
<div className="mt-2 ml-6">
<InlineCharts tokens={tokens} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* Correlations */}
{insight.correlations.length > 0 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.correlationsTitle")} />
<div className="flex flex-wrap gap-2">
{insight.correlations.map((corr, i) => (
<div
key={i}
className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs"
>
<span>{corr.factor}</span>
<span className="text-muted-foreground">↔</span>
<span>{corr.effect}</span>
<span
className={`ml-1 font-medium ${CONFIDENCE_STYLES[corr.confidence] ?? ""}`}
>
{corr.confidence}
</span>
</div>
))}
</div>
</div>
)}
{/* 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 && (
<div
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
data-slot="insight-legacy-payload-cta"
>
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1.5">
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3 w-3" />
)}
{t("insights.heroRegenerate")}
</Button>
</div>
</div>
)}
{/* Recommendations — v1.4.16 phase B1b polish: severity-ordered
* 2-col responsive grid via <RecommendationsGrid>. 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 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.recommendationsTitle")} />
<RecommendationsGrid recs={insight.recommendations} />
</div>
)}
{/* Data Quality (collapsible) */}
{insight.dataQuality && (
<div>
<button
onClick={() => setDataQualityOpen((v) => !v)}
aria-expanded={dataQualityOpen}
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-1.5 text-xs transition-colors"
>
{dataQualityOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>{t("insights.dataFoundation")}</span>
<span className="mx-1">·</span>
<span>
{t("insights.confidence")}:{" "}
<span
className={`font-medium ${CONFIDENCE_STYLES[insight.dataQuality.confidence] ?? ""}`}
>
{t(
CONFIDENCE_LABEL_KEYS[insight.dataQuality.confidence] ??
"insights.confidenceMittel",
)}
</span>
</span>
</button>
{dataQualityOpen && (
<div className="bg-muted/30 text-muted-foreground animate-insight-in mt-2 space-y-2 rounded-md px-3 py-2.5 text-xs">
<p>{insight.dataQuality.coverage}</p>
{insight.dataQuality.gaps.length > 0 && (
<div>
<p className="font-medium">{t("insights.dataGaps")}</p>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{insight.dataQuality.gaps.map((gap, i) => (
<li key={i}>{gap}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
{cachedAt && (
<p className="text-muted-foreground text-xs">
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
</p>
)}
{/* Disclaimer */}
<div className="flex items-start gap-1.5 pt-1">
<Info className="text-muted-foreground mt-0.5 h-3 w-3 shrink-0" />
<p className="text-muted-foreground text-xs">{insight.disclaimer}</p>
</div>
</CardContent>
</Card>
);
}
@@ -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` (`<InsightAdvisorCard>`) 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;
@@ -228,11 +228,11 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => {
});
it("includes metric:MOOD now that <MoodChart> is wired into the renderer", () => {
// v1.4.3 enabled `metric:MOOD`. The renderer in
// `<InsightAdvisorCard>` branches on `tokenKind(token) === "mood"`
// and mounts the dedicated, self-fetching `<MoodChart>` instead of
// the generic `<HealthChart>` (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 `<MoodChart>` instead of the generic `<HealthChart>`
// (which Zod-validates against `measurementTypeEnum` and would
// silently render empty).
expect(ALLOWED_CHART_TOKENS).toContain("metric:MOOD");
});
+3 -4
View File
@@ -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 `<InsightAdvisorCard>`) 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) =>