mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge pull request #799 from MBombeck/fix/dead-code-sweep
Remove the unreachable insights cluster and the retired comparison toggle
This commit is contained in:
23 files changed
+10
-2524
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>
|
||||
);
|
||||
}
|
||||
@@ -33,10 +33,6 @@ export const insightRecommendationRationaleSchema = z.object({
|
||||
deviation: z.string(),
|
||||
});
|
||||
|
||||
export type InsightRecommendationRationale = z.infer<
|
||||
typeof insightRecommendationRationaleSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* v1.4.16 — recommendations can be either the legacy plain-string
|
||||
* shape OR a structured object carrying an optional medical-reference
|
||||
@@ -77,8 +73,6 @@ export const insightRecommendationSchema = z.union([
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InsightRecommendation = z.infer<typeof insightRecommendationSchema>;
|
||||
|
||||
export const insightResultSchema = z.object({
|
||||
insightType: z.string().optional(),
|
||||
summary: z.string(),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type AssistantFlagSet,
|
||||
} from "@/hooks/use-feature-flags";
|
||||
|
||||
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
@@ -138,15 +137,11 @@ interface CoachSurface {
|
||||
}
|
||||
|
||||
// v1.18.7 — the HeroStrip "Ask the coach" action button + the in-band
|
||||
// SuggestedPrompts chip strip were removed from the overview hero. The
|
||||
// Coach is the bottom-right drawer; its launcher + the standalone
|
||||
// SuggestedPrompts surface still carry the flag cascade below.
|
||||
// SuggestedPrompts chip strip were removed from the overview hero, and the
|
||||
// strip's component has since been deleted along with the rest of the
|
||||
// unmounted insights cluster. The Coach is the bottom-right drawer; the
|
||||
// launcher surfaces below carry the flag cascade.
|
||||
const COACH_SURFACES: CoachSurface[] = [
|
||||
{
|
||||
name: "SuggestedPrompts standalone",
|
||||
mount: () => <SuggestedPrompts onPick={() => undefined} />,
|
||||
proofWhenOn: 'data-slot="insights-suggested-prompts"',
|
||||
},
|
||||
{
|
||||
name: "CoachLaunchButton inline pill",
|
||||
mount: () => (
|
||||
@@ -278,7 +273,6 @@ describe("Coach disable cascade invariant", () => {
|
||||
// action button + suggested-prompt strip were removed from the band.
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
"src/components/insights/layout-coach-mount.tsx",
|
||||
"src/components/insights/suggested-prompts.tsx",
|
||||
// v1.12.0 — the full-page Coach route gates on `flags.coach` and
|
||||
// redirects to `/insights` when the operator master flag is off.
|
||||
// v1.18.0 — moved to the standalone top-level `/coach` route.
|
||||
@@ -405,6 +399,6 @@ describe("Coach disable cascade invariant", () => {
|
||||
// test above pins every other `flags.coach` call site.
|
||||
// v1.18.7 — dropped from 6 to 4: the two HeroStrip coach surfaces
|
||||
// (action button + suggested-prompt strip) were removed from the band.
|
||||
expect(COACH_SURFACES.length).toBe(4);
|
||||
expect(COACH_SURFACES.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ import type { AuthUser } from "@/hooks/use-auth";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
|
||||
@@ -179,14 +178,11 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
|
||||
),
|
||||
proofWhenVisible: 'data-slot="coach-launch-inline"',
|
||||
},
|
||||
{
|
||||
name: "SuggestedPrompts chip strip",
|
||||
mount: () => <SuggestedPrompts onPick={() => undefined} />,
|
||||
proofWhenVisible: 'data-slot="insights-suggested-prompts"',
|
||||
},
|
||||
// v1.18.7 — the HeroStrip "Ask the coach" action button was removed from
|
||||
// the overview hero. The Coach is the bottom-right drawer; its launcher
|
||||
// surfaces below carry the per-user disable gate.
|
||||
// v1.18.7 — the HeroStrip "Ask the coach" action button and the
|
||||
// SuggestedPrompts chip strip were removed from the overview hero, and the
|
||||
// strip's component has now been deleted with the rest of the unmounted
|
||||
// insights cluster. The Coach is the bottom-right drawer; the launcher
|
||||
// surfaces above carry the per-user disable gate.
|
||||
];
|
||||
|
||||
describe("Coach per-user disableCoach invariant", () => {
|
||||
@@ -294,7 +290,6 @@ describe("Coach per-user disableCoach invariant", () => {
|
||||
// the `useDisableCoach` call entirely.
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
"src/components/insights/layout-coach-mount.tsx",
|
||||
"src/components/insights/suggested-prompts.tsx",
|
||||
// v1.12.0 — the full-page Coach route mirrors the launch-button /
|
||||
// FAB gate: operator master flag OR per-user opt-out redirects the
|
||||
// page back to `/insights` instead of painting a dead chat shell.
|
||||
|
||||
Reference in new issue
Block a user