From 52f8c5a57a6f8d5a11bd7a0c0c1d7ea67a892b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 15 Aug 2026 03:15:48 +0200 Subject: [PATCH 1/2] Remove six insights surfaces and a toggle nobody mounts The production dead-code check has been documented rather than enforced since PR #691, because its list mixed real debt with false positives. Triaged against the shipped tree, six of them are genuinely unreachable and go now. Five died when their host was retired and the leaves were left behind: the recommendation card with its confidence meter and feedback control, the grid that mounted them, and the suggested-prompts chip strip that left the hero band in v1.18.7. The sixth, the comparison toggle, has had two regression tests pinning its absence since v1.4.22, because the preference it once set is a Settings concern now. Deleting them freed sixteen translation keys in every locale, a pinned entry in the success-affordance register, and two entries in the Coach gate fixtures. Each of those was found by a guard rather than by reading, which is the point of having them. The adapter under signals/adapters stays: knip reports it as unreachable because only a test imports it, but that test is the registry invariant it exists to prove, and deleting it would remove a check rather than dead weight. --- messages/de.json | 23 - messages/en.json | 23 - messages/es.json | 23 - messages/fr.json | 23 - messages/it.json | 23 - messages/pl.json | 23 - .../success-affordance-guard.test.ts | 1 - .../__tests__/compare-toggle.test.tsx | 92 ---- src/components/comparison/compare-toggle.tsx | 111 ----- .../__tests__/confidence-meter.test.tsx | 144 ------ .../recommendation-card-confidence.test.tsx | 168 ------- .../__tests__/recommendation-card.test.tsx | 309 ------------ .../recommendation-feedback.test.tsx | 94 ---- .../__tests__/recommendations-grid.test.tsx | 177 ------- .../__tests__/suggested-prompts.test.tsx | 96 ---- src/components/insights/confidence-meter.tsx | 202 -------- .../insights/recommendation-card.tsx | 454 ------------------ .../insights/recommendation-feedback.tsx | 291 ----------- .../insights/recommendations-grid.tsx | 113 ----- src/components/insights/suggested-prompts.tsx | 107 ----- .../__tests__/coach-cascade.test.tsx | 16 +- .../__tests__/coach-user-disable.test.tsx | 15 +- 22 files changed, 10 insertions(+), 2518 deletions(-) delete mode 100644 src/components/comparison/__tests__/compare-toggle.test.tsx delete mode 100644 src/components/comparison/compare-toggle.tsx delete mode 100644 src/components/insights/__tests__/confidence-meter.test.tsx delete mode 100644 src/components/insights/__tests__/recommendation-card-confidence.test.tsx delete mode 100644 src/components/insights/__tests__/recommendation-card.test.tsx delete mode 100644 src/components/insights/__tests__/recommendation-feedback.test.tsx delete mode 100644 src/components/insights/__tests__/recommendations-grid.test.tsx delete mode 100644 src/components/insights/__tests__/suggested-prompts.test.tsx delete mode 100644 src/components/insights/confidence-meter.tsx delete mode 100644 src/components/insights/recommendation-card.tsx delete mode 100644 src/components/insights/recommendation-feedback.tsx delete mode 100644 src/components/insights/recommendations-grid.tsx delete mode 100644 src/components/insights/suggested-prompts.tsx diff --git a/messages/de.json b/messages/de.json index 69ac50dd5..d835c5c7f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index aec395564..10d6dd7a1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index fc40940dd..a42325704 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 458da22de..4374d6dd9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index a39e062c2..aeabdc9ab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 39a1c7476..3b5eff3c7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 79b1b0a9e..f41817193 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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 }, diff --git a/src/components/comparison/__tests__/compare-toggle.test.tsx b/src/components/comparison/__tests__/compare-toggle.test.tsx deleted file mode 100644 index a3f26bf58..000000000 --- a/src/components/comparison/__tests__/compare-toggle.test.tsx +++ /dev/null @@ -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( - {node}, - ); -} - -describe("", () => { - it("renders all three baseline segments", () => { - const html = render(); - 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(); - // 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(); - // 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(); - expect(html).toContain("None"); - expect(html).toContain("Last month"); - expect(html).toContain("Last year"); - }); - - it("translates the segment labels (DE)", () => { - const html = render(, "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(); - expect(html).toContain('role="group"'); - expect(html).toMatch(/aria-label="(Compare to|Vergleichen mit)"/); - }); -}); diff --git a/src/components/comparison/compare-toggle.tsx b/src/components/comparison/compare-toggle.tsx deleted file mode 100644 index 474f4105e..000000000 --- a/src/components/comparison/compare-toggle.tsx +++ /dev/null @@ -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("/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("/api/dashboard/widgets", body); - }, - onMutate: async (next) => { - await queryClient.cancelQueries({ - queryKey: queryKeys.dashboardWidgets(), - }); - const prev = queryClient.getQueryData( - queryKeys.dashboardWidgets(), - ); - if (prev) { - queryClient.setQueryData( - 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 ( -
- {COMPARISON_BASELINES.map((option) => { - const active = value === option; - return ( - - ); - })} -
- ); -} diff --git a/src/components/insights/__tests__/confidence-meter.test.tsx b/src/components/insights/__tests__/confidence-meter.test.tsx deleted file mode 100644 index 0ed33d0cd..000000000 --- a/src/components/insights/__tests__/confidence-meter.test.tsx +++ /dev/null @@ -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 — ``. - * - * 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( - {node}, - ); -} - -describe(" bars variant (default)", () => { - it("renders 5 lit bars at value=100", () => { - const html = render(); - 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(); - 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(); - 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(); - 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(); - 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(); - expect(html).toContain('data-confidence-band="high"'); - expect(html).toMatch(/bg-success/); - }); - - it("uses the caution tint at value 50..79", () => { - const html = render(); - 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(); - 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(); - // 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(); - expect(html).toContain('data-confidence-band="draft"'); - }); -}); - -describe(" ring variant", () => { - it("renders an SVG when variant=ring", () => { - const html = render(); - expect(html).toContain(" { - const html = render(); - expect(html).toContain('data-confidence-band="draft"'); - // Same draft pill rule as bars. - expect(html).not.toContain(" aria-label", () => { - it("EN: 'Confidence: 67 of 100'", () => { - const html = render(); - expect(html).toMatch(/aria-label="Confidence: 67 of 100"/); - }); - - it("DE: 'Vertrauen: 67 von 100'", () => { - const html = render(, "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(); - expect(html).toMatch(/aria-label="Confidence: 15 of 100"/); - }); -}); - -describe(" input clamping", () => { - it("clamps negative value to 0 (renders draft pill)", () => { - const html = render(); - expect(html).toContain('data-confidence-band="draft"'); - }); - - it("clamps value>100 to 100 (renders 5 bars, high band)", () => { - const html = render(); - const lit = (html.match(/data-bar-state="lit"/g) ?? []).length; - expect(lit).toBe(5); - expect(html).toContain('data-confidence-band="high"'); - }); -}); diff --git a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx deleted file mode 100644 index 12f3edbaa..000000000 --- a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx +++ /dev/null @@ -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: () =>
, -})); - -vi.mock("@/components/charts/mood-chart", () => ({ - MoodChart: () =>
, -})); - -// 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 `` 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 - * ``. - * - * The card reserves a `data-slot="rec-confidence-slot"` placeholder - * (B5c). B5d fills that slot with ``. 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 - * 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( - {node}, - ); -} - -describe(" — confidence slot wiring", () => { - it("renders ConfidenceMeter inside rec-confidence-slot when confidence is set", () => { - const html = render( - , - ); - // 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(); - // 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( - , - ); - expect(html).toContain('data-confidence-band="medium"'); - }); - - it("orange low band + low-confidence caption when value 25-49 and expanded", () => { - const html = render( - , - ); - 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( - , - ); - expect(html).not.toMatch(/Low confidence/); - }); - - it("draft pill below 25 (rendered by ConfidenceMeter)", () => { - const html = render( - , - ); - expect(html).toContain('data-confidence-band="draft"'); - expect(html).toMatch(/Draft/); - }); - - it("German locale renders translated low-confidence caption", () => { - const html = render( - , - "de", - ); - expect(html).toMatch(/Geringes Vertrauen/); - }); - - it("rec-feedback-slot is still rendered (B5e plug-in point intact)", () => { - const html = render( - , - ); - // 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"/); - }); -}); diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx deleted file mode 100644 index 6e7ab768e..000000000 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ /dev/null @@ -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; - }) => ( -
- chart:{title} -
- ), -})); - -vi.mock("@/components/charts/mood-chart", () => ({ - MoodChart: ({ mini }: { mini?: boolean }) => ( -
- ), -})); - -// 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>; - return (props: Record) => ; - }, - }; -}); - -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 `` 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( - {node}, - ); -} - -/** - * 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("", () => { - it("renders the rec text and severity badge in collapsed state", () => { - const html = render(); - 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( - , - ); - // 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( - , - ); - 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(); - 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( - , - ); - 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( - , - ); - 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( - , - ); - expect(html).toContain("Continue daily logging."); - expect(html).not.toMatch(/aria-expanded=/); - }); - - it("German locale labels — rationale rows translate", () => { - const html = render( - , - "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(); - // 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( - , - ); - 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( - , - ); - // 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( - , - ); - 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( - , - ); - 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( - , - ); - 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( - , - ); - 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(); - 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( - , - ); - expect(html).not.toContain("metric:blood_pressure_sys"); - expect(html).toContain("Watch the trend."); - expect(html).toContain("today"); - }); -}); diff --git a/src/components/insights/__tests__/recommendation-feedback.test.tsx b/src/components/insights/__tests__/recommendation-feedback.test.tsx deleted file mode 100644 index 33d97f1b7..000000000 --- a/src/components/insights/__tests__/recommendation-feedback.test.tsx +++ /dev/null @@ -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( - {node}, - ); -} - -describe("", () => { - it("renders thumbs-up + thumbs-down buttons", () => { - const html = render(); - expect(html).toContain('data-feedback-thumb="up"'); - expect(html).toContain('data-feedback-thumb="down"'); - }); - - it("attaches accessible aria-label keys", () => { - const html = render(); - 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(, "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( - , - ); - // 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( - , - ); - 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(); - // Both up + down buttons must declare type=button. - const upMatch = html.match(/]*data-feedback-thumb="up"[^>]*>/); - const downMatch = html.match( - /]*data-feedback-thumb="down"[^>]*>/, - ); - expect(upMatch?.[0]).toContain('type="button"'); - expect(downMatch?.[0]).toContain('type="button"'); - }); -}); diff --git a/src/components/insights/__tests__/recommendations-grid.test.tsx b/src/components/insights/__tests__/recommendations-grid.test.tsx deleted file mode 100644 index 28c90c81c..000000000 --- a/src/components/insights/__tests__/recommendations-grid.test.tsx +++ /dev/null @@ -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: () =>
, -})); -vi.mock("@/components/charts/mood-chart", () => ({ - MoodChart: () =>
, -})); -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 `` 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( - {node}, - ); -} - -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("", () => { - it("renders cards in a 2-col responsive grid", () => { - const recs = [rec("a", "info", "alpha"), rec("b", "urgent", "bravo")]; - const html = render(); - 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(); - // 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(); - // 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(); - 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(); - // 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"/); - }); -}); diff --git a/src/components/insights/__tests__/suggested-prompts.test.tsx b/src/components/insights/__tests__/suggested-prompts.test.tsx deleted file mode 100644 index 21cb02ab3..000000000 --- a/src/components/insights/__tests__/suggested-prompts.test.tsx +++ /dev/null @@ -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( - {node}, - ); -} - -describe("", () => { - it("renders the two default prompt chips in English", () => { - const html = render( {}} />); - 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( {}} />, "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( {}} />); - 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( {}} />, "de"); - expect(html).toContain("Frag mich"); - }); - - it("renders one chip per prompt with the suggested-prompts-chip slot", () => { - const html = render( {}} />); - 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( - {}} - />, - ); - 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( {}} />); - 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(); - 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( - {}} className="my-custom-spacing" />, - ); - expect(html).toContain("my-custom-spacing"); - }); -}); diff --git a/src/components/insights/confidence-meter.tsx b/src/components/insights/confidence-meter.tsx deleted file mode 100644 index 198222662..000000000 --- a/src/components/insights/confidence-meter.tsx +++ /dev/null @@ -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, - string -> = { - high: "bg-success", - medium: "bg-warning", - low: "bg-warning", -}; - -const RING_STROKE_COLOUR_BY_BAND: Record< - Exclude, - string -> = { - high: "stroke-success", - medium: "stroke-warning", - low: "stroke-warning", -}; - -function DraftPill({ ariaLabel }: { ariaLabel: string }) { - const { t } = useTranslations(); - return ( - - {t("insights.recommendation.confidenceDraft")} - - ); -} - -function BarsMeter({ - value, - band, - ariaLabel, -}: { - value: number; - band: Exclude; - ariaLabel: string; -}) { - const lit = litBarsFor(value); - const litColour = BAR_LIT_COLOUR_BY_BAND[band]; - return ( - - {[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 ( - - ); - })} - - ); -} - -function RingMeter({ - value, - band, - ariaLabel, -}: { - value: number; - band: Exclude; - 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 ( - - - - - ); -} - -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 ( - - - - ); - } - - return ( - - {variant === "ring" ? ( - - ) : ( - - )} - - ); -} diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx deleted file mode 100644 index 0868c84ef..000000000 --- a/src/components/insights/recommendation-card.tsx +++ /dev/null @@ -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: () => }, -); -const HealthChart = (props: ComponentProps) => ( - - - -); - -const MoodChartLazy = dynamic( - () => - importWithRetry(() => import("@/components/charts/chart-runtime")).then( - (mod) => ({ default: mod.MoodChart }), - ), - { ssr: false, loading: () => }, -); -const MoodChart = (props: ComponentProps) => ( - - - -); - -/** - * 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 = { - 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 ``) 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 ( - - - {t("insights.recommendation.source")}: - - - {ref.org} {ref.publishedYear} — {label} - - - ); -} - -function RationaleRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -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 ( -
-
- - - -
- - {/* 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) ? ( - - ) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? ( - - ) : null} - - {showLowConfidenceCaption && ( -

- {t("insights.recommendation.confidenceLow")} -

- )} - - {referenceId && ( -
- -
- )} - - {/* 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). */} -
- {feedbackProps && } -
-
- ); -} - -/** - * 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 ( -
-
-
- -
-
- {norm.severity && ( - - {norm.severity} - - )} -

- {stripChartTokens(norm.text)} -

-
-
-
-
- - {typeof norm.confidence === "number" && ( - - )} - - {expandable && ( - - )} -
-
- - {expandable && expanded && norm.rationale && ( -
- -
- )} - - {/* 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 && ( -
- -
- )} - - {/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link, - aligned under the rec text (the number gutter is `ml-6`). */} -
- -
-
- ); -} diff --git a/src/components/insights/recommendation-feedback.tsx b/src/components/insights/recommendation-feedback.tsx deleted file mode 100644 index df8a73fcb..000000000 --- a/src/components/insights/recommendation-feedback.tsx +++ /dev/null @@ -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 ``. - * - * 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 ` - -
- ); -} diff --git a/src/components/insights/recommendations-grid.tsx b/src/components/insights/recommendations-grid.tsx deleted file mode 100644 index ac107799c..000000000 --- a/src/components/insights/recommendations-grid.tsx +++ /dev/null @@ -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 ``: - * - 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 `
` - * wrapper so the underlying `` (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 = { - 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 `` 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 = { - 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 ( -
- {ordered.map((rec, index) => { - const severity = typeof rec === "string" ? null : rec.severity; - const borderClass = - (severity && SEVERITY_BORDER_CLASSES[severity]) || - SEVERITY_BORDER_FALLBACK; - return ( -
- -
- ); - })} -
- ); -} diff --git a/src/components/insights/suggested-prompts.tsx b/src/components/insights/suggested-prompts.tsx deleted file mode 100644 index a421e5cfe..000000000 --- a/src/components/insights/suggested-prompts.tsx +++ /dev/null @@ -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.` 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 - // 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 ( -
- - {t("insights.suggestedPrompts.label")} - - {items.map((prompt) => ( - - ))} -
- ); -} diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index 63bc1c149..12bba422f 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -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: () => 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); }); }); diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 0feaa790b..db6ddb379 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -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: () => 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. From eb087f9fc2d339ed608b45fc23eb1e0b3a6dfe90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 15 Aug 2026 03:35:41 +0200 Subject: [PATCH 2/2] Drop the two recommendation type aliases the deleted card exported for The schemas stay: the insight result still parses recommendations through them. Only the exported aliases went unused once the card that consumed them was removed, and the dead-code gate says so. --- src/lib/ai/types.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 67681677f..898edb62c 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -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; - export const insightResultSchema = z.object({ insightType: z.string().optional(), summary: z.string(),