feat(medications): GLP-1 cadence visualisation + compliance chips on detail page

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:15:24 +02:00
1 parent 3ef49679ed
commit 71cc05acd6
9 files changed
+770

No files matched your search

+42
View File
@@ -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",
+42
View File
@@ -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",
+42
View File
@@ -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",
+42
View File
@@ -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",
+42
View File
@@ -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",
+42
View File
@@ -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",
+14
View File
@@ -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({
<SideEffectsSection medicationId={id} />
)}
{/* 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" && (
<SchedulingSection
medicationId={id}
reminderEnabled={medication.notificationsEnabled ?? true}
/>
)}
<IntakeHistoryList
medicationId={id}
createOpen={createOpen}
@@ -0,0 +1,309 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Loader2, BellRing, BellOff, Pencil } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W19e — GLP-1 cadence + compliance section.
*
* Sits between `<SideEffectsSection>` (W19d) and `<IntakeHistoryList>`
* 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<CadenceTimelineEntry["status"], string> = {
// 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<CadenceResponse> => {
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 (
<section
className="border-border/60 rounded-md border"
aria-labelledby="scheduling-heading"
>
<header className="flex items-center justify-between px-3 py-2.5">
<h2
id="scheduling-heading"
className="text-foreground/85 text-sm font-medium"
>
{t("medications.scheduling.section")}
</h2>
<div className="flex items-center gap-2">
<Badge
variant={reminderEnabled ? "secondary" : "outline"}
className="gap-1 text-[10px]"
>
{reminderEnabled ? (
<BellRing className="h-3 w-3" />
) : (
<BellOff className="h-3 w-3" />
)}
{t(
reminderEnabled
? "medications.scheduling.reminders.on"
: "medications.scheduling.reminders.off",
)}
</Badge>
<Button
size="sm"
variant="outline"
asChild
aria-label={t("medications.scheduling.editCta")}
>
<Link href="/medications">
<Pencil className="mr-1.5 h-3.5 w-3.5" />
{t("medications.scheduling.editCta")}
</Link>
</Button>
</div>
</header>
<div className="border-border/60 border-t px-3 py-3 text-xs">
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.scheduling.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.scheduling.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-4">
{/* Next-dose chip */}
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground">
{data.next ? (
<>
{t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "}
<span className="text-foreground font-medium">
{fmt.dateShort(new Date(data.next.windowStart))}
</span>
</>
) : (
t("medications.scheduling.cadenceViz.noNextDose")
)}
</p>
</div>
{/* 30-day timeline */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.cadenceViz.title")}
</p>
{data.timeline.length === 0 ? (
<p className="text-muted-foreground py-1">
{t("medications.scheduling.cadenceViz.emptyState")}
</p>
) : (
<>
<div
className="flex flex-wrap gap-1"
role="img"
aria-label={t(
"medications.scheduling.cadenceViz.ariaTimeline",
)}
>
{data.timeline.map((slot, i) => (
<span
key={`${slot.windowStart}-${i}`}
className={`h-3 w-3 rounded-sm ${STATUS_STYLES[slot.status]}`}
title={`${fmt.dateShort(new Date(slot.windowStart))} — ${t(
`medications.scheduling.cadenceViz.status.${slot.status}`,
)}`}
/>
))}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-[10px]">
{(["taken", "skipped", "missed", "upcoming"] as const).map(
(s) => (
<span key={s} className="inline-flex items-center gap-1">
<span
className={`h-2.5 w-2.5 rounded-sm ${STATUS_STYLES[s]}`}
/>
<span>
{t(
`medications.scheduling.cadenceViz.status.${s}`,
)}
</span>
</span>
),
)}
</div>
</>
)}
</div>
{/* Compliance chips */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.compliance.title")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<ComplianceChip
label={t(
"medications.scheduling.compliance.adherenceRate",
)}
value={adherenceLabel ?? "—"}
tooltip={t(
"medications.scheduling.compliance.chips.adherenceTooltip",
)}
/>
<ComplianceChip
label={t(
"medications.scheduling.compliance.currentStreak",
)}
value={`${data.chips.currentStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.streakTooltip",
)}
/>
<ComplianceChip
label={t(
"medications.scheduling.compliance.longestStreak",
)}
value={`${data.chips.longestStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.longestTooltip",
)}
/>
<ComplianceChip
label={t(
"medications.scheduling.compliance.missedLast30",
)}
value={`${data.chips.missedLast30} ${t(
"medications.scheduling.compliance.unit.doses",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.missedTooltip",
)}
/>
</div>
</div>
</div>
)}
</div>
</section>
);
}
interface ComplianceChipProps {
label: string;
value: string;
tooltip: string;
}
function ComplianceChip({ label, value, tooltip }: ComplianceChipProps) {
return (
<div
className="bg-muted/30 rounded-md px-2.5 py-1.5"
title={tooltip}
aria-label={`${label}: ${value}. ${tooltip}`}
>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
{label}
</p>
<p className="text-foreground text-sm font-medium">{value}</p>
</div>
);
}
@@ -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(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
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("<SchedulingSection> — surface render", () => {
it("renders the section heading in English", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={false} />,
client,
);
expect(html).toContain("Reminders off");
});
});
describe("<SchedulingSection> — chips", () => {
it("renders the four compliance chip values", () => {
const client = makeClient();
seed(client, "med-1", SEEDED);
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("No data");
});
});
describe("<SchedulingSection> — timeline", () => {
it("renders the timeline empty-state when no slots are seeded", () => {
const client = makeClient();
seed(client, "med-1", { ...SEEDED, timeline: [] });
const html = render(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
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(
<SchedulingSection medicationId="med-1" reminderEnabled={true} />,
client,
);
expect(html).toContain("Taken");
expect(html).toContain("Skipped");
expect(html).toContain("Missed");
expect(html).toContain("Upcoming");
});
});