mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
cad53a68a3
commit
52edf85f02
19 files changed
+32
-2209
No files matched your search
+11
-91
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
-179
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user