From 71cc05acd6162102ad9481c1734d85660a24405f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 21:15:24 +0200 Subject: [PATCH] feat(medications): GLP-1 cadence visualisation + compliance chips on detail page --- messages/de.json | 42 +++ messages/en.json | 42 +++ messages/es.json | 42 +++ messages/fr.json | 42 +++ messages/it.json | 42 +++ messages/pl.json | 42 +++ src/app/medications/[id]/history/page.tsx | 14 + .../medications/SchedulingSection.tsx | 309 ++++++++++++++++++ .../__tests__/SchedulingSection.test.tsx | 195 +++++++++++ 9 files changed, 770 insertions(+) create mode 100644 src/components/medications/SchedulingSection.tsx create mode 100644 src/components/medications/__tests__/SchedulingSection.test.tsx diff --git a/messages/de.json b/messages/de.json index 68ad9b5a6..7488a4794 100644 --- a/messages/de.json +++ b/messages/de.json @@ -667,6 +667,48 @@ "gallbladderDiscomfort": "Gallenblasen-Beschwerden" } }, + "scheduling": { + "section": "Zeitplan", + "loading": "Lade Zeitplan-Daten", + "loadFailed": "Zeitplan-Daten konnten nicht geladen werden.", + "editCta": "Bearbeiten", + "reminders": { + "on": "Erinnerungen an", + "off": "Erinnerungen aus" + }, + "cadenceViz": { + "title": "Letzte 30 Tage", + "ariaTimeline": "Dosis-Zeitstrahl der letzten 30 Tage", + "emptyState": "Im Zeitfenster sind keine Dosen geplant.", + "nextDoseLabel": "Nächste Dosis:", + "noNextDose": "Keine kommende Dosis geplant.", + "status": { + "taken": "Genommen", + "skipped": "Ausgelassen", + "missed": "Verpasst", + "upcoming": "Geplant" + } + }, + "compliance": { + "title": "Adhärenz", + "noData": "Keine Daten", + "adherenceRate": "Adhärenz", + "currentStreak": "Aktuelle Serie", + "longestStreak": "Längste Serie", + "missedLast30": "Verpasst (30 T.)", + "unit": { + "days": "T.", + "doses": "Dosen", + "percent": "%" + }, + "chips": { + "adherenceTooltip": "Anteil genommener Dosen im 30-Tage-Fenster (ausgelassene Dosen werden ausgenommen).", + "streakTooltip": "Aufeinanderfolgende Tage bis heute, an denen alle geplanten Dosen genommen oder bewusst ausgelassen wurden.", + "longestTooltip": "Längste durchgehende Serie im 30-Tage-Fenster.", + "missedTooltip": "Anzahl verpasster Dosen in den letzten 30 Tagen." + } + } + }, "injectionSiteBodyOutlineAriaLabel": "Körperumriss", "injectionSitePickerAriaLabel": "Injektionsstelle auswählen", "siteAbdomenLeft": "Bauch, unten links", diff --git a/messages/en.json b/messages/en.json index 45334c1f7..34cc5a8ba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -667,6 +667,48 @@ "gallbladderDiscomfort": "Gallbladder discomfort" } }, + "scheduling": { + "section": "Schedule", + "loading": "Loading schedule data", + "loadFailed": "Schedule data failed to load.", + "editCta": "Edit", + "reminders": { + "on": "Reminders on", + "off": "Reminders off" + }, + "cadenceViz": { + "title": "Last 30 days", + "ariaTimeline": "Dose timeline for the last 30 days", + "emptyState": "No doses scheduled in this window.", + "nextDoseLabel": "Next dose:", + "noNextDose": "No upcoming dose scheduled.", + "status": { + "taken": "Taken", + "skipped": "Skipped", + "missed": "Missed", + "upcoming": "Upcoming" + } + }, + "compliance": { + "title": "Adherence", + "noData": "No data", + "adherenceRate": "Adherence", + "currentStreak": "Current streak", + "longestStreak": "Longest streak", + "missedLast30": "Missed (30 d.)", + "unit": { + "days": "d.", + "doses": "doses", + "percent": "%" + }, + "chips": { + "adherenceTooltip": "Share of doses taken across the last 30 days (skipped doses are excluded from the denominator).", + "streakTooltip": "Consecutive days up to today on which every scheduled dose was taken or deliberately skipped.", + "longestTooltip": "Longest unbroken streak within the 30-day window.", + "missedTooltip": "Count of doses missed in the last 30 days." + } + } + }, "injectionSiteBodyOutlineAriaLabel": "Body outline", "injectionSitePickerAriaLabel": "Injection site picker", "siteAbdomenLeft": "Abdomen, lower left", diff --git a/messages/es.json b/messages/es.json index 96bcd3124..8055b098d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -667,6 +667,48 @@ "gallbladderDiscomfort": "Molestia en la vesícula biliar" } }, + "scheduling": { + "section": "Programa", + "loading": "Cargando datos del programa", + "loadFailed": "No se han podido cargar los datos del programa.", + "editCta": "Editar", + "reminders": { + "on": "Recordatorios activos", + "off": "Recordatorios desactivados" + }, + "cadenceViz": { + "title": "Últimos 30 días", + "ariaTimeline": "Línea temporal de dosis de los últimos 30 días", + "emptyState": "No hay dosis programadas en este intervalo.", + "nextDoseLabel": "Próxima dosis:", + "noNextDose": "No hay ninguna dosis próxima programada.", + "status": { + "taken": "Tomada", + "skipped": "Omitida", + "missed": "Olvidada", + "upcoming": "Pendiente" + } + }, + "compliance": { + "title": "Adherencia", + "noData": "Sin datos", + "adherenceRate": "Adherencia", + "currentStreak": "Racha actual", + "longestStreak": "Racha más larga", + "missedLast30": "Olvidadas (30 d.)", + "unit": { + "days": "d.", + "doses": "dosis", + "percent": "%" + }, + "chips": { + "adherenceTooltip": "Proporción de dosis tomadas en los últimos 30 días (las dosis omitidas se excluyen del denominador).", + "streakTooltip": "Días consecutivos hasta hoy en los que se ha tomado o se ha omitido deliberadamente cada dosis programada.", + "longestTooltip": "Racha continua más larga dentro de la ventana de 30 días.", + "missedTooltip": "Número de dosis olvidadas en los últimos 30 días." + } + } + }, "injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo", "injectionSitePickerAriaLabel": "Selector de sitio de inyección", "siteAbdomenLeft": "Abdomen, lower left", diff --git a/messages/fr.json b/messages/fr.json index 5d7d7a763..4b412e3b4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -667,6 +667,48 @@ "gallbladderDiscomfort": "Inconfort de la vésicule biliaire" } }, + "scheduling": { + "section": "Calendrier", + "loading": "Chargement des données du calendrier", + "loadFailed": "Échec du chargement des données du calendrier.", + "editCta": "Modifier", + "reminders": { + "on": "Rappels activés", + "off": "Rappels désactivés" + }, + "cadenceViz": { + "title": "30 derniers jours", + "ariaTimeline": "Chronologie des doses des 30 derniers jours", + "emptyState": "Aucune dose prévue sur cette période.", + "nextDoseLabel": "Prochaine dose :", + "noNextDose": "Aucune dose à venir programmée.", + "status": { + "taken": "Prise", + "skipped": "Sautée", + "missed": "Manquée", + "upcoming": "À venir" + } + }, + "compliance": { + "title": "Observance", + "noData": "Aucune donnée", + "adherenceRate": "Observance", + "currentStreak": "Série en cours", + "longestStreak": "Série la plus longue", + "missedLast30": "Manquées (30 j.)", + "unit": { + "days": "j.", + "doses": "doses", + "percent": "%" + }, + "chips": { + "adherenceTooltip": "Part des doses prises sur les 30 derniers jours (les doses sautées sont exclues du dénominateur).", + "streakTooltip": "Jours consécutifs jusqu'à aujourd'hui où chaque dose prévue a été prise ou volontairement sautée.", + "longestTooltip": "Série continue la plus longue sur la fenêtre de 30 jours.", + "missedTooltip": "Nombre de doses manquées au cours des 30 derniers jours." + } + } + }, "injectionSiteBodyOutlineAriaLabel": "Silhouette du corps", "injectionSitePickerAriaLabel": "Sélecteur de site d'injection", "siteAbdomenLeft": "Abdomen, lower left", diff --git a/messages/it.json b/messages/it.json index 3748a774e..e5bd6979f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -667,6 +667,48 @@ "gallbladderDiscomfort": "Fastidio alla cistifellea" } }, + "scheduling": { + "section": "Programma", + "loading": "Caricamento dei dati del programma", + "loadFailed": "Impossibile caricare i dati del programma.", + "editCta": "Modifica", + "reminders": { + "on": "Promemoria attivi", + "off": "Promemoria disattivati" + }, + "cadenceViz": { + "title": "Ultimi 30 giorni", + "ariaTimeline": "Cronologia delle dosi degli ultimi 30 giorni", + "emptyState": "Nessuna dose programmata in questo intervallo.", + "nextDoseLabel": "Prossima dose:", + "noNextDose": "Nessuna dose programmata in arrivo.", + "status": { + "taken": "Assunta", + "skipped": "Saltata", + "missed": "Mancata", + "upcoming": "In arrivo" + } + }, + "compliance": { + "title": "Aderenza", + "noData": "Nessun dato", + "adherenceRate": "Aderenza", + "currentStreak": "Serie attuale", + "longestStreak": "Serie più lunga", + "missedLast30": "Mancate (30 g.)", + "unit": { + "days": "g.", + "doses": "dosi", + "percent": "%" + }, + "chips": { + "adherenceTooltip": "Quota di dosi assunte negli ultimi 30 giorni (le dosi saltate sono escluse dal denominatore).", + "streakTooltip": "Giorni consecutivi fino a oggi in cui ogni dose programmata è stata assunta o deliberatamente saltata.", + "longestTooltip": "Serie continua più lunga nella finestra di 30 giorni.", + "missedTooltip": "Numero di dosi mancate negli ultimi 30 giorni." + } + } + }, "injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo", "injectionSitePickerAriaLabel": "Selettore del sito di iniezione", "siteAbdomenLeft": "Abdomen, lower left", diff --git a/messages/pl.json b/messages/pl.json index 953c32c1f..70f64f034 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -667,6 +667,48 @@ "gallbladderDiscomfort": "Dolegliwości pęcherzyka żółciowego" } }, + "scheduling": { + "section": "Harmonogram", + "loading": "Wczytywanie danych harmonogramu", + "loadFailed": "Nie udało się wczytać danych harmonogramu.", + "editCta": "Edytuj", + "reminders": { + "on": "Przypomnienia włączone", + "off": "Przypomnienia wyłączone" + }, + "cadenceViz": { + "title": "Ostatnie 30 dni", + "ariaTimeline": "Oś czasu dawek z ostatnich 30 dni", + "emptyState": "Brak zaplanowanych dawek w tym oknie.", + "nextDoseLabel": "Następna dawka:", + "noNextDose": "Brak zaplanowanej nadchodzącej dawki.", + "status": { + "taken": "Przyjęta", + "skipped": "Pominięta", + "missed": "Niepodjęta", + "upcoming": "Nadchodząca" + } + }, + "compliance": { + "title": "Adherencja", + "noData": "Brak danych", + "adherenceRate": "Adherencja", + "currentStreak": "Bieżąca seria", + "longestStreak": "Najdłuższa seria", + "missedLast30": "Pominięte (30 d.)", + "unit": { + "days": "d.", + "doses": "dawek", + "percent": "%" + }, + "chips": { + "adherenceTooltip": "Udział dawek przyjętych w ostatnich 30 dniach (pominięte dawki są wyłączone z mianownika).", + "streakTooltip": "Kolejne dni do dziś, w których każda zaplanowana dawka została przyjęta lub świadomie pominięta.", + "longestTooltip": "Najdłuższa nieprzerwana seria w 30-dniowym oknie.", + "missedTooltip": "Liczba dawek pominiętych w ostatnich 30 dniach." + } + } + }, "injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała", "injectionSitePickerAriaLabel": "Wybór miejsca iniekcji", "siteAbdomenLeft": "Abdomen, lower left", diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index d38021cd9..dbb2e699a 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { IntakeHistoryList } from "@/components/medications/intake-history-list"; import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; +import { SchedulingSection } from "@/components/medications/SchedulingSection"; import { ArrowLeft, Loader2, Plus } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "@/lib/i18n/context"; @@ -40,6 +41,7 @@ export default function IntakeHistoryPage({ name: string; dose: string; treatmentClass?: string; + notificationsEnabled?: boolean; }; }, enabled: isAuthenticated, @@ -109,6 +111,18 @@ export default function IntakeHistoryPage({ )} + {/* v1.4.25 W19e — GLP-1 cadence visualisation + compliance chips. + Sits between the side-effect logbook and the intake history so + the user lands on cycle context (drug-level), then symptom + record, then schedule cadence + adherence, then the + dose-by-dose timeline. */} + {medication?.treatmentClass === "GLP1" && ( + + )} + ` (W19d) and `` + * on the medication detail page. Same chrome as the W19d section so + * the three Wave-4b panels read as one visual group. + * + * Three sub-sections, top-to-bottom: + * + * 1. Header strip — reminder on/off badge + next-due chip + "Edit + * schedule" link back to the medication list (where the existing + * `medication-form` is the canonical schedule editor; we + * deliberately do not re-implement the schedule editor here so + * there's no second source of truth). + * + * 2. Cadence visualisation — a 30-day track showing one cell per + * expected dose, status-coloured (taken / skipped / missed / + * upcoming). The legend below the track names each status. + * + * 3. Compliance chips — four monochrome chips: adherence rate %, + * current streak, longest streak, missed last 30. Marc-memory: + * no gamified badges, no WebP art. + * + * Data comes from /api/medications/[id]/cadence; the route delegates + * to the pure scheduling helpers so the chart, the chips, and the + * route agree dose-for-dose. + */ + +interface CadenceTimelineEntry { + day: string; + windowStart: string; + windowEnd: string; + scheduleIndex: number; + status: "taken" | "skipped" | "missed" | "upcoming"; +} + +interface CadenceChips { + adherenceRate: number | null; + currentStreak: number; + longestStreak: number; + missedLast30: number; + windowDays: number; +} + +interface CadenceResponse { + windowDays: number; + anchorIso: string; + next: { + windowStart: string; + windowEnd: string; + scheduleIndex: number; + } | null; + chips: CadenceChips; + timeline: CadenceTimelineEntry[]; +} + +interface SchedulingSectionProps { + medicationId: string; + reminderEnabled: boolean; +} + +const STATUS_STYLES: Record = { + // Monochrome scale; chart visually identical contract — no new colour + // tokens, all classes already exist in the design system. + taken: "bg-primary/85", + skipped: "bg-muted", + missed: "bg-destructive/65", + upcoming: "border-border/70 border bg-background", +}; + +export function SchedulingSection({ + medicationId, + reminderEnabled, +}: SchedulingSectionProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data, isLoading, error } = useQuery({ + queryKey: ["medications", medicationId, "cadence"], + queryFn: async (): Promise => { + const res = await fetch( + `/api/medications/${medicationId}/cadence?days=30`, + ); + if (!res.ok) { + throw new Error(`Failed to load cadence: ${res.status}`); + } + const json = await res.json(); + return json.data as CadenceResponse; + }, + }); + + const adherenceLabel = useMemo(() => { + if (!data) return null; + if (data.chips.adherenceRate === null) { + return t("medications.scheduling.compliance.noData"); + } + return `${data.chips.adherenceRate}%`; + }, [data, t]); + + return ( +
+
+

+ {t("medications.scheduling.section")} +

+
+ + {reminderEnabled ? ( + + ) : ( + + )} + {t( + reminderEnabled + ? "medications.scheduling.reminders.on" + : "medications.scheduling.reminders.off", + )} + + +
+
+ +
+ {isLoading && ( +
+ + {t("medications.scheduling.loading")} +
+ )} + + {!!error && !isLoading && ( +

+ {t("medications.scheduling.loadFailed")} +

+ )} + + {data && !isLoading && ( +
+ {/* Next-dose chip */} +
+

+ {data.next ? ( + <> + {t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "} + + {fmt.dateShort(new Date(data.next.windowStart))} + + + ) : ( + t("medications.scheduling.cadenceViz.noNextDose") + )} +

+
+ + {/* 30-day timeline */} +
+

+ {t("medications.scheduling.cadenceViz.title")} +

+ {data.timeline.length === 0 ? ( +

+ {t("medications.scheduling.cadenceViz.emptyState")} +

+ ) : ( + <> +
+ {data.timeline.map((slot, i) => ( + + ))} +
+
+ {(["taken", "skipped", "missed", "upcoming"] as const).map( + (s) => ( + + + + {t( + `medications.scheduling.cadenceViz.status.${s}`, + )} + + + ), + )} +
+ + )} +
+ + {/* Compliance chips */} +
+

+ {t("medications.scheduling.compliance.title")} +

+
+ + + + +
+
+
+ )} +
+
+ ); +} + +interface ComplianceChipProps { + label: string; + value: string; + tooltip: string; +} + +function ComplianceChip({ label, value, tooltip }: ComplianceChipProps) { + return ( +
+

+ {label} +

+

{value}

+
+ ); +} diff --git a/src/components/medications/__tests__/SchedulingSection.test.tsx b/src/components/medications/__tests__/SchedulingSection.test.tsx new file mode 100644 index 000000000..c627dba39 --- /dev/null +++ b/src/components/medications/__tests__/SchedulingSection.test.tsx @@ -0,0 +1,195 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { SchedulingSection } from "@/components/medications/SchedulingSection"; + +/** + * v1.4.25 W19e — SchedulingSection SSR smoke tests. + * + * Same testing convention as `SideEffectsSection.test.tsx`: + * `renderToStaticMarkup` + seeded react-query cache. The + * fetch-driven interactive branches (loading spinner, error state) + * are covered by the API-route tests + the pure cadence + compliance + * helper tests; the surface tests here pin the static-render contract: + * + * 1. Section heading + reminder badge render in EN and DE. + * 2. Compliance chips display the four values (adherence, current, + * longest, missed) when data is seeded. + * 3. Empty timeline yields the empty-state copy. + * 4. Reminder badge switches text between on / off. + */ + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + }, + }, + }); +} + +interface SeedShape { + windowDays: number; + next: { windowStart: string; windowEnd: string; scheduleIndex: number } | null; + chips: { + adherenceRate: number | null; + currentStreak: number; + longestStreak: number; + missedLast30: number; + windowDays: number; + }; + timeline: Array<{ + day: string; + windowStart: string; + windowEnd: string; + scheduleIndex: number; + status: "taken" | "skipped" | "missed" | "upcoming"; + }>; +} + +function seed(client: QueryClient, medId: string, payload: SeedShape) { + client.setQueryData(["medications", medId, "cadence"], payload); +} + +function render( + node: React.ReactNode, + client: QueryClient, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + {node} + , + ); +} + +const SEEDED: SeedShape = { + windowDays: 30, + next: { + windowStart: "2026-05-15T08:00:00.000Z", + windowEnd: "2026-05-15T09:00:00.000Z", + scheduleIndex: 0, + }, + chips: { + adherenceRate: 92, + currentStreak: 12, + longestStreak: 21, + missedLast30: 2, + windowDays: 30, + }, + timeline: [ + { + day: "2026-05-13T00:00:00.000Z", + windowStart: "2026-05-13T08:00:00.000Z", + windowEnd: "2026-05-13T09:00:00.000Z", + scheduleIndex: 0, + status: "taken", + }, + { + day: "2026-05-14T00:00:00.000Z", + windowStart: "2026-05-14T08:00:00.000Z", + windowEnd: "2026-05-14T09:00:00.000Z", + scheduleIndex: 0, + status: "missed", + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe(" — surface render", () => { + it("renders the section heading in English", () => { + const client = makeClient(); + seed(client, "med-1", SEEDED); + const html = render( + , + client, + ); + expect(html).toContain("Schedule"); + expect(html).toContain("Reminders on"); + }); + + it("renders the section heading in German", () => { + const client = makeClient(); + seed(client, "med-1", SEEDED); + const html = render( + , + client, + "de", + ); + expect(html).toContain("Zeitplan"); + expect(html).toContain("Erinnerungen an"); + }); + + it("switches the reminder badge when reminderEnabled is false", () => { + const client = makeClient(); + seed(client, "med-1", SEEDED); + const html = render( + , + client, + ); + expect(html).toContain("Reminders off"); + }); +}); + +describe(" — chips", () => { + it("renders the four compliance chip values", () => { + const client = makeClient(); + seed(client, "med-1", SEEDED); + const html = render( + , + client, + ); + expect(html).toContain("92%"); + expect(html).toContain("12"); + expect(html).toContain("21"); + expect(html).toContain("Current streak"); + expect(html).toContain("Longest streak"); + expect(html).toContain("Adherence"); + }); + + it("renders `No data` when adherence is null", () => { + const client = makeClient(); + seed(client, "med-1", { + ...SEEDED, + chips: { ...SEEDED.chips, adherenceRate: null }, + }); + const html = render( + , + client, + ); + expect(html).toContain("No data"); + }); +}); + +describe(" — timeline", () => { + it("renders the timeline empty-state when no slots are seeded", () => { + const client = makeClient(); + seed(client, "med-1", { ...SEEDED, timeline: [] }); + const html = render( + , + client, + ); + expect(html).toContain("No doses scheduled in this window"); + }); + + it("renders the legend with all four statuses", () => { + const client = makeClient(); + seed(client, "med-1", SEEDED); + const html = render( + , + client, + ); + expect(html).toContain("Taken"); + expect(html).toContain("Skipped"); + expect(html).toContain("Missed"); + expect(html).toContain("Upcoming"); + }); +});