mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(medications): GLP-1 cadence visualisation + compliance chips on detail page
This commit is contained in:
1 parent
3ef49679ed
commit
71cc05acd6
9 files changed
+770
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user