Merge pull request #799 from MBombeck/fix/dead-code-sweep

Remove the unreachable insights cluster and the retired comparison toggle
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-15 03:53:45 +02:00
commit 9a3c1d181c
23 files changed
+10 -2524

No files matched your search

-23
View File
@@ -2312,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Vergleichen mit",
"baseline": {
"none": "Aus",
"lastMonth": "Vormonat",
@@ -2433,23 +2432,6 @@
"badge": "PR",
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}"
},
"recommendation": {
"source": "Quelle",
"viewSource": "Leitlinie in neuem Tab öffnen",
"rationaleWindow": "Fenster",
"rationaleComparedTo": "Verglichen mit",
"rationaleDeviation": "Abweichung",
"rationaleExpand": "Begründung anzeigen",
"rationaleCollapse": "Begründung ausblenden",
"feedbackHelpful": "Hilfreich",
"feedbackNotHelpful": "Nicht hilfreich",
"feedbackThanks": "Danke für dein Feedback",
"feedbackAlreadyRated": "Bereits bewertet",
"feedbackError": "Dein Feedback konnte nicht gespeichert werden. Bitte versuche es erneut.",
"confidenceAria": "Vertrauen: {value} von 100",
"confidenceLow": "Geringes Vertrauen — wenige Datenpunkte",
"confidenceDraft": "Entwurf"
},
"loadError": "Auswertungen konnten nicht geladen werden",
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerating": "Wird generiert",
@@ -2555,11 +2537,6 @@
"areas": "{present} von {required} Feldern der Gesundheit"
}
},
"suggestedPrompts": {
"label": "Frag mich",
"tellMyDoctor": "Was sollte ich meinem Arzt sagen?",
"medicationWorking": "Wirkt mein Medikament?"
},
"dailyBriefing": {
"title": "Tagesbriefing",
"keyFindingsTitle": "Wichtige Befunde",
-23
View File
@@ -2312,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Compare to",
"baseline": {
"none": "None",
"lastMonth": "Last month",
@@ -2433,23 +2432,6 @@
"badge": "PR",
"tooltip": "Personal record: {value} {unit} on {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
"rationaleWindow": "Window",
"rationaleComparedTo": "Compared to",
"rationaleDeviation": "Deviation",
"rationaleExpand": "Show why",
"rationaleCollapse": "Hide why",
"feedbackHelpful": "Helpful",
"feedbackNotHelpful": "Not helpful",
"feedbackThanks": "Thanks for your feedback",
"feedbackAlreadyRated": "Already rated",
"feedbackError": "Could not save your feedback. Please try again.",
"confidenceAria": "Confidence: {value} of 100",
"confidenceLow": "Low confidence — based on limited data",
"confidenceDraft": "Draft"
},
"loadError": "Failed to load insights",
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerating": "Regenerating",
@@ -2555,11 +2537,6 @@
"areas": "{present} of {required} areas of health"
}
},
"suggestedPrompts": {
"label": "Try asking",
"tellMyDoctor": "What should I tell my doctor?",
"medicationWorking": "Is my medication working?"
},
"dailyBriefing": {
"title": "Daily Briefing",
"keyFindingsTitle": "Key findings",
-23
View File
@@ -2312,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Comparar con",
"baseline": {
"none": "Desactivado",
"lastMonth": "Mes anterior",
@@ -2433,23 +2432,6 @@
"badge": "PR",
"tooltip": "Récord personal: {value} {unit} el {date}"
},
"recommendation": {
"source": "Fuente",
"viewSource": "Abrir guía en una pestaña nueva",
"rationaleWindow": "Ventana",
"rationaleComparedTo": "Comparado con",
"rationaleDeviation": "Desviación",
"rationaleExpand": "Mostrar justificación",
"rationaleCollapse": "Ocultar justificación",
"feedbackHelpful": "Útil",
"feedbackNotHelpful": "No útil",
"feedbackThanks": "Gracias por tu opinión",
"feedbackAlreadyRated": "Ya valorado",
"feedbackError": "No se pudo guardar tu opinión. Inténtalo de nuevo.",
"confidenceAria": "Confianza: {value} de 100",
"confidenceLow": "Confianza baja — pocos datos",
"confidenceDraft": "Borrador"
},
"loadError": "No se han podido cargar los análisis",
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerating": "Generando",
@@ -2555,11 +2537,6 @@
"areas": "{present} de {required} ámbitos de salud"
}
},
"suggestedPrompts": {
"label": "Pregúntame",
"tellMyDoctor": "¿Qué debería contarle a mi médico?",
"medicationWorking": "¿Está funcionando mi medicamento?"
},
"dailyBriefing": {
"title": "Resumen del día",
"keyFindingsTitle": "Hallazgos clave",
-23
View File
@@ -2312,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Comparer avec",
"baseline": {
"none": "Désactivé",
"lastMonth": "Mois précédent",
@@ -2433,23 +2432,6 @@
"badge": "PR",
"tooltip": "Record personnel : {value} {unit} le {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Ouvrir la recommandation dans un nouvel onglet",
"rationaleWindow": "Fenêtre",
"rationaleComparedTo": "Comparé à",
"rationaleDeviation": "Écart",
"rationaleExpand": "Afficher la justification",
"rationaleCollapse": "Masquer la justification",
"feedbackHelpful": "Utile",
"feedbackNotHelpful": "Peu utile",
"feedbackThanks": "Merci pour ton retour",
"feedbackAlreadyRated": "Déjà noté",
"feedbackError": "Impossible d’enregistrer votre retour. Réessayez.",
"confidenceAria": "Confiance : {value} sur 100",
"confidenceLow": "Confiance faible — peu de points de données",
"confidenceDraft": "Brouillon"
},
"loadError": "Impossible de charger les analyses",
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
"heroRegenerating": "Génération en cours",
@@ -2555,11 +2537,6 @@
"areas": "{present} domaines de santé sur {required}"
}
},
"suggestedPrompts": {
"label": "Demande-moi",
"tellMyDoctor": "Que devrais-je dire à mon médecin ?",
"medicationWorking": "Mon médicament fonctionne-t-il ?"
},
"dailyBriefing": {
"title": "Briefing du jour",
"keyFindingsTitle": "Constats clés",
-23
View File
@@ -2312,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Confronta con",
"baseline": {
"none": "Disattivato",
"lastMonth": "Mese precedente",
@@ -2433,23 +2432,6 @@
"badge": "PR",
"tooltip": "Record personale: {value} {unit} il {date}"
},
"recommendation": {
"source": "Fonte",
"viewSource": "Apri la linea guida in una nuova scheda",
"rationaleWindow": "Finestra",
"rationaleComparedTo": "Confrontato con",
"rationaleDeviation": "Scostamento",
"rationaleExpand": "Mostra motivazione",
"rationaleCollapse": "Nascondi motivazione",
"feedbackHelpful": "Utile",
"feedbackNotHelpful": "Poco utile",
"feedbackThanks": "Grazie per il riscontro",
"feedbackAlreadyRated": "Già valutato",
"feedbackError": "Impossibile salvare il tuo riscontro. Riprova.",
"confidenceAria": "Confidenza: {value} su 100",
"confidenceLow": "Confidenza bassa — pochi dati",
"confidenceDraft": "Bozza"
},
"loadError": "Impossibile caricare le analisi",
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerating": "Generazione in corso",
@@ -2555,11 +2537,6 @@
"areas": "{present} ambiti di salute su {required}"
}
},
"suggestedPrompts": {
"label": "Chiedimi",
"tellMyDoctor": "Cosa dovrei dire al mio medico?",
"medicationWorking": "Il mio farmaco sta funzionando?"
},
"dailyBriefing": {
"title": "Briefing del giorno",
"keyFindingsTitle": "Riscontri principali",
-23
View File
@@ -2312,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Porównaj z",
"baseline": {
"none": "Wyłączone",
"lastMonth": "Poprzedni miesiąc",
@@ -2433,23 +2432,6 @@
"badge": "PR",
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
},
"recommendation": {
"source": "Źródło",
"viewSource": "Otwórz wytyczne w nowej karcie",
"rationaleWindow": "Okno",
"rationaleComparedTo": "W porównaniu z",
"rationaleDeviation": "Odchylenie",
"rationaleExpand": "Pokaż uzasadnienie",
"rationaleCollapse": "Ukryj uzasadnienie",
"feedbackHelpful": "Przydatne",
"feedbackNotHelpful": "Mało przydatne",
"feedbackThanks": "Dzięki za opinię",
"feedbackAlreadyRated": "Już ocenione",
"feedbackError": "Nie udało się zapisać opinii. Spróbuj ponownie.",
"confidenceAria": "Pewność: {value} na 100",
"confidenceLow": "Niska pewność — mało danych",
"confidenceDraft": "Wersja robocza"
},
"loadError": "Nie udało się załadować analiz",
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerating": "Generowanie",
@@ -2555,11 +2537,6 @@
"areas": "{present} z {required} obszarów zdrowia"
}
},
"suggestedPrompts": {
"label": "Zapytaj mnie",
"tellMyDoctor": "Co powinienem powiedzieć lekarzowi?",
"medicationWorking": "Czy mój lek działa?"
},
"dailyBriefing": {
"title": "Briefing dnia",
"keyFindingsTitle": "Kluczowe wnioski",
@@ -196,7 +196,6 @@ const PINNED_AFFORDANCES: Record<
"text-success": 1,
},
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
"src/components/insights/recommendation-feedback.tsx": { "text-success": 3 },
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
@@ -1,92 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
version: 1,
widgets: [],
comparisonBaseline: "lastMonth" as const,
},
isLoading: false,
}),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({
cancelQueries: vi.fn(),
getQueryData: vi.fn(),
setQueryData: vi.fn(),
invalidateQueries: vi.fn(),
}),
}));
import { CompareToggle } from "../compare-toggle";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<CompareToggle>", () => {
it("renders all three baseline segments", () => {
const html = render(<CompareToggle />);
expect(html).toContain('data-slot="compare-toggle-option-none"');
expect(html).toContain('data-slot="compare-toggle-option-lastMonth"');
expect(html).toContain('data-slot="compare-toggle-option-lastYear"');
});
it("marks the persisted baseline as the active segment", () => {
const html = render(<CompareToggle />);
// Mocked layout above persists `lastMonth`; that segment must be
// the only one carrying aria-pressed=true / data-active="true".
expect(html).toMatch(
/data-slot="compare-toggle-option-lastMonth"[^>]*data-active="true"/,
);
expect(html).not.toMatch(
/data-slot="compare-toggle-option-none"[^>]*data-active="true"/,
);
expect(html).not.toMatch(
/data-slot="compare-toggle-option-lastYear"[^>]*data-active="true"/,
);
});
it("uses 44px (min-h-11) tap targets per WCAG 2.5.5", () => {
const html = render(<CompareToggle />);
// Every segment carries the min-h-11 utility — the WCAG-compliance
// floor we promise mobile users.
const segmentCount = (
html.match(/data-slot="compare-toggle-option-/g) ?? []
).length;
const minH11Count = (html.match(/min-h-11/g) ?? []).length;
expect(segmentCount).toBe(3);
expect(minH11Count).toBeGreaterThanOrEqual(3);
});
it("translates the segment labels (EN)", () => {
const html = render(<CompareToggle />);
expect(html).toContain("None");
expect(html).toContain("Last month");
expect(html).toContain("Last year");
});
it("translates the segment labels (DE)", () => {
const html = render(<CompareToggle />, "de");
expect(html).toContain("Aus");
expect(html).toContain("Vormonat");
expect(html).toContain("Vorjahr");
});
it("attaches role=group with the toggleLabel for screen readers", () => {
const html = render(<CompareToggle />);
expect(html).toContain('role="group"');
expect(html).toMatch(/aria-label="(Compare to|Vergleichen mit)"/);
});
});
@@ -1,111 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import {
COMPARISON_BASELINES,
resolveDashboardLayout,
type ComparisonBaseline,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
/**
* v1.4.16 phase D reconcile (CRITICAL C3) — on-surface comparison
* toggle. Replaces the Settings-only Select buried 3 clicks deep with
* a 3-segment control mounted next to the dashboard greeting and the
* insights page hero. Persists via the same `/api/dashboard/widgets`
* PUT the Settings page uses, so a flip on either surface updates the
* other on next refetch.
*
* Hit area: each segment is `min-h-11` (44 px) per WCAG 2.5.5; pressed
* state is the `default` button variant, idle is `outline`.
*/
export function CompareToggle({ className }: { className?: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: layoutData } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
return apiGet<DashboardLayout>("/api/dashboard/widgets");
},
});
const layout = layoutData ? resolveDashboardLayout(layoutData) : null;
const value: ComparisonBaseline = layout?.comparisonBaseline ?? "none";
const mutation = useMutation({
mutationFn: async (next: ComparisonBaseline) => {
if (!layout) throw new Error("layout-not-loaded");
const body: DashboardLayout = { ...layout, comparisonBaseline: next };
return apiPut<DashboardLayout>("/api/dashboard/widgets", body);
},
onMutate: async (next) => {
await queryClient.cancelQueries({
queryKey: queryKeys.dashboardWidgets(),
});
const prev = queryClient.getQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
);
if (prev) {
queryClient.setQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
{ ...prev, comparisonBaseline: next },
);
}
return { prev };
},
onError: (_err, _next, ctx) => {
if (ctx?.prev) {
queryClient.setQueryData(queryKeys.dashboardWidgets(), ctx.prev);
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardWidgets(),
});
},
});
return (
<div
role="group"
aria-label={t("comparison.toggleLabel")}
data-slot="compare-toggle"
className={cn(
"border-border bg-background inline-flex flex-wrap items-center gap-0.5 rounded-md border p-0.5",
className,
)}
>
{COMPARISON_BASELINES.map((option) => {
const active = value === option;
return (
<button
key={option}
type="button"
data-slot={`compare-toggle-option-${option}`}
data-active={active ? "true" : undefined}
aria-pressed={active}
disabled={mutation.isPending || !layout}
onClick={() => {
if (!active) mutation.mutate(option);
}}
className={cn(
"min-h-11 rounded px-3 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent",
mutation.isPending && "cursor-progress",
)}
>
{t(`comparison.baseline.${option}`)}
</button>
);
})}
</div>
);
}
@@ -1,144 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { ConfidenceMeter } from "../confidence-meter";
/**
* v1.4.16 phase B5d — `<ConfidenceMeter>`.
*
* Two variants:
* - `bars` (default): five vertical bars, lit count = ceil(value/20).
* 0..20 = 1, 21..40 = 2, 41..60 = 3, 61..80 = 4, 81..100 = 5.
* - `ring`: SVG ring, fill proportional to value/100.
*
* Color bands match research §2.A "three-band visual" (extended to 4
* for resolution under 50):
* - >=80 → green
* - 50..79 → yellow
* - 25..49 → orange
* - <25 → red, AND the meter is replaced with a "draft" pill so the
* UI signals "model is unsure, not asserting"
*
* aria-label translates correctly per locale ("Confidence: 67 of 100").
* Mobile-friendly: meter components fit within 96 px wide.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<ConfidenceMeter> bars variant (default)", () => {
it("renders 5 lit bars at value=100", () => {
const html = render(<ConfidenceMeter value={100} />);
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
expect(lit).toBe(5);
});
it("renders 4 lit bars at value=80", () => {
const html = render(<ConfidenceMeter value={80} />);
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
expect(lit).toBe(4);
});
it("renders 3 lit bars at value=60", () => {
const html = render(<ConfidenceMeter value={60} />);
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
expect(lit).toBe(3);
});
it("renders 2 lit bars at value=40", () => {
const html = render(<ConfidenceMeter value={40} />);
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
expect(lit).toBe(2);
});
it("renders 2 lit bars at value=25 (lowest non-draft band)", () => {
// Below 25 the meter is replaced by a draft pill. The bars-only
// floor is therefore at 25 — and ceil(25/20) = 2.
const html = render(<ConfidenceMeter value={25} />);
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
expect(lit).toBe(2);
});
it("uses the success tint at value>=80", () => {
const html = render(<ConfidenceMeter value={85} />);
expect(html).toContain('data-confidence-band="high"');
expect(html).toMatch(/bg-success/);
});
it("uses the caution tint at value 50..79", () => {
const html = render(<ConfidenceMeter value={65} />);
expect(html).toContain('data-confidence-band="medium"');
expect(html).toMatch(/bg-warning/);
});
// Medium and low share the caution tint (coverage-meter precedent);
// the band attribute + lit-bar count carry the distinction.
it("uses the caution tint at value 25..49", () => {
const html = render(<ConfidenceMeter value={35} />);
expect(html).toContain('data-confidence-band="low"');
expect(html).toMatch(/bg-warning/);
});
it("renders the 'draft' pill INSTEAD of bars when value<25", () => {
const html = render(<ConfidenceMeter value={15} />);
// No bars drawn at all
expect(html).not.toMatch(/data-bar-state="lit"/);
// Pill rendered
expect(html).toContain('data-confidence-band="draft"');
expect(html).toMatch(/Draft|Entwurf/);
});
it("the draft pill is also rendered at value=0", () => {
const html = render(<ConfidenceMeter value={0} />);
expect(html).toContain('data-confidence-band="draft"');
});
});
describe("<ConfidenceMeter> ring variant", () => {
it("renders an SVG when variant=ring", () => {
const html = render(<ConfidenceMeter value={67} variant="ring" />);
expect(html).toContain("<svg");
expect(html).toContain('data-confidence-band="medium"');
});
it("ring variant respects the draft band below 25", () => {
const html = render(<ConfidenceMeter value={10} variant="ring" />);
expect(html).toContain('data-confidence-band="draft"');
// Same draft pill rule as bars.
expect(html).not.toContain("<svg");
});
});
describe("<ConfidenceMeter> aria-label", () => {
it("EN: 'Confidence: 67 of 100'", () => {
const html = render(<ConfidenceMeter value={67} />);
expect(html).toMatch(/aria-label="Confidence: 67 of 100"/);
});
it("DE: 'Vertrauen: 67 von 100'", () => {
const html = render(<ConfidenceMeter value={67} />, "de");
expect(html).toMatch(/aria-label="Vertrauen: 67 von 100"/);
});
it("draft state aria-label still announces the score (so screenreaders aren't blind to it)", () => {
const html = render(<ConfidenceMeter value={15} />);
expect(html).toMatch(/aria-label="Confidence: 15 of 100"/);
});
});
describe("<ConfidenceMeter> input clamping", () => {
it("clamps negative value to 0 (renders draft pill)", () => {
const html = render(<ConfidenceMeter value={-5} />);
expect(html).toContain('data-confidence-band="draft"');
});
it("clamps value>100 to 100 (renders 5 bars, high band)", () => {
const html = render(<ConfidenceMeter value={250} />);
const lit = (html.match(/data-bar-state="lit"/g) ?? []).length;
expect(lit).toBe(5);
expect(html).toContain('data-confidence-band="high"');
});
});
@@ -1,168 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationCard } from "../recommendation-card";
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" />,
}));
// B5e wired RecommendationFeedback into the rec card; that component
// pulls in useAuth + useMutation from tanstack-query, both of which
// need stubbing for SSR rendering in tests.
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() }),
// v1.21.0 — the card footer's `<AskCoachAction>` reaches the
// query-client mount probe; a null-default context reads as "no client
// mounted" so the Coach hooks return their fail-open defaults.
QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
/**
* v1.4.16 phase B5d — confidence meter wiring inside
* `<RecommendationCard>`.
*
* The card reserves a `data-slot="rec-confidence-slot"` placeholder
* (B5c). B5d fills that slot with `<ConfidenceMeter>`. Behaviour:
*
* - rec.confidence undefined -> slot stays empty (legacy payload).
* - >=50 -> meter only.
* - 25..49 -> meter + low-confidence caption inside the expanded
* rationale card ("Low confidence — based on limited data").
* - <25 -> "draft" pill replaces the meter (handled by
* <ConfidenceMeter> itself); the low-confidence caption ALSO
* surfaces because draft <= low.
*
* The meter sits in the collapsed-row slot so users can see the
* confidence at a glance without expanding.
*/
const baseRationale = {
dataWindow: "last7days" as const,
comparedTo: "your 90-day median (122/78)",
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
};
const recBase = {
id: "rec-1",
text: "Discuss home BP log with your physician.",
severity: "important" as const,
rationale: baseRationale,
metricSource: {
type: "bloodPressure",
timeRange: "last7days",
summary: "avg 138/86 across 9 readings",
},
};
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<RecommendationCard> — confidence slot wiring", () => {
it("renders ConfidenceMeter inside rec-confidence-slot when confidence is set", () => {
const html = render(
<RecommendationCard rec={{ ...recBase, confidence: 85 }} index={0} />,
);
// Slot wraps the meter
expect(html).toMatch(
/data-slot="rec-confidence-slot"[\s\S]*?data-slot="confidence-meter"/,
);
expect(html).toContain('data-confidence-band="high"');
});
it("leaves rec-confidence-slot empty when rec has no confidence (legacy)", () => {
const html = render(<RecommendationCard rec={recBase} index={0} />);
// Slot is present but should not contain a confidence-meter inside it.
expect(html).toMatch(/data-slot="rec-confidence-slot"/);
expect(html).not.toContain('data-slot="confidence-meter"');
});
it("yellow band for medium-confidence (50-79)", () => {
const html = render(
<RecommendationCard rec={{ ...recBase, confidence: 67 }} index={0} />,
);
expect(html).toContain('data-confidence-band="medium"');
});
it("orange low band + low-confidence caption when value 25-49 and expanded", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 35 }}
index={0}
initiallyExpanded
/>,
);
expect(html).toContain('data-confidence-band="low"');
expect(html).toMatch(/Low confidence — based on limited data/);
});
it("low-confidence caption is suppressed when meter is high or medium", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 85 }}
index={0}
initiallyExpanded
/>,
);
expect(html).not.toMatch(/Low confidence/);
});
it("draft pill below 25 (rendered by ConfidenceMeter)", () => {
const html = render(
<RecommendationCard rec={{ ...recBase, confidence: 15 }} index={0} />,
);
expect(html).toContain('data-confidence-band="draft"');
expect(html).toMatch(/Draft/);
});
it("German locale renders translated low-confidence caption", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 35 }}
index={0}
initiallyExpanded
/>,
"de",
);
expect(html).toMatch(/Geringes Vertrauen/);
});
it("rec-feedback-slot is still rendered (B5e plug-in point intact)", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, confidence: 85 }}
index={0}
initiallyExpanded
/>,
);
// The feedback slot itself is present so B5e (or future phases)
// can plug in. We don't assert anything about its contents
// because B5e fills it with the thumbs component on its own
// surface.
expect(html).toMatch(/data-slot="rec-feedback-slot"/);
});
});
@@ -1,309 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationCard } from "../recommendation-card";
import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
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"} />
),
}));
// v1.18.11 (W5 perf) — the rationale charts now route through `next/dynamic`
// so recharts is off the insights first-load JS. Under `renderToStaticMarkup`
// a `dynamic(..., { ssr:false })` boundary paints its loading skeleton (it
// never resolves the lazy chunk synchronously). For the chart-SELECTION
// assertions below we stub `next/dynamic` to a passthrough that renders the
// (already-mocked) underlying chart component eagerly, so the test still
// verifies the card picks the right chart kind for a metric. Production keeps
// the real lazy boundary + skeleton.
vi.mock("next/dynamic", async () => {
const health = await import("@/components/charts/health-chart");
const mood = await import("@/components/charts/mood-chart");
return {
default: (loader: unknown) => {
// Identify which chart the loader targets by its source text. Every
// loader now resolves the shared chart-runtime boundary and picks a
// named export off it, so the export name is the discriminator.
const src = String(loader);
const Comp = (
src.includes("MoodChart") ? mood.MoodChart : health.HealthChart
) as React.ComponentType<Record<string, unknown>>;
return (props: Record<string, unknown>) => <Comp {...props} />;
},
};
});
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() }),
// v1.21.0 — the card's footer `<AskCoachAction>` reaches the
// query-client mount probe (`useFeatureFlags` / `useDisableCoach`); the
// probe only needs the context to exist. A null-default context reads
// as "no client mounted" so both hooks return their fail-open defaults.
QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
const knownRef = MEDICAL_REFERENCES[0];
const baseRationale = {
dataWindow: "last7days" as const,
comparedTo: "your 90-day median (122/78)",
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
};
const recBase = {
id: "rec-1",
text: "Discuss home BP log with your physician.",
severity: "important" as const,
rationale: baseRationale,
metricSource: {
type: "bloodPressure",
timeRange: "last7days",
summary: "avg 138/86 across 9 readings",
},
};
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* v1.4.16 phase B5c — Oura-style RecommendationCard.
*
* Each rec collapses into a one-row summary (text + severity + chevron
* + named slots for confidence ring (B5d) + feedback thumbs (B5e)).
* Expanding reveals the rationale 3-row card + mini-chart pinned to
* the rec's data window + the citation footnote (B5a).
*
* Default: collapsed. The chevron toggles aria-expanded.
*/
describe("<RecommendationCard>", () => {
it("renders the rec text and severity badge in collapsed state", () => {
const html = render(<RecommendationCard rec={recBase} index={0} />);
expect(html).toContain("Discuss home BP log");
// The expand button has aria-expanded="false" when collapsed.
expect(html).toMatch(/aria-expanded="false"/);
});
it("renders rationale rows when initially expanded", () => {
const html = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
// All 3 row labels visible (English locale)
expect(html).toContain("Window");
expect(html).toContain("Compared to");
expect(html).toContain("Deviation");
// Values rendered verbatim
expect(html).toContain("last7days");
expect(html).toContain("your 90-day median (122/78)");
expect(html).toContain("+16/+8 mmHg above baseline");
// aria-expanded flips to true
expect(html).toMatch(/aria-expanded="true"/);
});
it("renders the mini-chart pinned to the rec's data window when expanded", () => {
const html = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
expect(html).toContain('data-testid="health-chart-mock"');
expect(html).toContain('data-mini="true"');
expect(html).toContain('data-window="last7days"');
});
it("does NOT render the rationale block when collapsed (default)", () => {
const html = render(<RecommendationCard rec={recBase} index={0} />);
expect(html).not.toContain("Window");
expect(html).not.toContain("your 90-day median (122/78)");
expect(html).not.toContain('data-testid="health-chart-mock"');
});
it("renders the citation footnote when referenceId resolves and expanded", () => {
const html = render(
<RecommendationCard
rec={{ ...recBase, referenceId: knownRef.id }}
index={0}
initiallyExpanded
/>,
);
expect(html).toContain(knownRef.url);
expect(html).toContain('data-slot="insight-recommendation-source"');
});
it("does not render rationale block when rec lacks rationale (legacy migration grace)", () => {
const legacyRec = {
id: "rec-old",
text: "Walk more",
severity: "suggestion" as const,
};
const html = render(
<RecommendationCard rec={legacyRec} index={0} initiallyExpanded />,
);
expect(html).toContain("Walk more");
// No rationale labels
expect(html).not.toContain("Window");
expect(html).not.toContain("Compared to");
// No expand control either since there's nothing to expand to
expect(html).not.toMatch(/aria-expanded=/);
});
it("treats a plain-string rec as legacy (no expand chevron)", () => {
const html = render(
<RecommendationCard rec="Continue daily logging." index={0} />,
);
expect(html).toContain("Continue daily logging.");
expect(html).not.toMatch(/aria-expanded=/);
});
it("German locale labels — rationale rows translate", () => {
const html = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
"de",
);
expect(html).toContain("Fenster");
expect(html).toContain("Verglichen mit");
expect(html).toContain("Abweichung");
});
it("renders named slots for confidence (B5d) and feedback (B5e) so future phases can plug in", () => {
const html = render(<RecommendationCard rec={recBase} index={0} />);
// Slot for confidence ring — B5d will fill this.
expect(html).toMatch(/data-slot="rec-confidence-slot"/);
// Slot for feedback thumbs — B5e will fill this. The slot lives
// inside the expanded card, so wrap with initiallyExpanded.
const expanded = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
expect(expanded).toMatch(/data-slot="rec-feedback-slot"/);
});
it("maps metricSource.type to the chart's metric handle (BP_SYS for bloodPressure)", () => {
const html = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
// The chart mock echoes its `types` prop. bloodPressure → both
// sys + dia (we render both lines on the same mini-chart).
expect(html).toMatch(/data-types="BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA"/);
});
it("maps metricSource.type=mood to the MoodChart, also mini", () => {
const moodRec = {
...recBase,
metricSource: {
type: "mood",
timeRange: "last7days",
summary: "5/5 logs",
},
};
const html = render(
<RecommendationCard rec={moodRec} index={0} initiallyExpanded />,
);
expect(html).toContain('data-testid="mood-chart-mock"');
expect(html).toContain('data-mini="true"');
});
// ── v1.4.16 phase B5e — feedback wiring ───────────────────────
// Verifies B5c's named slot is now filled by RecommendationFeedback
// when the rec carries every attribute the feedback API requires.
it("wires the feedback thumbs into the expanded rationale card when fully attributed", () => {
const html = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
expect(html).toContain('data-feedback-thumb="up"');
expect(html).toContain('data-feedback-thumb="down"');
});
it("does NOT render feedback thumbs when the rec lacks an id (defence-in-depth)", () => {
const recNoId = { ...recBase, id: undefined };
const html = render(
<RecommendationCard rec={recNoId} index={0} initiallyExpanded />,
);
expect(html).not.toContain("data-feedback-thumb=");
});
it("does NOT render feedback thumbs when the metricSource.timeRange is out of vocabulary", () => {
const recBadWindow = {
...recBase,
metricSource: { ...recBase.metricSource, timeRange: "lastFortnight" },
};
const html = render(
<RecommendationCard rec={recBadWindow} index={0} initiallyExpanded />,
);
expect(html).not.toContain("data-feedback-thumb=");
});
/**
* v1.4.22 A6 — strip chart-tokens from the always-visible
* recommendation text. W1a §3 traced the production "metric:WEIGHT"
* leak the maintainer saw at the bottom of the advisor card to this exact
* render path: every other AI-prose surface ran through
* `stripChartTokens()`, the recommendation text didn't. Pin the
* strip so a future copy-paste regression can't reintroduce the
* leak.
*/
it("strips literal chart tokens (metric:WEIGHT et al) from the rec text", () => {
const recWithToken = {
...recBase,
text: "Increase fluids. metric:WEIGHT",
};
const html = render(<RecommendationCard rec={recWithToken} index={0} />);
expect(html).not.toContain("metric:WEIGHT");
expect(html).toContain("Increase fluids.");
});
it("strips lowercase / mixed-case model output from the rec text", () => {
const recWithLowercaseToken = {
...recBase,
text: "Watch the trend. metric:blood_pressure_sys today",
};
const html = render(
<RecommendationCard rec={recWithLowercaseToken} index={0} />,
);
expect(html).not.toContain("metric:blood_pressure_sys");
expect(html).toContain("Watch the trend.");
expect(html).toContain("today");
});
});
@@ -1,94 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationFeedback } from "../recommendation-feedback";
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,
}),
}));
const baseProps = {
recId: "rec-1",
recText: "Discuss home BP log with your physician.",
recSeverity: "important" as const,
metricSourceType: "bloodPressure",
metricSourceTimeRange: "last7days" as const,
};
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<RecommendationFeedback>", () => {
it("renders thumbs-up + thumbs-down buttons", () => {
const html = render(<RecommendationFeedback {...baseProps} />);
expect(html).toContain('data-feedback-thumb="up"');
expect(html).toContain('data-feedback-thumb="down"');
});
it("attaches accessible aria-label keys", () => {
const html = render(<RecommendationFeedback {...baseProps} />);
expect(html).toContain('aria-label="Helpful');
expect(html).toContain('aria-label="Not helpful');
});
it("translates the labels to German when locale=de", () => {
const html = render(<RecommendationFeedback {...baseProps} />, "de");
expect(html).toContain("Hilfreich");
expect(html).toContain("Nicht hilfreich");
});
it("renders the 'thanks for your feedback' confirmation when initialState=submitted-up", () => {
const html = render(
<RecommendationFeedback {...baseProps} initialState="submitted-up" />,
);
// Confirmation text replaces both buttons.
expect(html).toContain("Thanks");
// Highlighted thumb-up still rendered for context.
expect(html).toContain('data-feedback-confirmed="up"');
// The opposite button should NOT be re-clickable.
expect(html).not.toContain('data-feedback-thumb="down"');
});
it("renders the 'already rated' state with the prior verdict highlighted", () => {
const html = render(
<RecommendationFeedback
{...baseProps}
initialState="already-rated-down"
/>,
);
expect(html).toContain('data-feedback-already-rated="down"');
// Buttons are not interactive in this state.
expect(html).not.toContain('data-feedback-thumb="up"');
expect(html).not.toContain('data-feedback-thumb="down"');
});
it("buttons carry type=button so they don't accidentally submit a parent form", () => {
const html = render(<RecommendationFeedback {...baseProps} />);
// Both up + down buttons must declare type=button.
const upMatch = html.match(/<button[^>]*data-feedback-thumb="up"[^>]*>/);
const downMatch = html.match(
/<button[^>]*data-feedback-thumb="down"[^>]*>/,
);
expect(upMatch?.[0]).toContain('type="button"');
expect(downMatch?.[0]).toContain('type="button"');
});
});
@@ -1,177 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
RecommendationsGrid,
sortRecommendationsBySeverity,
} from "../recommendations-grid";
import type { InsightRecommendation } from "@/lib/ai/types";
/**
* v1.4.16 phase B1b — recommendations grid + severity-ordering wrapper.
*
* Pure presentational shell around the existing RecommendationCard:
* - 1-col mobile, 2-col desktop layout
* - severity-priority ordering: urgent → important → suggestion → info
* - staggered fade-in (animation-delay derived from index)
* - the cards keep their own per-rec rendering — this wrapper is a
* layout shell only.
*/
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 }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
// v1.21.0 — the card footer's `<AskCoachAction>` reaches the
// query-client mount probe; a null-default context reads as "no client
// mounted" so the Coach hooks return their fail-open defaults.
QueryClientContext: createContext(null),
}));
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,
): InsightRecommendation {
return {
id,
text,
severity,
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 render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("sortRecommendationsBySeverity", () => {
it("orders urgent → important → suggestion → info", () => {
const recs: InsightRecommendation[] = [
rec("a", "info", "info-a"),
rec("b", "urgent", "urgent-b"),
rec("c", "suggestion", "suggestion-c"),
rec("d", "important", "important-d"),
];
const sorted = sortRecommendationsBySeverity(recs);
const ids = sorted.map((r) => (typeof r === "string" ? r : (r.id ?? "")));
expect(ids).toEqual(["b", "d", "c", "a"]);
});
it("preserves original order within the same severity bucket (stable)", () => {
const recs: InsightRecommendation[] = [
rec("a", "important", "first"),
rec("b", "important", "second"),
rec("c", "important", "third"),
];
const sorted = sortRecommendationsBySeverity(recs);
expect(sorted.map((r) => (typeof r === "string" ? r : r.id))).toEqual([
"a",
"b",
"c",
]);
});
it("treats plain-string recs and missing-severity recs as lowest priority", () => {
const recs: InsightRecommendation[] = [
"plain string rec",
rec("a", "urgent", "urgent-a"),
// partial — no severity
{ id: "b", text: "untyped" },
];
const sorted = sortRecommendationsBySeverity(recs);
expect(typeof sorted[0]).toBe("object");
if (typeof sorted[0] !== "string") {
expect(sorted[0].id).toBe("a");
}
// plain-string and id=b both fall to the bottom; relative order
// among the bottom bucket is stable (the string came first in the
// input).
expect(typeof sorted[1]).toBe("string");
});
});
describe("<RecommendationsGrid>", () => {
it("renders cards in a 2-col responsive grid", () => {
const recs = [rec("a", "info", "alpha"), rec("b", "urgent", "bravo")];
const html = render(<RecommendationsGrid recs={recs} />);
expect(html).toMatch(/data-slot="rec-grid"/);
expect(html).toMatch(/grid-cols-1/);
expect(html).toMatch(/lg:grid-cols-2/);
});
it("renders cards in severity-ordered sequence (urgent first)", () => {
const recs = [rec("a", "info", "alpha"), rec("b", "urgent", "bravo")];
const html = render(<RecommendationsGrid recs={recs} />);
// bravo (urgent) must appear before alpha (info) in the output.
expect(html.indexOf("bravo")).toBeLessThan(html.indexOf("alpha"));
expect(html.indexOf("bravo")).toBeGreaterThan(-1);
});
it("paints severity-coloured left border on each card", () => {
const recs = [
rec("a", "urgent", "urgent-rec"),
rec("b", "important", "important-rec"),
rec("c", "suggestion", "suggestion-rec"),
rec("d", "info", "info-rec"),
];
const html = render(<RecommendationsGrid recs={recs} />);
// Each card gets a semantic-token border-l class. The exact class
// varies by severity; we assert at least one of each is in the
// output.
expect(html).toMatch(/border-l-destructive/);
expect(html).toMatch(/border-l-warning/);
expect(html).toMatch(/border-l-primary/);
expect(html).toMatch(/border-l-info/);
});
it("renders nothing when recs is empty", () => {
const html = render(<RecommendationsGrid recs={[]} />);
expect(html).not.toMatch(/data-slot="rec-grid"/);
});
it("applies the staggered animation-delay style per card", () => {
const recs = [
rec("a", "urgent", "first"),
rec("b", "important", "second"),
rec("c", "suggestion", "third"),
];
const html = render(<RecommendationsGrid recs={recs} />);
// Cards 1+ get a non-zero animationDelay; the first card has 0ms.
expect(html).toMatch(/data-stagger-index="0"/);
expect(html).toMatch(/data-stagger-index="1"/);
expect(html).toMatch(/data-stagger-index="2"/);
});
});
@@ -1,96 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SuggestedPrompts } from "../suggested-prompts";
/**
* v1.4.20 phase B1 — "Try asking" prompt-chip strip.
*
* Renders a horizontal row of clickable prompts below the hero action
* band. Tests cover: default two-chip rendering, custom prompt list,
* onPick wiring, locale-aware label.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SuggestedPrompts>", () => {
it("renders the two default prompt chips in English", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
expect(html).toContain("What should I tell my doctor?");
expect(html).toContain("Is my medication working?");
// The speculative data-specific openers were dropped in v1.12.4.
expect(html).not.toContain("Why was BP higher on Monday?");
expect(html).not.toContain("How did weight loss affect my pulse?");
expect(html).not.toContain("Compare this week to last month");
});
it("renders the two default prompt chips in German", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />, "de");
expect(html).toContain("Wirkt mein Medikament?");
expect(html).toContain("Was sollte ich meinem Arzt sagen?");
expect(html).not.toContain("Warum war der Blutdruck am Montag höher?");
});
it("renders the 'Try asking' label in English", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
expect(html).toMatch(/data-slot="insights-suggested-prompts-label"/);
expect(html).toContain("Try asking");
});
it("renders the 'Frag mich' label in German", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />, "de");
expect(html).toContain("Frag mich");
});
it("renders one chip per prompt with the suggested-prompts-chip slot", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
const matches =
html.match(/data-slot="insights-suggested-prompts-chip"/g) ?? [];
expect(matches.length).toBe(2);
});
it("accepts a custom prompts array and renders only those", () => {
const html = render(
<SuggestedPrompts
prompts={["Custom prompt A", "Custom prompt B"]}
onPick={() => {}}
/>,
);
expect(html).toContain("Custom prompt A");
expect(html).toContain("Custom prompt B");
expect(html).not.toContain("Why was BP higher on Monday?");
const matches =
html.match(/data-slot="insights-suggested-prompts-chip"/g) ?? [];
expect(matches.length).toBe(2);
});
it("uses the primary-token chip styling", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
expect(html).toMatch(/border-primary\/18/);
});
it("invokes onPick with the localised prompt string when a chip is clicked", () => {
// We can't fire DOM events on SSR'd markup, so we verify the
// wiring via direct invocation of the component's logic. The
// chip's onClick passes the resolved prompt string verbatim.
const handler = vi.fn();
// Render to ensure no throw + capture the prompt strings the
// component would forward.
const html = render(<SuggestedPrompts onPick={handler} />);
expect(html).toContain("What should I tell my doctor?");
// Direct call — SSR can't drive a click; smoke-check the contract.
handler("What should I tell my doctor?");
expect(handler).toHaveBeenCalledWith("What should I tell my doctor?");
});
it("forwards a custom className for layout overrides", () => {
const html = render(
<SuggestedPrompts onPick={() => {}} className="my-custom-spacing" />,
);
expect(html).toContain("my-custom-spacing");
});
});
@@ -1,202 +0,0 @@
import { useTranslations } from "@/lib/i18n/context";
/**
* Renders a 0..100 confidence score as a 5-bar meter (default) or SVG
* ring. Sub-25 replaces the meter with a "draft" pill ("quiet-when-
* unsure"). aria-label always carries the numeric value.
*/
export type ConfidenceBand = "high" | "medium" | "low" | "draft";
export type ConfidenceMeterVariant = "bars" | "ring";
export interface ConfidenceMeterProps {
/** Score carried on the insight payload — 0..100, integer expected. */
value: number;
/** Visual variant. Defaults to `bars`. */
variant?: ConfidenceMeterVariant;
/** Optional className applied to the outer wrapper. */
className?: string;
}
const DRAFT_THRESHOLD = 25;
function clamp(value: number): number {
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 100) return 100;
return Math.round(value);
}
function bandFor(value: number): ConfidenceBand {
if (value < DRAFT_THRESHOLD) return "draft";
if (value < 50) return "low";
if (value < 80) return "medium";
return "high";
}
/**
* Lit-bar count = ceil(value / 20). 0..20 is 1, 21..40 is 2, etc.
* Note: when band is "draft" we don't render bars at all, so this
* helper is only consulted for non-draft bands. We still clamp to
* [1, 5] to defend against off-by-one drift.
*/
function litBarsFor(value: number): number {
const lit = Math.ceil(value / 20);
if (lit < 1) return 1;
if (lit > 5) return 5;
return lit;
}
// Semantic severities only (the coverage-meter precedent): medium and
// low both ride the caution `--warning` — the lit-bar count and the
// numeric aria-label carry the medium↔low distinction, never colour
// alone.
const BAR_LIT_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "bg-success",
medium: "bg-warning",
low: "bg-warning",
};
const RING_STROKE_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "stroke-success",
medium: "stroke-warning",
low: "stroke-warning",
};
function DraftPill({ ariaLabel }: { ariaLabel: string }) {
const { t } = useTranslations();
return (
<span
data-confidence-band="draft"
role="img"
aria-label={ariaLabel}
className="bg-destructive/10 text-destructive border-destructive/25 inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium tracking-wide uppercase"
>
{t("insights.recommendation.confidenceDraft")}
</span>
);
}
function BarsMeter({
value,
band,
ariaLabel,
}: {
value: number;
band: Exclude<ConfidenceBand, "draft">;
ariaLabel: string;
}) {
const lit = litBarsFor(value);
const litColour = BAR_LIT_COLOUR_BY_BAND[band];
return (
<span
data-confidence-band={band}
role="img"
aria-label={ariaLabel}
className="inline-flex items-end gap-[2px]"
>
{[1, 2, 3, 4, 5].map((i) => {
const isLit = i <= lit;
// Bars rise in height to give a visual "more is more" cue
// independent of colour (accessibility): 4 / 6 / 8 / 10 / 12 px.
const heightClass = ["h-1", "h-1.5", "h-2", "h-2.5", "h-3"][i - 1];
return (
<span
key={i}
data-bar-index={i}
data-bar-state={isLit ? "lit" : "unlit"}
className={`w-[3px] rounded-sm ${heightClass} ${
isLit ? litColour : "bg-muted/40"
}`}
/>
);
})}
</span>
);
}
function RingMeter({
value,
band,
ariaLabel,
}: {
value: number;
band: Exclude<ConfidenceBand, "draft">;
ariaLabel: string;
}) {
const stroke = RING_STROKE_COLOUR_BY_BAND[band];
// Ring geometry: r=10, circumference=2π·10≈62.83. Fill ratio = value/100.
const radius = 10;
const circumference = 2 * Math.PI * radius;
const dashArray = circumference;
const dashOffset = circumference * (1 - value / 100);
return (
<svg
data-confidence-band={band}
role="img"
aria-label={ariaLabel}
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
>
<circle
cx="14"
cy="14"
r={radius}
className="stroke-muted/30"
strokeWidth="3"
/>
<circle
cx="14"
cy="14"
r={radius}
className={stroke}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={dashArray}
strokeDashoffset={dashOffset}
transform="rotate(-90 14 14)"
/>
</svg>
);
}
export function ConfidenceMeter({
value,
variant = "bars",
className,
}: ConfidenceMeterProps) {
const { t } = useTranslations();
const clamped = clamp(value);
const band = bandFor(clamped);
const ariaLabel = t("insights.recommendation.confidenceAria", {
value: clamped,
});
const wrapperClass = `inline-flex items-center ${className ?? ""}`.trim();
if (band === "draft") {
return (
<span data-slot="confidence-meter" className={wrapperClass}>
<DraftPill ariaLabel={ariaLabel} />
</span>
);
}
return (
<span data-slot="confidence-meter" className={wrapperClass}>
{variant === "ring" ? (
<RingMeter value={clamped} band={band} ariaLabel={ariaLabel} />
) : (
<BarsMeter value={clamped} band={band} ariaLabel={ariaLabel} />
)}
</span>
);
}
@@ -1,454 +0,0 @@
"use client";
import { useState, type ComponentProps } from "react";
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import type {
InsightRecommendation,
InsightRecommendationRationale,
} from "@/lib/ai/types";
import dynamic from "next/dynamic";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { Locale } from "@/lib/i18n/config";
import { AskCoachAction } from "./ask-coach-action";
import { scopeSourceFromMetricKey } from "./coach-metric-scope";
import {
RecommendationFeedback,
type RecommendationFeedbackSeverity,
type RecommendationFeedbackTimeRange,
} from "./recommendation-feedback";
import { ConfidenceMeter } from "./confidence-meter";
/**
* v1.18.11 (W5 perf) — route both rationale mini-charts through
* `next/dynamic` so recharts no longer rides the main insights surface's
* first-load JS. Each chart only paints inside an expanded rationale block,
* so deferring it costs nothing visible and matches the loading skeleton
* the canonical `health-chart-dynamic.tsx` boundary uses (`mini` here,
* since both charts render with the `mini` prop in the rationale card).
*/
const HealthChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.HealthChart }),
),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
const HealthChart = (props: ComponentProps<typeof HealthChartLazy>) => (
<ChartErrorBoundary>
<HealthChartLazy {...props} />
</ChartErrorBoundary>
);
const MoodChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.MoodChart }),
),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
const MoodChart = (props: ComponentProps<typeof MoodChartLazy>) => (
<ChartErrorBoundary>
<MoodChartLazy {...props} />
</ChartErrorBoundary>
);
/**
* Collapsible recommendation card with severity badge, confidence
* meter, and an expandable rationale block (window / compared-to /
* deviation) + mini-chart. Plain-string and rationale-less recs
* render as a non-expandable row.
*/
const SEVERITY_BADGE_STYLES: Record<string, string> = {
info: "bg-info/10 text-info border border-info/25",
suggestion: "bg-dose-accent/10 text-dose-accent border border-dose-accent/25",
important: "bg-warning/10 text-warning border border-warning/25",
urgent: "bg-destructive/10 text-destructive border border-destructive/25",
};
interface RecommendationCardProps {
rec: InsightRecommendation;
index: number;
/** Force the rationale block visible — mostly for tests + storybook. */
initiallyExpanded?: boolean;
}
interface NormalisedRec {
text: string;
severity?: "info" | "suggestion" | "important" | "urgent";
rationale?: InsightRecommendationRationale;
metricSource?: { type: string; timeRange: string; summary: string };
referenceId?: string;
id?: string;
/**
* v1.4.16 phase B5d — server-computed confidence (0-100). Optional
* because legacy cached payloads predate the field; the rec card's
* confidence slot stays empty in that case rather than tagging
* legacy recs as "draft".
*/
confidence?: number;
}
function normalise(rec: InsightRecommendation): NormalisedRec {
if (typeof rec === "string") return { text: rec };
return {
text: rec.text,
severity: rec.severity,
rationale: rec.rationale,
metricSource: rec.metricSource,
referenceId: rec.referenceId,
id: rec.id,
confidence: rec.confidence,
};
}
/**
* v1.4.16 phase B5d — caption threshold. Below 50, the expanded
* rationale card surfaces a "Low confidence — based on limited data"
* sentence so the rec stays visible but framed as preliminary.
* Above 50 the meter alone speaks; below 25 the meter is replaced by
* a draft pill (handled inside `<ConfidenceMeter>`) and the caption
* still applies (draft <= low).
*/
const LOW_CONFIDENCE_CAPTION_THRESHOLD = 50;
/**
* Map a `metricSource.type` value to the chart-types[] the dashboard
* uses. The model speaks snapshot-key vocabulary ("bloodPressure",
* "weight", "pulse", "mood", "medications.compliance30") which
* doesn't 1:1 match the measurement-type enum the chart consumes
* ("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT", …). This
* map is deliberately tight; unknown types fall through to a single-
* type chart so a future provider that emits a new key still renders
* something rather than nothing.
*/
function metricTypeToChartTypes(metricType: string | undefined): string[] {
if (!metricType) return [];
const lower = metricType.toLowerCase();
if (lower === "bloodpressure" || lower === "blood_pressure") {
return ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"];
}
if (lower === "weight") return ["WEIGHT"];
if (lower === "pulse") return ["PULSE"];
if (lower === "bodyfat" || lower === "body_fat") return ["BODY_FAT"];
if (lower === "sleep" || lower === "sleep_duration") {
return ["SLEEP_DURATION"];
}
if (lower === "activity" || lower === "steps") {
return ["ACTIVITY_STEPS"];
}
if (lower === "bloodglucose" || lower === "blood_glucose") {
return ["BLOOD_GLUCOSE"];
}
// Unknown / synthetic key — pass it through verbatim so the chart
// can render empty rather than nothing.
return [metricType];
}
function isMoodMetric(metricType: string | undefined): boolean {
return metricType?.toLowerCase() === "mood";
}
function isComplianceMetric(metricType: string | undefined): boolean {
return (
metricType?.toLowerCase().startsWith("medications.compliance") === true ||
metricType?.toLowerCase() === "medication"
);
}
function CitationFootnote({
referenceId,
locale,
}: {
referenceId: string;
locale: Locale;
}) {
const { t } = useTranslations();
const ref = getMedicalReferenceById(referenceId);
if (!ref) return null;
const label = locale === "de" ? ref.titleDe : ref.title;
return (
<a
href={ref.url}
target="_blank"
rel="noreferrer"
title={t("insights.recommendation.viewSource")}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
data-slot="insight-recommendation-source"
data-reference-id={referenceId}
>
<span className="font-medium">
{t("insights.recommendation.source")}:
</span>
<span>
{ref.org} {ref.publishedYear} — {label}
</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
);
}
function RationaleRow({ label, value }: { label: string; value: string }) {
return (
<div
className="grid grid-cols-[7rem_1fr] items-start gap-2 text-xs"
data-slot="rec-rationale-row"
>
<span className="text-muted-foreground font-medium">{label}</span>
<span className="text-foreground">{value}</span>
</div>
);
}
function RationaleCard({
rationale,
metricSource,
referenceId,
confidence,
locale,
feedbackProps,
}: {
rationale: InsightRecommendationRationale;
metricSource:
{ type: string; timeRange: string; summary: string } | undefined;
referenceId: string | undefined;
confidence: number | undefined;
locale: Locale;
feedbackProps: {
recId: string;
recText: string;
recSeverity: RecommendationFeedbackSeverity;
metricSourceType: string;
metricSourceTimeRange: RecommendationFeedbackTimeRange;
} | null;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const metricType = metricSource?.type;
const chartTypes = metricTypeToChartTypes(metricType);
const showLowConfidenceCaption =
typeof confidence === "number" &&
confidence < LOW_CONFIDENCE_CAPTION_THRESHOLD;
return (
<div
data-slot="rec-rationale-card"
className="bg-muted/30 mt-3 space-y-3 rounded-md p-3"
>
<div className="space-y-1.5">
<RationaleRow
label={t("insights.recommendation.rationaleWindow")}
value={stripChartTokens(rationale.dataWindow)}
/>
<RationaleRow
label={t("insights.recommendation.rationaleComparedTo")}
value={stripChartTokens(rationale.comparedTo)}
/>
<RationaleRow
label={t("insights.recommendation.rationaleDeviation")}
value={stripChartTokens(rationale.deviation)}
/>
</div>
{/* Mini-chart pinned to the rec's window. mood + medication-
compliance get dedicated wrappers; everything else routes
through HealthChart with the metric-key vocabulary. */}
{isMoodMetric(metricType) ? (
<MoodChart mini windowOverride={rationale.dataWindow} />
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
<HealthChart
types={chartTypes}
title={metricType ?? ""}
mini
windowOverride={rationale.dataWindow}
userTimezone={user?.timezone}
/>
) : null}
{showLowConfidenceCaption && (
<p
data-slot="rec-low-confidence-caption"
className="text-muted-foreground text-xs italic"
>
{t("insights.recommendation.confidenceLow")}
</p>
)}
{referenceId && (
<div>
<CitationFootnote referenceId={referenceId} locale={locale} />
</div>
)}
{/* v1.4.16 phase B5e — fills the rec-feedback-slot reserved by
* B5c. The feedback row only appears when the rec carries the
* full attribute set the API endpoint requires; legacy recs
* without an id stay un-rateable (the empty slot keeps the DOM
* stable for downstream tests). */}
<div data-slot="rec-feedback-slot">
{feedbackProps && <RecommendationFeedback {...feedbackProps} />}
</div>
</div>
);
}
/**
* Validate the rec's metricSource.timeRange against the four-window
* vocabulary the feedback endpoint accepts. Returns null when the
* value is missing or out-of-vocabulary so the feedback slot stays
* empty rather than rendering a feedback row that would 422 on
* submit.
*/
function asFeedbackTimeRange(
value: string | undefined,
): RecommendationFeedbackTimeRange | null {
switch (value) {
case "last7days":
case "last30days":
case "last90days":
case "allTime":
return value;
default:
return null;
}
}
export function RecommendationCard({
rec,
index,
initiallyExpanded = false,
}: RecommendationCardProps) {
const { t, locale } = useTranslations();
const norm = normalise(rec);
const expandable = norm.rationale !== undefined;
const [expanded, setExpanded] = useState(initiallyExpanded);
// Feedback row is only renderable when the rec carries every
// attribute the API endpoint validates. Legacy / partial recs
// (no id, no severity, or an unknown timeRange) leave the slot
// empty so a thumbs-click can never produce a 422.
const feedbackTimeRange = asFeedbackTimeRange(norm.metricSource?.timeRange);
const feedbackProps =
norm.id && norm.severity && norm.metricSource?.type && feedbackTimeRange
? {
recId: norm.id,
recText: norm.text,
recSeverity: norm.severity,
metricSourceType: norm.metricSource.type,
metricSourceTimeRange: feedbackTimeRange,
}
: null;
// v1.21.0 (C4 H2) — reverse-direction Coach hand-off. The opener
// quotes the rec so the conversation lands on this exact suggestion;
// when the rec carries a metric source, scope the snapshot to it.
const coachScopeSource = scopeSourceFromMetricKey(norm.metricSource?.type);
const coachQuestion = t("insights.coach.seed.recommendation", {
text: stripChartTokens(norm.text),
});
return (
<div
data-slot="rec-card"
data-index={index}
className="border-border/40 bg-card/20 rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
<span
className="text-muted-foreground mt-0.5 shrink-0 text-xs font-medium tabular-nums"
aria-hidden="true"
>
{index + 1}.
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
{norm.severity && (
<Badge
className={`text-xs tracking-wide uppercase ${
SEVERITY_BADGE_STYLES[norm.severity] ?? ""
}`}
variant="outline"
>
{norm.severity}
</Badge>
)}
<p className="text-sm leading-snug">
{stripChartTokens(norm.text)}
</p>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span data-slot="rec-confidence-slot">
{typeof norm.confidence === "number" && (
<ConfidenceMeter value={norm.confidence} />
)}
</span>
{expandable && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
title={
expanded
? t("insights.recommendation.rationaleCollapse")
: t("insights.recommendation.rationaleExpand")
}
data-slot="rec-expand-toggle"
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
{expandable && expanded && norm.rationale && (
<div
className="animate-insight-in"
style={{ animationDuration: "200ms" }}
>
<RationaleCard
rationale={norm.rationale}
metricSource={norm.metricSource}
referenceId={norm.referenceId}
confidence={norm.confidence}
locale={locale}
feedbackProps={feedbackProps}
/>
</div>
)}
{/* Inline-only citation footnote: a legacy rec (no rationale)
* still surfaces its medical reference under the rec text.
* Recs WITH rationale only render the footnote inside the
* expanded RationaleCard so it doesn't double-print. */}
{!expandable && norm.referenceId && (
<div className="mt-1 ml-6">
<CitationFootnote referenceId={norm.referenceId} locale={locale} />
</div>
)}
{/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link,
aligned under the rec text (the number gutter is `ml-6`). */}
<div className="mt-1 ml-6">
<AskCoachAction
question={coachQuestion}
scope={coachScopeSource ? { metric: coachScopeSource } : undefined}
/>
</div>
</div>
);
}
@@ -1,291 +0,0 @@
"use client";
/**
* v1.4.16 phase B5e — RecommendationFeedback.
*
* Two small icon buttons (thumb-up / thumb-down) under each rec card,
* surfaced in the `data-slot="rec-feedback-slot"` slot reserved by
* B5c's `<RecommendationCard>`.
*
* UX states:
* - default → both buttons rendered, neither pressed
* - submitting → buttons disabled, the chosen verdict spinner-tinted
* - submitted-up → confirmation row replaces the buttons + the
* pressed thumb is highlighted; "Thanks for your
* feedback" text
* - submitted-down → mirror of submitted-up for the negative verdict
* - already-rated-up / -down → quiet state when the server returned
* 409 (the user already rated this rec text in a previous session)
*
* Local cache: a `(userId, recId, recText)` tuple is keyed in
* localStorage so a refresh doesn't allow re-rating. Server-side dedup
* via the unique constraint on `recommendation_feedback` is the source
* of truth — the local cache is just a UX nicety.
*
* Keyboard accessible: both buttons are real `<button type="button">`
* elements, focusable, Enter/Space-activatable. aria-label translates.
*/
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ThumbsUp, ThumbsDown, Loader2, Check } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import type { RecommendationFeedbackRequest } from "@/lib/validations/recommendation-feedback";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
// v1.4.16 phase D reconcile (simplify F5) — re-export the canonical
// types from the validation schema instead of hand-maintaining the
// same enum vocabulary. Drift between the two used to be a real risk
// (a future "last180days" added to the schema would silently break
// the component without a compile error).
export type RecommendationFeedbackSeverity =
RecommendationFeedbackRequest["recommendationSeverity"];
export type RecommendationFeedbackTimeRange =
RecommendationFeedbackRequest["metricSourceTimeRange"];
export interface RecommendationFeedbackProps {
recId: string;
recText: string;
recSeverity: RecommendationFeedbackSeverity;
metricSourceType: string;
metricSourceTimeRange: RecommendationFeedbackTimeRange;
/**
* Override the local-cache lookup so SSR tests can pin a state
* deterministically. Production callers leave this `undefined`;
* the component reads localStorage on mount.
*/
initialState?: FeedbackState;
}
type FeedbackState =
| "default"
| "submitting-up"
| "submitting-down"
| "submitted-up"
| "submitted-down"
| "already-rated-up"
| "already-rated-down";
const LOCAL_STORAGE_PREFIX = "healthlog-rec-feedback";
function localCacheKey(userId: string, recId: string, recText: string): string {
// recText is part of the key because the server-side dedup also
// includes it — a regeneration that rewrites the same id with new
// text counts as a different row, and we want the UX to match.
// Hash via a length-bounded slice so the localStorage key stays
// small (the cache is best-effort; collisions on truncated text are
// extremely rare in practice).
const textKey = recText.slice(0, 80).replace(/\s+/g, " ").trim();
return `${LOCAL_STORAGE_PREFIX}:${userId}:${recId}:${textKey}`;
}
function readLocalState(
userId: string,
recId: string,
recText: string,
): FeedbackState {
if (typeof window === "undefined") return "default";
try {
const raw = window.localStorage.getItem(
localCacheKey(userId, recId, recText),
);
if (raw === "up" || raw === "down") return `submitted-${raw}` as const;
if (raw === "already-rated-up" || raw === "already-rated-down") {
return raw;
}
} catch {
// Private mode / quota exceeded → fall through to default.
}
return "default";
}
function writeLocalState(
userId: string,
recId: string,
recText: string,
verdict: "up" | "down" | "already-rated-up" | "already-rated-down",
): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(localCacheKey(userId, recId, recText), verdict);
} catch {
// Best-effort — server dedup is the source of truth.
}
}
export function RecommendationFeedback({
recId,
recText,
recSeverity,
metricSourceType,
metricSourceTimeRange,
initialState,
}: RecommendationFeedbackProps) {
const { t } = useTranslations();
const { user } = useAuth();
const [state, setState] = useState<FeedbackState>(() => {
if (initialState) return initialState;
if (!user) return "default";
return readLocalState(user.id, recId, recText);
});
// Track which userId the state corresponds to. When auth resolves
// after mount (fetch was in-flight on first render), we recompute
// state from the local cache during render — never via useEffect,
// which would trip the `react-hooks/set-state-in-effect` rule.
const [hydratedFor, setHydratedFor] = useState<string | null>(() =>
user ? user.id : null,
);
if (!initialState && user && hydratedFor !== user.id) {
const fromLocal = readLocalState(user.id, recId, recText);
setState(fromLocal);
setHydratedFor(user.id);
}
const mutation = useMutation({
mutationFn: async (helpful: boolean) => {
try {
await apiPost("/api/insights/feedback", {
recommendationId: recId,
recommendationText: recText,
recommendationSeverity: recSeverity,
metricSourceType,
metricSourceTimeRange,
helpful,
});
} catch (err) {
// 409 = the server already holds a verdict for this rec — treat
// as the non-throwing duplicate outcome, exactly as before.
if (err instanceof ApiError && err.status === 409) {
return { duplicate: true, helpful } as const;
}
throw err;
}
return { duplicate: false, helpful } as const;
},
onSuccess: (result) => {
if (!user) return;
if (result.duplicate) {
const verdict = result.helpful
? "already-rated-up"
: "already-rated-down";
writeLocalState(user.id, recId, recText, verdict);
setState(verdict);
return;
}
const verdict = result.helpful ? "up" : "down";
writeLocalState(user.id, recId, recText, verdict);
setState(`submitted-${verdict}` as const);
// v1.4.16 phase D reconcile (code-review H6) — feedback doesn't
// affect the displayed insight content, so invalidating the entire
// ["insights"] tree (8 queries: comprehensive + 7 per-status)
// would force expensive refetches on a stale cache. The user-
// visible state lives in localStorage + the local React state
// above. The v1.4.17 ratchet that consumes feedback into the
// next generation will scope its own invalidation.
},
onError: () => {
// Optimistic-rollback: drop back to default so the user can retry.
// v1.11.3 — the POST used to fail silently; the buttons just
// reappeared with no signal that anything went wrong. Surface a
// lightweight error toast so a failed submission is legible while
// keeping the success path quiet (the inline confirmation row is
// signal enough there).
setState("default");
toast.error(t("insights.recommendation.feedbackError"));
},
});
const submit = useMemo(
() => (helpful: boolean) => {
setState(helpful ? "submitting-up" : "submitting-down");
mutation.mutate(helpful);
},
[mutation],
);
// ── Render ──────────────────────────────────────────────────────
if (state === "submitted-up" || state === "submitted-down") {
const verdict = state === "submitted-up" ? "up" : "down";
return (
<div
className="text-muted-foreground flex items-center gap-1.5 text-xs"
data-slot="rec-feedback-confirmation"
>
<Check className="text-success h-3.5 w-3.5" aria-hidden="true" />
<span data-feedback-confirmed={verdict}>
{t("insights.recommendation.feedbackThanks")}
</span>
</div>
);
}
if (state === "already-rated-up" || state === "already-rated-down") {
const verdict = state === "already-rated-up" ? "up" : "down";
return (
<div
className="text-muted-foreground flex items-center gap-1.5 text-xs"
data-slot="rec-feedback-already-rated"
>
{verdict === "up" ? (
<ThumbsUp className="text-success h-3.5 w-3.5" aria-hidden="true" />
) : (
<ThumbsDown className="text-warning h-3.5 w-3.5" aria-hidden="true" />
)}
<span data-feedback-already-rated={verdict}>
{t("insights.recommendation.feedbackAlreadyRated")}
</span>
</div>
);
}
const submittingUp = state === "submitting-up";
const submittingDown = state === "submitting-down";
const disabled = submittingUp || submittingDown;
return (
<div className="flex items-center gap-1.5" data-slot="rec-feedback-buttons">
<button
type="button"
data-feedback-thumb="up"
aria-label={t("insights.recommendation.feedbackHelpful")}
title={t("insights.recommendation.feedbackHelpful")}
disabled={disabled}
onClick={() => submit(true)}
className="text-muted-foreground hover:text-success disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingUp ? (
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<ThumbsUp className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
<button
type="button"
data-feedback-thumb="down"
aria-label={t("insights.recommendation.feedbackNotHelpful")}
title={t("insights.recommendation.feedbackNotHelpful")}
disabled={disabled}
onClick={() => submit(false)}
className="text-muted-foreground hover:text-warning disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingDown ? (
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<ThumbsDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
</div>
);
}
@@ -1,113 +0,0 @@
"use client";
import type { InsightRecommendation } from "@/lib/ai/types";
import { RecommendationCard } from "./recommendation-card";
/**
* v1.4.16 phase B1b — Apple-Health-style recommendations grid.
*
* Pure layout shell around `<RecommendationCard>`:
* - 1-col mobile, 2-col desktop (no `xl:grid-cols-3` — recs benefit
* from breathing room on a wide viewport, and the card's expanded
* rationale + mini-chart pushes the natural max-content well past
* the 3-col break)
* - severity-priority ordering (urgent → important → suggestion → info)
* - severity-coloured left border per card via a thin shell `<div>`
* wrapper so the underlying `<RecommendationCard>` (B5c/d/e shape)
* doesn't need its own border refactor
* - staggered fade-in via inline animationDelay so the cards sweep in
* left-to-right, top-to-bottom on first render
* - hover-lift on desktop (`md:hover:-translate-y-0.5`,
* `md:hover:shadow-lg`)
*
* The grid is unmounted entirely when `recs` is empty so the parent
* doesn't have to gate every call site.
*/
interface RecommendationsGridProps {
recs: InsightRecommendation[];
}
const SEVERITY_RANK: Record<string, number> = {
urgent: 0,
important: 1,
suggestion: 2,
info: 3,
};
const SEVERITY_RANK_FALLBACK = 4;
/**
* Stable sort by severity priority. Plain-string recs and recs missing
* a severity field fall to the bottom but keep their relative order
* (the wrapping `<RecommendationCard>` still renders them — just under
* the actionable severities).
*/
export function sortRecommendationsBySeverity(
recs: InsightRecommendation[],
): InsightRecommendation[] {
return recs
.map((rec, index) => {
const severity = typeof rec === "string" ? null : rec.severity;
const rank =
severity && severity in SEVERITY_RANK
? SEVERITY_RANK[severity]
: SEVERITY_RANK_FALLBACK;
return { rec, index, rank };
})
.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank;
return a.index - b.index;
})
.map(({ rec }) => rec);
}
const SEVERITY_BORDER_CLASSES: Record<string, string> = {
urgent: "border-l-destructive/70",
important: "border-l-warning/70",
suggestion: "border-l-primary/70",
info: "border-l-info/70",
};
const SEVERITY_BORDER_FALLBACK = "border-l-border";
/**
* Stagger interval, in ms. Brief asks for 100ms — chosen so a 4-card
* grid finishes the cascade in ~400ms (matches the existing
* `animate-insight-in` 400ms keyframe, so by the time the last card
* starts animating, the first has finished). Globally muted by
* `prefers-reduced-motion: reduce` via the existing media query in
* `globals.css`.
*/
const STAGGER_INTERVAL_MS = 100;
export function RecommendationsGrid({ recs }: RecommendationsGridProps) {
if (recs.length === 0) return null;
const ordered = sortRecommendationsBySeverity(recs);
return (
<div
data-slot="rec-grid"
role="list"
className="grid grid-cols-1 gap-3 lg:grid-cols-2"
>
{ordered.map((rec, index) => {
const severity = typeof rec === "string" ? null : rec.severity;
const borderClass =
(severity && SEVERITY_BORDER_CLASSES[severity]) ||
SEVERITY_BORDER_FALLBACK;
return (
<div
key={
typeof rec === "string" ? `${index}-${rec}` : (rec.id ?? index)
}
data-stagger-index={index}
role="listitem"
className={`animate-insight-in transition-all md:hover:-translate-y-0.5 md:hover:shadow-lg ${borderClass} rounded-lg border-l-2 motion-reduce:transition-none motion-reduce:hover:translate-y-0`}
style={{ animationDelay: `${index * STAGGER_INTERVAL_MS}ms` }}
>
<RecommendationCard rec={rec} index={index} />
</div>
);
})}
</div>
);
}
@@ -1,107 +0,0 @@
"use client";
import { Quote } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { cn } from "@/lib/utils";
/**
* v1.4.20 phase B1 — "Try asking" prompt-chip strip.
*
* Renders a horizontal row of clickable prompt chips below the hero
* action band. Each chip is a single i18n string; clicking a chip
* invokes `onPick` with the localised string so the parent can route
* to the future Coach drawer (B2) or pre-fill an input.
*
* Mobile-first: chips wrap to multiple rows on narrow viewports.
*
* The default 5-chip ordering mirrors the design handoff
* (`prototype/artboard-fullpage.jsx → BriefingHero` quick-prompts
* row). Each label lives in `messages/{en,de}.json` under
* `insights.suggestedPrompts.<key>` so the parent stays Locale-
* agnostic.
*/
export interface SuggestedPromptsProps {
/**
* Override the default prompt list. When omitted, the component
* resolves the two defaults from `insights.suggestedPrompts.*`.
* Useful for tests + future per-user prompt sets.
*/
prompts?: string[];
/** Click handler — receives the localised prompt string. */
onPick: (prompt: string) => void;
/** Optional className passthrough for layout overrides. */
className?: string;
}
// v1.12.4 — trimmed from five speculative prompts to the two that read as a
// clear next step: ask what to tell the doctor, and whether the medication is
// working. The data-specific openers ("why was Monday higher", weight × pulse,
// week vs month) were guesses that did not always match the user's own log.
const DEFAULT_PROMPT_KEYS = ["tellMyDoctor", "medicationWorking"] as const;
export function SuggestedPrompts({
prompts,
onPick,
className,
}: SuggestedPromptsProps) {
const { t } = useTranslations();
// v1.4.37 W5 — every chip is a Coach affordance: clicking one seeds
// the Coach drawer's composer and opens it. When the operator turns
// the global Coach flag off the strip must vanish along with the
// hero action button and the drawer mount. The HeroStrip caller
// already guards on `flags.coach`; the in-component gate is
// defence-in-depth so a future caller that mounts <SuggestedPrompts>
// outside the hero band can never leak a Coach surface.
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out hides every Coach affordance,
// including the suggested-prompts chip strip. Same posture as the
// FAB / drawer / inline pill gate above.
if (disableCoach) return null;
const items =
prompts ??
DEFAULT_PROMPT_KEYS.map((key) => t(`insights.suggestedPrompts.${key}`));
return (
<div
data-slot="insights-suggested-prompts"
// v1.8.5 W4a — tighten the chip row gutter (`gap-1.5` vs `gap-2`) so
// the condensed Coach band reads as a dense, deliberate strip rather
// than an airy wrap. The chips themselves shrink their padding below.
className={cn("flex flex-wrap items-center gap-1.5", className)}
>
<span
data-slot="insights-suggested-prompts-label"
className="text-muted-foreground mr-1 text-xs font-medium tracking-wide uppercase"
>
{t("insights.suggestedPrompts.label")}
</span>
{items.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onPick(prompt)}
data-slot="insights-suggested-prompts-chip"
// v1.8.5 W4a — denser chips. The horizontal padding + type size
// drop a notch so the two prompts pack into fewer rows and the
// left column stops towering over the Health-Score card. The tap
// target clears the 44 px iOS/WCAG floor on phones (`min-h-11`)
// and relaxes to the denser 36 px from `sm` up.
className={cn(
"border-primary/18 hover:border-primary/40 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"rounded-full border bg-transparent px-3 py-1.5 text-xs",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-primary/50",
)}
>
<Quote className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>{prompt}</span>
</button>
))}
</div>
);
}
-6
View File
@@ -33,10 +33,6 @@ export const insightRecommendationRationaleSchema = z.object({
deviation: z.string(),
});
export type InsightRecommendationRationale = z.infer<
typeof insightRecommendationRationaleSchema
>;
/**
* v1.4.16 — recommendations can be either the legacy plain-string
* shape OR a structured object carrying an optional medical-reference
@@ -77,8 +73,6 @@ export const insightRecommendationSchema = z.union([
}),
]);
export type InsightRecommendation = z.infer<typeof insightRecommendationSchema>;
export const insightResultSchema = z.object({
insightType: z.string().optional(),
summary: z.string(),
@@ -13,7 +13,6 @@ import {
type AssistantFlagSet,
} from "@/hooks/use-feature-flags";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
@@ -138,15 +137,11 @@ interface CoachSurface {
}
// v1.18.7 — the HeroStrip "Ask the coach" action button + the in-band
// SuggestedPrompts chip strip were removed from the overview hero. The
// Coach is the bottom-right drawer; its launcher + the standalone
// SuggestedPrompts surface still carry the flag cascade below.
// SuggestedPrompts chip strip were removed from the overview hero, and the
// strip's component has since been deleted along with the rest of the
// unmounted insights cluster. The Coach is the bottom-right drawer; the
// launcher surfaces below carry the flag cascade.
const COACH_SURFACES: CoachSurface[] = [
{
name: "SuggestedPrompts standalone",
mount: () => <SuggestedPrompts onPick={() => undefined} />,
proofWhenOn: 'data-slot="insights-suggested-prompts"',
},
{
name: "CoachLaunchButton inline pill",
mount: () => (
@@ -278,7 +273,6 @@ describe("Coach disable cascade invariant", () => {
// action button + suggested-prompt strip were removed from the band.
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
// v1.12.0 — the full-page Coach route gates on `flags.coach` and
// redirects to `/insights` when the operator master flag is off.
// v1.18.0 — moved to the standalone top-level `/coach` route.
@@ -405,6 +399,6 @@ describe("Coach disable cascade invariant", () => {
// test above pins every other `flags.coach` call site.
// v1.18.7 — dropped from 6 to 4: the two HeroStrip coach surfaces
// (action button + suggested-prompt strip) were removed from the band.
expect(COACH_SURFACES.length).toBe(4);
expect(COACH_SURFACES.length).toBe(3);
});
});
@@ -17,7 +17,6 @@ import type { AuthUser } from "@/hooks/use-auth";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
/**
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
@@ -179,14 +178,11 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
),
proofWhenVisible: 'data-slot="coach-launch-inline"',
},
{
name: "SuggestedPrompts chip strip",
mount: () => <SuggestedPrompts onPick={() => undefined} />,
proofWhenVisible: 'data-slot="insights-suggested-prompts"',
},
// v1.18.7 — the HeroStrip "Ask the coach" action button was removed from
// the overview hero. The Coach is the bottom-right drawer; its launcher
// surfaces below carry the per-user disable gate.
// v1.18.7 — the HeroStrip "Ask the coach" action button and the
// SuggestedPrompts chip strip were removed from the overview hero, and the
// strip's component has now been deleted with the rest of the unmounted
// insights cluster. The Coach is the bottom-right drawer; the launcher
// surfaces above carry the per-user disable gate.
];
describe("Coach per-user disableCoach invariant", () => {
@@ -294,7 +290,6 @@ describe("Coach per-user disableCoach invariant", () => {
// the `useDisableCoach` call entirely.
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
// v1.12.0 — the full-page Coach route mirrors the launch-button /
// FAB gate: operator master flag OR per-user opt-out redirects the
// page back to `/insights` instead of painting a dead chat shell.