mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
This commit is contained in:
1 parent
8ec682f007
commit
52f8c5a57a
22 files changed
+10
-2518
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user