Merge pull request #304 from MBombeck/release/v1.15.18

Release v1.15.18
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-08 20:16:11 +02:00
commit a431b2346c
90 files changed
+9659 -1517

No files matched your search

+20
View File
@@ -2,6 +2,26 @@
## [Unreleased]
## [1.15.18] — 2026-06-08 — a traceable medication dose history
### Added
- **A traceable dose history for every medication.** The Verlauf tab now shows every expected dose slot with a clear status — taken on time, taken late, skipped, or missed — and tags any off-schedule intake as ad-hoc. The history reads from the same ledger the compliance percentage is built on, so the rate you see can no longer disagree with the timeline beneath it.
- **A configurable intake window per dose.** Each dose time can carry either a point time or an explicit range (for example 07:00–09:00); the default stays ±1 hour. The window defines the on-time band, so a take that lands inside it counts as on time rather than late. The window is editable on the schedule and in the medication wizard.
- **An Insights settings page.** A cog at the top right of the overview opens a dedicated Insights section in Settings, where you arrange the overview sections and sort the navigation pills. The old inline "Anpassen" toggle is gone — the cog is the single entry point.
- **A dose-history endpoint.** A read endpoint returns the resolved per-slot status over a date range, so the history view and any client render the same picture.
### Changed
- **A redesigned medication detail page.** The detail page is now a tabbed layout — Übersicht, Zeitplan, Erinnerung, Bestand, Verlauf, Erweitert — and the old stacked advanced-settings modal is dissolved into tidy, grouped sections under Erweitert. Card actions open straight to the matching tab.
- **The medication card keeps a constant surface.** The card no longer changes colour by dose status; the current status shows as a discreet label instead of tinting the whole card.
- **Intake writes and edits attribute by the real intake time.** Marking a dose taken records it against the slot whose window it falls in; a take outside every window records as ad-hoc instead of snapping to a far-off slot. Editing a dose re-attributes it, a near-miss take offers "attribute to this dose anyway?", and marking taken or skipped recomputes the history and rate immediately.
### Fixed
- **The daily briefing's regenerate no longer times out before the text is ready.** A slow but successful regeneration used to be discarded by a short timeout, leaving the old text in place while the toast still claimed success. The regenerate path now waits for the warm text and the toast is honest — it reports a refresh only when one happened, and tells you to try again when generation is still running.
- **The sleep-phase chart no longer double-counts time in bed.** Time-in-bed was being stacked as if it were a separate phase on top of the individual phases, roughly doubling the bar and the per-night total. It is no longer counted as a phase, so the bar and tooltip total read correctly.
## [1.15.17] — 2026-06-07 — large imports, demo chart toggles
### Fixed
+40 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.15.17
version: 1.15.18
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -4307,6 +4307,15 @@ components:
type: integer
minimum: 0
maximum: 52
doseWindows:
description: Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time
band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must
match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on
the default derivation. The late tail stays cadence-derived.
maxItems: 8
type: array
items:
$ref: "#/components/schemas/DoseWindowEntry"
required:
- windowStart
- windowEnd
@@ -4315,6 +4324,28 @@ components:
preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually
exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK
constraint.
DoseWindowEntry:
type: object
properties:
timeOfDay:
type: string
pattern: ^([01]\d|2[0-3]):([0-5]\d)$
description: The dose time this window applies to (matches a `timesOfDay` entry).
start:
type: string
pattern: ^([01]\d|2[0-3]):([0-5]\d)$
description: On-time band lower bound (HH:mm, user local).
end:
type: string
pattern: ^([01]\d|2[0-3]):([0-5]\d)$
description: On-time band upper bound (HH:mm, user local). Must be >= `start`.
required:
- timeOfDay
- start
- end
description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]`
(HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then
ad-hoc.
UpdateMedicationRequest:
type: object
properties:
@@ -4489,6 +4520,14 @@ components:
- THIGH_RIGHT
- UPPER_ARM_LEFT
- UPPER_ARM_RIGHT
forceSlotInstant:
description: "Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc
row. Must be a real scheduled slot of this medication on its day (validated server-side against the
dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band
attribution."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- medicationId
description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second
+119 -1
View File
@@ -1338,6 +1338,42 @@
"failed": "Einnahme konnte nicht gelöscht werden."
}
},
"verlauf": {
"loading": "Verlauf wird geladen …",
"loadError": "Verlauf konnte nicht geladen werden.",
"empty": "Noch keine Einnahmen in diesem Zeitraum.",
"summaryEmpty": "Noch keine auswertbaren Dosen.",
"adherenceLabel": "Therapietreue",
"adherenceBarLabel": "{onTime} pünktlich, {late} verspätet, {missed} verpasst",
"add": "Eintrag",
"markTaken": "Genommen",
"markSkipped": "Übersprungen",
"adHocTag": "außerplanmäßig",
"takenDetail": "geplant {planned} · genommen {taken}",
"status": {
"taken_on_time": "pünktlich",
"taken_late": "verspätet",
"skipped": "übersprungen",
"missed": "verpasst",
"upcoming": "ausstehend",
"ad_hoc": "außerplanmäßig"
},
"addDialog": {
"title": "Einnahme hinzufügen",
"description": "Dosis nachtragen – auch rückwirkend oder außerplanmäßig.",
"takenAtLabel": "Genommen um",
"skippedLabel": "Übersprungen",
"nudgeHint": "Liegt nah am Slot {slot} – beim Speichern fragen wir, ob zugeordnet werden soll.",
"cancel": "Abbrechen",
"submit": "Speichern"
},
"nudge": {
"title": "Diesem Slot zuordnen?",
"body": "Die Einnahme liegt knapp außerhalb des Fensters von {slot}. Diesem Slot zuordnen oder als außerplanmäßigen Eintrag speichern?",
"keepAdHoc": "Außerplanmäßig",
"pin": "Zuordnen"
}
},
"notifications": {
"title": "Erinnerungen",
"switchLabel": "Erinnern, wenn die Dosis fällig ist",
@@ -1457,6 +1493,69 @@
"back": "Zurück zum Medikament",
"subtitle": "Vollständiger Einnahmeverlauf",
"plannedSuffix": "geplant"
},
"tabs": {
"uebersicht": "Übersicht",
"zeitplan": "Zeitplan",
"erinnerung": "Erinnerung",
"bestand": "Bestand",
"verlauf": "Verlauf",
"injektion": "Injektion",
"erweitert": "Erweitert"
},
"shell": {
"fullEdit": "Vollständig bearbeiten",
"loadFailed": "Dieses Medikament konnte nicht geladen werden.",
"tablistLabel": "Medikamenten-Detailbereiche"
},
"uebersicht": {
"complianceTitle": "Therapietreue",
"complianceEmpty": "Noch keine Daten zur Therapietreue."
},
"bestand": {
"title": "Bestand",
"summary": "{remaining} von {total} Dosen übrig",
"itemsTitle": "Pens / Ampullen",
"doses": "{remaining} / {total} Dosen",
"state": {
"ACTIVE": "Ungeöffnet",
"IN_USE": "In Gebrauch",
"EXPIRED": "Abgelaufen",
"USED_UP": "Aufgebraucht"
},
"empty": "Kein Bestand erfasst.",
"emptyHelper": "Erfasse einen Pen oder eine Packung, um verbleibende Dosen und Reichweite zu verfolgen."
},
"erweitert": {
"group": {
"lifecycle": "Lebenszyklus",
"externalApi": "Externe API",
"data": "Daten",
"danger": "Gefahrenzone"
}
},
"zeitplan": {
"title": "Zeitplan",
"timesTitle": "Einnahmezeiten",
"timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Für die Häufigkeit (täglich, wöchentlich …) nutze „Vollständig bearbeiten“.",
"scheduleLabel": "Zeitplan {index}",
"fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du über „Vollständig bearbeiten“.",
"graceEcho": "Erinnerungsfenster: {minutes} Minuten",
"graceLink": "In „Erinnerung“ ändern",
"save": "Zeitplan speichern",
"saved": "Zeitplan gespeichert",
"failed": "Zeitplan konnte nicht gespeichert werden",
"window": {
"intro": "Lege je Dosis fest, wann eine Einnahme als pünktlich gilt — ein genauer Zeitpunkt (±1 Std.) oder ein eigenes Fenster wie 07:00–09:00.",
"explicitToggle": "Eigenes Fenster",
"startLabel": "Fensterbeginn",
"endLabel": "Fensterende",
"onTimeBand": "Pünktlich: {start}–{end}",
"lateUntil": "verspätet bis {time}",
"lateDays": "verspätet bis {days} Tage danach",
"orderError": "Der Beginn muss vor dem Ende liegen.",
"noTimes": "Noch keine Einnahmezeiten festgelegt."
}
}
},
"logInjectionSiteTitle": "Injektionsstelle erfassen",
@@ -3041,7 +3140,22 @@
},
"disclaimer": {
"footer": "HealthLog beschreibt deine eigenen erfassten Daten — es ist keine klinische Bewertung und keine Diagnose. Sprich mit deiner Ärztin oder deinem Arzt über alles, was dich beunruhigt."
}
},
"settings": {
"overviewTitle": "Übersicht anordnen",
"overviewDescription": "Abschnitte ein- oder ausblenden und ihre Reihenfolge auf der Insights-Übersicht festlegen."
},
"pillOrder": {
"title": "Navigations-Pills sortieren",
"description": "Ziehe die Pills in die Reihenfolge, in der sie oben in den Insights erscheinen sollen.",
"dragHandle": "Verschieben",
"saveSuccess": "Reihenfolge der Pills gespeichert.",
"saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.",
"save": "Speichern",
"empty": "Noch keine Navigations-Pills verfügbar."
},
"customize": "Insights anpassen",
"regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen."
},
"thresholds": {
"defaultLabel": "Default",
@@ -3429,6 +3543,10 @@
"sharing": {
"title": "Freigabe",
"description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen."
},
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
}
},
"profile": "Profil",
+119 -1
View File
@@ -1338,6 +1338,42 @@
"failed": "Could not delete the intake."
}
},
"verlauf": {
"loading": "Loading history …",
"loadError": "Could not load the history.",
"empty": "No intakes in this period yet.",
"summaryEmpty": "No doses to score yet.",
"adherenceLabel": "Adherence",
"adherenceBarLabel": "{onTime} on time, {late} late, {missed} missed",
"add": "Entry",
"markTaken": "Taken",
"markSkipped": "Skipped",
"adHocTag": "off-schedule",
"takenDetail": "planned {planned} · taken {taken}",
"status": {
"taken_on_time": "on time",
"taken_late": "late",
"skipped": "skipped",
"missed": "missed",
"upcoming": "upcoming",
"ad_hoc": "off-schedule"
},
"addDialog": {
"title": "Add intake",
"description": "Record a dose — including a backdated or off-schedule one.",
"takenAtLabel": "Taken at",
"skippedLabel": "Skipped",
"nudgeHint": "Close to the {slot} slot — on save we ask whether to attribute it.",
"cancel": "Cancel",
"submit": "Save"
},
"nudge": {
"title": "Attribute to this slot?",
"body": "This intake falls just outside the {slot} window. Attribute it to that slot, or keep it as an off-schedule entry?",
"keepAdHoc": "Off-schedule",
"pin": "Attribute"
}
},
"notifications": {
"title": "Notifications",
"switchLabel": "Send a reminder when this dose is due",
@@ -1457,6 +1493,69 @@
"back": "Back to medication",
"subtitle": "Full intake history",
"plannedSuffix": "planned"
},
"tabs": {
"uebersicht": "Overview",
"zeitplan": "Schedule",
"erinnerung": "Reminders",
"bestand": "Supply",
"verlauf": "History",
"injektion": "Injection",
"erweitert": "Advanced"
},
"shell": {
"fullEdit": "Edit fully",
"loadFailed": "This medication could not be loaded.",
"tablistLabel": "Medication detail sections"
},
"uebersicht": {
"complianceTitle": "Adherence",
"complianceEmpty": "No adherence data yet."
},
"bestand": {
"title": "Supply",
"summary": "{remaining} of {total} doses remaining",
"itemsTitle": "Pens / vials",
"doses": "{remaining} / {total} doses",
"state": {
"ACTIVE": "Unopened",
"IN_USE": "In use",
"EXPIRED": "Expired",
"USED_UP": "Used up"
},
"empty": "No supply recorded.",
"emptyHelper": "Register a pen or pack to track doses remaining and weeks of supply."
},
"erweitert": {
"group": {
"lifecycle": "Lifecycle",
"externalApi": "External API",
"data": "Data",
"danger": "Danger zone"
}
},
"zeitplan": {
"title": "Schedule",
"timesTitle": "Dose times",
"timesNote": "Adjust each dose's time and on-time window. For the frequency (daily, weekly …) use “Full edit”.",
"scheduleLabel": "Schedule {index}",
"fullEditNote": "Frequency, treatment class and course window are edited through “Full edit”.",
"graceEcho": "Reminder window: {minutes} minutes",
"graceLink": "Change under “Reminders”",
"save": "Save schedule",
"saved": "Schedule saved",
"failed": "Could not save the schedule",
"window": {
"intro": "For each dose, set when an intake still counts as on time — a precise time (±1h) or your own window such as 07:00–09:00.",
"explicitToggle": "Custom window",
"startLabel": "Window start",
"endLabel": "Window end",
"onTimeBand": "On time: {start}–{end}",
"lateUntil": "late until {time}",
"lateDays": "late until {days} days after",
"orderError": "The start must be before the end.",
"noTimes": "No dose times set yet."
}
}
},
"logInjectionSiteTitle": "Log injection site",
@@ -3041,7 +3140,22 @@
},
"disclaimer": {
"footer": "HealthLog describes your own tracked data — it is not a clinical assessment or diagnosis. Talk to your doctor about anything that concerns you."
}
},
"settings": {
"overviewTitle": "Arrange the overview",
"overviewDescription": "Show or hide sections and set their order on the Insights overview."
},
"pillOrder": {
"title": "Sort the navigation pills",
"description": "Drag the pills into the order they should appear in along the top of Insights.",
"dragHandle": "Reorder",
"saveSuccess": "Pill order saved.",
"saveError": "Saving the pill order failed.",
"save": "Save",
"empty": "No navigation pills available yet."
},
"customize": "Customize Insights",
"regenerateError": "Still refreshing — please try again in a moment."
},
"thresholds": {
"defaultLabel": "Default",
@@ -3429,6 +3543,10 @@
"sharing": {
"title": "Sharing",
"description": "Create a read-only link to share your health record with a clinician."
},
"insights": {
"title": "Insights",
"description": "Overview and navigation-pill order."
}
},
"profile": "Profile",
+119 -1
View File
@@ -1338,6 +1338,42 @@
"failed": "No se pudo eliminar la toma."
}
},
"verlauf": {
"loading": "Cargando historial …",
"loadError": "No se pudo cargar el historial.",
"empty": "Aún no hay tomas en este periodo.",
"summaryEmpty": "Aún no hay dosis que evaluar.",
"adherenceLabel": "Adherencia",
"adherenceBarLabel": "{onTime} a tiempo, {late} con retraso, {missed} omitidas",
"add": "Entrada",
"markTaken": "Tomada",
"markSkipped": "Saltada",
"adHocTag": "fuera de horario",
"takenDetail": "prevista {planned} · tomada {taken}",
"status": {
"taken_on_time": "a tiempo",
"taken_late": "con retraso",
"skipped": "saltada",
"missed": "perdida",
"upcoming": "pendiente",
"ad_hoc": "fuera de horario"
},
"addDialog": {
"title": "Añadir toma",
"description": "Registra una dosis, también retroactiva o fuera de horario.",
"takenAtLabel": "Tomada a las",
"skippedLabel": "Saltada",
"nudgeHint": "Cerca del horario {slot}: al guardar te preguntamos si asignarla.",
"cancel": "Cancelar",
"submit": "Guardar"
},
"nudge": {
"title": "¿Asignar a este horario?",
"body": "Esta toma queda justo fuera de la ventana de {slot}. ¿Asignarla a ese horario o guardarla como entrada fuera de horario?",
"keepAdHoc": "Fuera de horario",
"pin": "Asignar"
}
},
"notifications": {
"title": "Recordatorios",
"switchLabel": "Enviar un recordatorio cuando toque esta dosis",
@@ -1457,6 +1493,69 @@
"back": "Volver al medicamento",
"subtitle": "Historial completo de tomas",
"plannedSuffix": "planificado"
},
"tabs": {
"uebersicht": "Resumen",
"zeitplan": "Horario",
"erinnerung": "Recordatorios",
"bestand": "Existencias",
"verlauf": "Historial",
"injektion": "Inyección",
"erweitert": "Avanzado"
},
"shell": {
"fullEdit": "Editar por completo",
"loadFailed": "No se pudo cargar este medicamento.",
"tablistLabel": "Secciones de detalle del medicamento"
},
"uebersicht": {
"complianceTitle": "Adherencia",
"complianceEmpty": "Aún no hay datos de adherencia."
},
"bestand": {
"title": "Existencias",
"summary": "{remaining} de {total} dosis restantes",
"itemsTitle": "Plumas / viales",
"doses": "{remaining} / {total} dosis",
"state": {
"ACTIVE": "Sin abrir",
"IN_USE": "En uso",
"EXPIRED": "Caducado",
"USED_UP": "Agotado"
},
"empty": "No hay existencias registradas.",
"emptyHelper": "Registra una pluma o un paquete para hacer seguimiento de las dosis restantes y las semanas de suministro."
},
"erweitert": {
"group": {
"lifecycle": "Ciclo de vida",
"externalApi": "API externa",
"data": "Datos",
"danger": "Zona de peligro"
}
},
"zeitplan": {
"title": "Horario",
"timesTitle": "Horas de toma",
"timesNote": "Ajusta la hora y la ventana puntual de cada dosis. Para la frecuencia (diaria, semanal…) usa «Edición completa».",
"scheduleLabel": "Horario {index}",
"fullEditNote": "La frecuencia, la clase de tratamiento y la ventana del curso se editan en «Edición completa».",
"graceEcho": "Ventana de recordatorio: {minutes} minutos",
"graceLink": "Cambiar en «Recordatorios»",
"save": "Guardar horario",
"saved": "Horario guardado",
"failed": "No se pudo guardar el horario",
"window": {
"intro": "Para cada dosis, define cuándo una toma sigue siendo puntual: una hora exacta (±1h) o tu propia ventana como 07:00–09:00.",
"explicitToggle": "Ventana propia",
"startLabel": "Inicio de la ventana",
"endLabel": "Fin de la ventana",
"onTimeBand": "Puntual: {start}–{end}",
"lateUntil": "con retraso hasta las {time}",
"lateDays": "con retraso hasta {days} días después",
"orderError": "El inicio debe ser anterior al fin.",
"noTimes": "Aún no hay horas de toma definidas."
}
}
},
"logInjectionSiteTitle": "Registrar sitio de inyección",
@@ -3041,7 +3140,22 @@
},
"disclaimer": {
"footer": "HealthLog describe tus propios datos registrados — no es una evaluación clínica ni un diagnóstico. Habla con tu médico sobre cualquier cosa que te preocupe."
}
},
"settings": {
"overviewTitle": "Organizar la vista general",
"overviewDescription": "Muestra u oculta secciones y define su orden en la vista general de Análisis."
},
"pillOrder": {
"title": "Ordenar las pestañas de navegación",
"description": "Arrastra las pestañas al orden en que deben aparecer en la parte superior de Análisis.",
"dragHandle": "Reordenar",
"saveSuccess": "Orden de las pestañas guardado.",
"saveError": "No se pudo guardar el orden de las pestañas.",
"save": "Guardar",
"empty": "Aún no hay pestañas de navegación disponibles."
},
"customize": "Personalizar Análisis",
"regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento."
},
"thresholds": {
"defaultLabel": "Por defecto",
@@ -3429,6 +3543,10 @@
"sharing": {
"title": "Compartir",
"description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico."
},
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
}
},
"profile": "Perfil",
+119 -1
View File
@@ -1338,6 +1338,42 @@
"failed": "Impossible de supprimer la prise."
}
},
"verlauf": {
"loading": "Chargement de l'historique …",
"loadError": "Impossible de charger l'historique.",
"empty": "Aucune prise sur cette période pour l'instant.",
"summaryEmpty": "Aucune dose à évaluer pour l'instant.",
"adherenceLabel": "Observance",
"adherenceBarLabel": "{onTime} à l'heure, {late} en retard, {missed} manquées",
"add": "Entrée",
"markTaken": "Prise",
"markSkipped": "Sautée",
"adHocTag": "hors planning",
"takenDetail": "prévue {planned} · prise {taken}",
"status": {
"taken_on_time": "à l'heure",
"taken_late": "en retard",
"skipped": "sautée",
"missed": "manquée",
"upcoming": "à venir",
"ad_hoc": "hors planning"
},
"addDialog": {
"title": "Ajouter une prise",
"description": "Enregistrez une dose, même rétroactive ou hors planning.",
"takenAtLabel": "Prise à",
"skippedLabel": "Sautée",
"nudgeHint": "Proche du créneau {slot} — à l'enregistrement, nous demandons s'il faut l'y rattacher.",
"cancel": "Annuler",
"submit": "Enregistrer"
},
"nudge": {
"title": "Rattacher à ce créneau ?",
"body": "Cette prise se situe juste en dehors de la fenêtre de {slot}. La rattacher à ce créneau ou la conserver comme entrée hors planning ?",
"keepAdHoc": "Hors planning",
"pin": "Rattacher"
}
},
"notifications": {
"title": "Rappels",
"switchLabel": "Envoyer un rappel lorsque cette dose est due",
@@ -1457,6 +1493,69 @@
"back": "Retour au médicament",
"subtitle": "Historique complet des prises",
"plannedSuffix": "prévu"
},
"tabs": {
"uebersicht": "Aperçu",
"zeitplan": "Horaire",
"erinnerung": "Rappels",
"bestand": "Stock",
"verlauf": "Historique",
"injektion": "Injection",
"erweitert": "Avancé"
},
"shell": {
"fullEdit": "Modifier entièrement",
"loadFailed": "Ce médicament n'a pas pu être chargé.",
"tablistLabel": "Sections de détail du médicament"
},
"uebersicht": {
"complianceTitle": "Observance",
"complianceEmpty": "Aucune donnée d'observance pour l'instant."
},
"bestand": {
"title": "Stock",
"summary": "{remaining} doses restantes sur {total}",
"itemsTitle": "Stylos / flacons",
"doses": "{remaining} / {total} doses",
"state": {
"ACTIVE": "Non ouvert",
"IN_USE": "En cours",
"EXPIRED": "Expiré",
"USED_UP": "Épuisé"
},
"empty": "Aucun stock enregistré.",
"emptyHelper": "Enregistrez un stylo ou une boîte pour suivre les doses restantes et l'autonomie."
},
"erweitert": {
"group": {
"lifecycle": "Cycle de vie",
"externalApi": "API externe",
"data": "Données",
"danger": "Zone de danger"
}
},
"zeitplan": {
"title": "Horaire",
"timesTitle": "Heures de prise",
"timesNote": "Ajustez l'heure et la fenêtre à l'heure de chaque dose. Pour la fréquence (quotidienne, hebdomadaire…), utilisez « Édition complète ».",
"scheduleLabel": "Horaire {index}",
"fullEditNote": "La fréquence, la classe de traitement et la fenêtre de cure se modifient via « Édition complète ».",
"graceEcho": "Fenêtre de rappel : {minutes} minutes",
"graceLink": "Modifier dans « Rappels »",
"save": "Enregistrer l'horaire",
"saved": "Horaire enregistré",
"failed": "Impossible d'enregistrer l'horaire",
"window": {
"intro": "Pour chaque dose, définissez quand une prise reste à l'heure : un horaire précis (±1 h) ou votre propre fenêtre comme 07:00–09:00.",
"explicitToggle": "Fenêtre personnalisée",
"startLabel": "Début de la fenêtre",
"endLabel": "Fin de la fenêtre",
"onTimeBand": "À l'heure : {start}–{end}",
"lateUntil": "en retard jusqu'à {time}",
"lateDays": "en retard jusqu'à {days} jours après",
"orderError": "Le début doit précéder la fin.",
"noTimes": "Aucune heure de prise définie pour l'instant."
}
}
},
"logInjectionSiteTitle": "Enregistrer le site d'injection",
@@ -3041,7 +3140,22 @@
},
"disclaimer": {
"footer": "HealthLog décrit vos propres données suivies — il ne s'agit pas d'une évaluation clinique ni d'un diagnostic. Parlez à votre médecin de tout ce qui vous préoccupe."
}
},
"settings": {
"overviewTitle": "Organiser la vue d'ensemble",
"overviewDescription": "Affichez ou masquez des sections et définissez leur ordre sur la vue d'ensemble des Analyses."
},
"pillOrder": {
"title": "Trier les onglets de navigation",
"description": "Faites glisser les onglets dans l'ordre où ils doivent apparaître en haut des Analyses.",
"dragHandle": "Réorganiser",
"saveSuccess": "Ordre des onglets enregistré.",
"saveError": "L'enregistrement de l'ordre des onglets a échoué.",
"save": "Enregistrer",
"empty": "Aucun onglet de navigation disponible pour le moment."
},
"customize": "Personnaliser les Analyses",
"regenerateError": "Actualisation en cours — réessayez dans un instant."
},
"thresholds": {
"defaultLabel": "Par défaut",
@@ -3429,6 +3543,10 @@
"sharing": {
"title": "Partage",
"description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé."
},
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
}
},
"profile": "Profil",
+119 -1
View File
@@ -1338,6 +1338,42 @@
"failed": "Impossibile eliminare l’assunzione."
}
},
"verlauf": {
"loading": "Caricamento cronologia …",
"loadError": "Impossibile caricare la cronologia.",
"empty": "Ancora nessuna assunzione in questo periodo.",
"summaryEmpty": "Ancora nessuna dose da valutare.",
"adherenceLabel": "Aderenza",
"adherenceBarLabel": "{onTime} puntuali, {late} in ritardo, {missed} mancate",
"add": "Voce",
"markTaken": "Assunta",
"markSkipped": "Saltata",
"adHocTag": "fuori programma",
"takenDetail": "prevista {planned} · assunta {taken}",
"status": {
"taken_on_time": "puntuale",
"taken_late": "in ritardo",
"skipped": "saltata",
"missed": "mancata",
"upcoming": "in attesa",
"ad_hoc": "fuori programma"
},
"addDialog": {
"title": "Aggiungi assunzione",
"description": "Registra una dose, anche retroattiva o fuori programma.",
"takenAtLabel": "Assunta alle",
"skippedLabel": "Saltata",
"nudgeHint": "Vicina allo slot {slot}: al salvataggio chiediamo se attribuirla.",
"cancel": "Annulla",
"submit": "Salva"
},
"nudge": {
"title": "Attribuire a questo slot?",
"body": "Questa assunzione cade appena fuori dalla finestra di {slot}. Attribuirla a quello slot o tenerla come voce fuori programma?",
"keepAdHoc": "Fuori programma",
"pin": "Attribuisci"
}
},
"notifications": {
"title": "Promemoria",
"switchLabel": "Invia un promemoria quando questa dose è dovuta",
@@ -1457,6 +1493,69 @@
"back": "Torna al farmaco",
"subtitle": "Cronologia completa delle assunzioni",
"plannedSuffix": "pianificato"
},
"tabs": {
"uebersicht": "Panoramica",
"zeitplan": "Programma",
"erinnerung": "Promemoria",
"bestand": "Scorta",
"verlauf": "Cronologia",
"injektion": "Iniezione",
"erweitert": "Avanzate"
},
"shell": {
"fullEdit": "Modifica completa",
"loadFailed": "Impossibile caricare questo farmaco.",
"tablistLabel": "Sezioni di dettaglio del farmaco"
},
"uebersicht": {
"complianceTitle": "Aderenza",
"complianceEmpty": "Ancora nessun dato sull'aderenza."
},
"bestand": {
"title": "Scorta",
"summary": "{remaining} dosi rimanenti su {total}",
"itemsTitle": "Penne / flaconi",
"doses": "{remaining} / {total} dosi",
"state": {
"ACTIVE": "Non aperto",
"IN_USE": "In uso",
"EXPIRED": "Scaduto",
"USED_UP": "Esaurito"
},
"empty": "Nessuna scorta registrata.",
"emptyHelper": "Registra una penna o una confezione per monitorare le dosi rimanenti e l'autonomia."
},
"erweitert": {
"group": {
"lifecycle": "Ciclo di vita",
"externalApi": "API esterna",
"data": "Dati",
"danger": "Zona pericolosa"
}
},
"zeitplan": {
"title": "Programma",
"timesTitle": "Orari di assunzione",
"timesNote": "Regola l'orario e la finestra puntuale di ogni dose. Per la frequenza (giornaliera, settimanale…) usa «Modifica completa».",
"scheduleLabel": "Programma {index}",
"fullEditNote": "Frequenza, classe di trattamento e finestra del ciclo si modificano da «Modifica completa».",
"graceEcho": "Finestra di promemoria: {minutes} minuti",
"graceLink": "Modifica in «Promemoria»",
"save": "Salva programma",
"saved": "Programma salvato",
"failed": "Impossibile salvare il programma",
"window": {
"intro": "Per ogni dose, stabilisci quando un'assunzione è ancora puntuale: un orario preciso (±1 h) o una finestra a tua scelta come 07:00–09:00.",
"explicitToggle": "Finestra personalizzata",
"startLabel": "Inizio finestra",
"endLabel": "Fine finestra",
"onTimeBand": "Puntuale: {start}–{end}",
"lateUntil": "in ritardo fino alle {time}",
"lateDays": "in ritardo fino a {days} giorni dopo",
"orderError": "L'inizio deve precedere la fine.",
"noTimes": "Nessun orario di assunzione impostato."
}
}
},
"logInjectionSiteTitle": "Registra il sito di iniezione",
@@ -3041,7 +3140,22 @@
},
"disclaimer": {
"footer": "HealthLog descrive i tuoi dati registrati — non è una valutazione clinica né una diagnosi. Parla con il tuo medico di qualsiasi cosa ti preoccupi."
}
},
"settings": {
"overviewTitle": "Organizza la panoramica",
"overviewDescription": "Mostra o nascondi le sezioni e definisci il loro ordine nella panoramica degli Approfondimenti."
},
"pillOrder": {
"title": "Ordina le schede di navigazione",
"description": "Trascina le schede nell'ordine in cui devono comparire in alto negli Approfondimenti.",
"dragHandle": "Riordina",
"saveSuccess": "Ordine delle schede salvato.",
"saveError": "Salvataggio dell'ordine delle schede non riuscito.",
"save": "Salva",
"empty": "Nessuna scheda di navigazione ancora disponibile."
},
"customize": "Personalizza gli Approfondimenti",
"regenerateError": "Aggiornamento ancora in corso — riprova tra un momento."
},
"thresholds": {
"defaultLabel": "Predefinito",
@@ -3429,6 +3543,10 @@
"sharing": {
"title": "Condivisione",
"description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico."
},
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
}
},
"profile": "Profilo",
+119 -1
View File
@@ -1338,6 +1338,42 @@
"failed": "Nie udało się usunąć przyjęcia."
}
},
"verlauf": {
"loading": "Wczytywanie historii …",
"loadError": "Nie udało się wczytać historii.",
"empty": "Brak przyjęć w tym okresie.",
"summaryEmpty": "Brak dawek do oceny.",
"adherenceLabel": "Przestrzeganie",
"adherenceBarLabel": "{onTime} na czas, {late} z opóźnieniem, {missed} pominięte",
"add": "Wpis",
"markTaken": "Przyjęte",
"markSkipped": "Pominięte",
"adHocTag": "poza planem",
"takenDetail": "planowo {planned} · przyjęto {taken}",
"status": {
"taken_on_time": "na czas",
"taken_late": "z opóźnieniem",
"skipped": "pominięte",
"missed": "nieprzyjęte",
"upcoming": "oczekujące",
"ad_hoc": "poza planem"
},
"addDialog": {
"title": "Dodaj przyjęcie",
"description": "Zarejestruj dawkę — także wstecznie lub poza planem.",
"takenAtLabel": "Przyjęto o",
"skippedLabel": "Pominięte",
"nudgeHint": "Blisko slotu {slot} — przy zapisie zapytamy, czy przypisać.",
"cancel": "Anuluj",
"submit": "Zapisz"
},
"nudge": {
"title": "Przypisać do tego slotu?",
"body": "To przyjęcie wypada tuż poza oknem {slot}. Przypisać je do tego slotu czy zachować jako wpis poza planem?",
"keepAdHoc": "Poza planem",
"pin": "Przypisz"
}
},
"notifications": {
"title": "Przypomnienia",
"switchLabel": "Wyślij przypomnienie, gdy nadejdzie czas tej dawki",
@@ -1457,6 +1493,69 @@
"back": "Powrót do leku",
"subtitle": "Pełna historia przyjęć",
"plannedSuffix": "zaplanowane"
},
"tabs": {
"uebersicht": "Przegląd",
"zeitplan": "Harmonogram",
"erinnerung": "Przypomnienia",
"bestand": "Zapas",
"verlauf": "Historia",
"injektion": "Iniekcja",
"erweitert": "Zaawansowane"
},
"shell": {
"fullEdit": "Edytuj w pełni",
"loadFailed": "Nie udało się załadować tego leku.",
"tablistLabel": "Sekcje szczegółów leku"
},
"uebersicht": {
"complianceTitle": "Przestrzeganie",
"complianceEmpty": "Brak danych o przestrzeganiu."
},
"bestand": {
"title": "Zapas",
"summary": "Pozostało {remaining} z {total} dawek",
"itemsTitle": "Wstrzykiwacze / fiolki",
"doses": "{remaining} / {total} dawek",
"state": {
"ACTIVE": "Nieotwarte",
"IN_USE": "W użyciu",
"EXPIRED": "Przeterminowane",
"USED_UP": "Zużyte"
},
"empty": "Nie zarejestrowano zapasu.",
"emptyHelper": "Zarejestruj wstrzykiwacz lub opakowanie, aby śledzić pozostałe dawki i zapas na tygodnie."
},
"erweitert": {
"group": {
"lifecycle": "Cykl życia",
"externalApi": "Zewnętrzne API",
"data": "Dane",
"danger": "Strefa zagrożenia"
}
},
"zeitplan": {
"title": "Harmonogram",
"timesTitle": "Godziny przyjęcia",
"timesNote": "Dostosuj godzinę i okno punktualności każdej dawki. Częstotliwość (codziennie, co tydzień…) zmieniasz w „Pełna edycja”.",
"scheduleLabel": "Harmonogram {index}",
"fullEditNote": "Częstotliwość, klasę leczenia i okno kuracji edytujesz przez „Pełna edycja”.",
"graceEcho": "Okno przypomnienia: {minutes} minut",
"graceLink": "Zmień w „Przypomnienia”",
"save": "Zapisz harmonogram",
"saved": "Zapisano harmonogram",
"failed": "Nie udało się zapisać harmonogramu",
"window": {
"intro": "Dla każdej dawki ustal, kiedy przyjęcie jest jeszcze punktualne — dokładna godzina (±1 h) lub własne okno, np. 07:00–09:00.",
"explicitToggle": "Własne okno",
"startLabel": "Początek okna",
"endLabel": "Koniec okna",
"onTimeBand": "Punktualnie: {start}–{end}",
"lateUntil": "z opóźnieniem do {time}",
"lateDays": "z opóźnieniem do {days} dni później",
"orderError": "Początek musi być przed końcem.",
"noTimes": "Nie ustawiono jeszcze godzin przyjęcia."
}
}
},
"logInjectionSiteTitle": "Zarejestruj miejsce iniekcji",
@@ -3041,7 +3140,22 @@
},
"disclaimer": {
"footer": "HealthLog opisuje Twoje własne zarejestrowane dane — nie jest oceną kliniczną ani diagnozą. Porozmawiaj z lekarzem o wszystkim, co Cię niepokoi."
}
},
"settings": {
"overviewTitle": "Uporządkuj przegląd",
"overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz."
},
"pillOrder": {
"title": "Sortuj pigułki nawigacyjne",
"description": "Przeciągnij pigułki w kolejności, w jakiej mają się pojawiać u góry Analiz.",
"dragHandle": "Zmień kolejność",
"saveSuccess": "Zapisano kolejność pigułek.",
"saveError": "Zapisanie kolejności pigułek nie powiodło się.",
"save": "Zapisz",
"empty": "Brak dostępnych pigułek nawigacyjnych."
},
"customize": "Dostosuj Analizy",
"regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę."
},
"thresholds": {
"defaultLabel": "Domyślne",
@@ -3429,6 +3543,10 @@
"sharing": {
"title": "Udostępnianie",
"description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi."
},
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
}
},
"profile": "Profil",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.15.17",
"version": "1.15.18",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,18 @@
-- v1.15.18 — per-dose configurable intake WINDOW.
--
-- Marc's "07:00–09:00" lever: a dose time can carry an explicit on-time RANGE
-- instead of the symmetric ±1h default the band minter derives. The column is
-- a nullable JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per
-- dose time the user gave an explicit window for, e.g.
-- [{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]
--
-- The band minter reads it to build the on-time band from `[start, end]` for
-- the matching `timeOfDay`; a time with no entry keeps the default ±1h
-- derivation, and a NULL column leaves every slot on the default (unchanged
-- behaviour). The late tail stays cadence-derived.
--
-- Additive + non-destructive: a new nullable column with no backfill. Existing
-- rows read NULL and behave exactly as before; the iOS contract gains an
-- optional field.
ALTER TABLE "medication_schedules"
ADD COLUMN "dose_windows" JSONB;
+10
View File
@@ -1388,6 +1388,16 @@ model MedicationSchedule {
/// v1.7.0 — cyclic "off" weeks. Only meaningful when
/// `scheduleType = CYCLIC`.
cyclicOffWeeks Int? @map("cyclic_off_weeks")
/// v1.15.18 — per-dose on-time intake WINDOW (the configurable lever).
/// A JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per
/// dose time the user wants an explicit range for (e.g.
/// `[{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]`).
/// The band minter reads this to build the on-time band from the
/// explicit `[start, end]` range for the matching `timeOfDay`; a
/// `timeOfDay` with no entry falls back to the symmetric ±1h default.
/// The late tail stays cadence-derived. NULL = every slot uses the
/// default derivation (unchanged behaviour). Additive + iOS-safe.
doseWindows Json? @map("dose_windows")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
telegramMessages TelegramReminderMessage[]
@@ -169,7 +169,15 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
expect(entry!.onTime).toBe(2);
});
it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => {
it("a 12h-late dose is an ad-hoc take + a missed 07:00 slot (band model)", async () => {
// v1.15.18 — the heatmap now buckets the unified dose-history ledger. A
// single 07:00 daily dose logged at 19:00 is 12h outside the 07:00 slot's
// on-time band (±60min) AND its late tail (to ~10:00), so it is an AD-HOC
// off-schedule take — a real logged dose (taken / on-time tone), NOT a
// "very late" attribution onto the 07:00 slot. The 07:00 slot itself reads
// missed (no intake inside its window past its cutoff). This is the audit's
// intent: the cell no longer mislabels an off-schedule take as a punctuality
// grade against a slot it never belonged to.
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
@@ -179,7 +187,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
id: "sched-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: [], // single-time: candidate = [windowStart]
timesOfDay: [], // single-time daily: the minter mints a 07:00 band
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
@@ -191,7 +199,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
]) as never,
);
// Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late.
// Single 07:00 schedule, dose logged at 19:00 → outside every band → ad-hoc.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "19:00"),
@@ -203,9 +211,14 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(1);
expect(entry!.onTime).toBe(0);
// The off-schedule take reads as a logged dose; no late-attribution.
expect(entry!.veryLate).toBe(0);
expect(entry!.late).toBe(0);
expect(entry!.onTime).toBe(1);
expect(entry!.taken).toBe(1);
// The 07:00 slot itself is missed (uncovered, past its cutoff).
// expected = the missed slot + the ad-hoc take's own slot.
expect(entry!.expected).toBe(2);
});
it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => {
+200 -182
View File
@@ -6,83 +6,132 @@ import {
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import {
reconstructDoseHistory,
type DoseHistoryRow,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import {
type CanonicalSchedule,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
/** A single effective dose slot with its derived punctuality window. */
interface SlotWindow {
/** Minute-of-day of the slot's start time (used for nearest-slot match). */
startMinutes: number;
/** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */
type MedicationScheduleRow = {
id: string;
rrule: string | null;
rollingIntervalDays: number | null;
timesOfDay: string[];
daysOfWeek: string | null;
windowStart: string;
/** "HH:mm" slot end = slot start + the schedule's window span. */
windowEnd: string;
}
reminderGraceMinutes: number | null;
scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
cyclicOnWeeks: number | null;
cyclicOffWeeks: number | null;
/** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */
doseWindows: unknown;
};
const MINUTES_PER_DAY = 24 * 60;
/** Parse "HH:mm" into minute-of-day. */
function hhmmToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */
function minutesToHHmm(minutes: number): string {
const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) %
MINUTES_PER_DAY;
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
/**
* v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the
* band minter consumes. A legacy daily row carrying only `windowStart`
* surfaces it as the single `timeOfDay` so the minter mints its daily band.
*/
function toCanonicalForBands(
s: MedicationScheduleRow,
oneShot: boolean,
): CanonicalSchedule {
const base: CanonicalSchedule = {
id: s.id,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
timesOfDay: s.timesOfDay,
daysOfWeek: s.daysOfWeek,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: normaliseDoseWindows(s.doseWindows),
};
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
!oneShot
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
}
/**
* v1.8.1 B15 follow-on — expand every schedule into its effective dose
* slots so timing classification matches against the right time-of-day.
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
*
* Candidate slot times for a schedule are its `timesOfDay` when non-empty,
* else `[windowStart]` (so single-time schedules collapse to one slot at
* `windowStart` and stay byte-identical to the pre-fix matcher). Each
* slot's punctuality window is anchored at the slot time and spans the
* schedule's `windowEnd − windowStart` duration; overnight windows
* (`windowEnd ≤ windowStart`) add a day to the span before deriving the
* per-slot end. Multi-schedule meds union their slots — the matcher then
* picks the single closest slot across all of them.
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
* take — it counts as `taken` AND adds its own `expected` slot (so the
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
* reads on-time (a logged dose colours green).
*/
function buildSlotCandidates(
schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[],
): SlotWindow[] {
const slots: SlotWindow[] = [];
for (const sched of schedules) {
const startMin = hhmmToMinutes(sched.windowStart);
let endMin = hhmmToMinutes(sched.windowEnd);
// Overnight window: the end is on the following day.
if (endMin <= startMin) endMin += MINUTES_PER_DAY;
const spanMinutes = endMin - startMin;
const times =
sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart];
for (const time of times) {
const slotStartMin = hhmmToMinutes(time);
slots.push({
startMinutes: slotStartMin,
windowStart: minutesToHHmm(slotStartMin),
// `classifyIntakeTiming` re-detects the overnight wrap from the
// HH:mm pair, so a wrapped end formats back into 0..1439 cleanly.
windowEnd: minutesToHHmm(slotStartMin + spanMinutes),
});
}
function bucketLedgerRow(
entry: DailyComplianceEntry,
row: DoseHistoryRow,
): void {
switch (row.status) {
case "taken_on_time":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
case "taken_late":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.late++;
break;
case "missed":
entry.expected++;
entry.expectedCount++;
break;
case "skipped":
entry.expected++;
entry.expectedCount++;
entry.skipped++;
break;
case "upcoming":
// A future / still-takeable slot is due but not yet acted on. It counts
// toward the day's expected/due grid but not toward taken or missed.
entry.expected++;
entry.expectedCount++;
break;
case "ad_hoc":
// An off-schedule take: a real taken dose with no scheduled slot. Count
// it as taken + its own expected slot so the heatmap missed math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
}
return slots;
}
export const GET = apiHandler(
@@ -165,134 +214,103 @@ export const GET = apiHandler(
{ now },
);
// Build daily compliance map for heatmap/line chart (90 days)
// v1.15.18 — the heatmap / line-chart daily map is now bucketed from the
// ONE unified dose-history ledger (the same bands the % + the history view
// read), so the per-day timing split (on-time / late) and the per-day
// missed marks can never disagree with the headline rate. The legacy
// per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount
// ForDay` walk are retired here in favour of band membership: a take inside
// a slot's on-time band reads on-time, inside its late tail reads late,
// outside every band reads ad-hoc; an unfilled slot past its miss cutoff
// reads missed; a future slot is upcoming (not yet due).
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
for (let d = 0; d < 90; d++) {
// v1.7.0 code-correctness M1 — anchor each day's [start,end) on the
// user-tz local-day boundary, not a UTC-midnight slice. The engine
// applies `timesOfDay` in the user timezone, so for tz-distant users
// a UTC slice could attach `due` / `expectedCount` to the adjacent
// calendar cell. Anchor on a noon-local representative instant
// (dodges the DST midnight-ambiguity edge) and key off the user-tz
// day so the heatmap cell, the engine frame, and `dateKey` agree.
const representative = new Date(
now.getTime() - d * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000,
);
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
);
// `getUserTodayBounds` returns an inclusive end (local 23:59:59.999);
// the slicing loops below use a half-open `[dayStart, dayEnd)`.
const dayEnd = new Date(dayEndInclusive.getTime() + 1);
// Build the bands across the whole 90-day heatmap window once. The window
// floor clamps to the medication's creation so pre-existence days never
// mint phantom slots.
const DAY_MS = 24 * 60 * 60 * 1000;
const windowFrom = new Date(
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
);
const bandMedication: BandMinterMedication = {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
};
const bandCtx: RecurrenceContext = {
medication: {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
},
timeZone: userTz,
lastIntakeAt,
};
const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map(
(s) => toCanonicalForBands(s, medication.oneShot),
);
const intakeInstants = mapped
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
medication: bandMedication,
schedules: canonicalSchedules,
ctx: bandCtx,
userTz,
range: { from: windowFrom, to: now },
now,
intakeInstants,
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const historyIntakes: HistoryIntake[] = mapped
.filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
}));
const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now);
// Skip days before medication was created
if (dayEnd <= createdAt) continue;
const dateKey = userDayKey(dayStart, userTz);
const dayEvents = mapped.filter(
(e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd,
);
const takenEvents = dayEvents.filter(
(e) => e.takenAt !== null && !e.skipped,
);
// Classify timing for each taken event against the best-matching schedule
let onTime = 0;
let late = 0;
let veryLate = 0;
let early = 0;
// v1.8.1 B15 follow-on — match each taken event to the closest
// effective time-of-day SLOT, not merely `windowStart`. A single
// schedule row carrying `timesOfDay = ["07:00","19:00"]` is two
// distinct dose slots; matching the evening dose against the lone
// `windowStart` "07:00" mis-classified it as `very_late` (~12h late)
// and painted the heatmap cell orange even when both doses were
// taken on time. Candidate slots for a schedule are its `timesOfDay`
// when non-empty, else `[windowStart]` — so single-time schedules
// collapse to exactly one candidate at `windowStart` and stay
// byte-identical to the pre-fix behaviour. The per-slot window
// mirrors the projector / cadence anchor: effective windowStart is
// the slot time, effective windowEnd is the slot time plus the
// schedule's `windowEnd − windowStart` span (overnight spans where
// `windowEnd ≤ windowStart` add a day).
const slotCandidates = buildSlotCandidates(medication.schedules);
for (const evt of takenEvents) {
if (slotCandidates.length === 0) {
// No schedule info: treat all taken as on_time
onTime++;
continue;
}
// Match event to the closest slot window by scheduledFor time.
const evtHour = evt.scheduledFor.getUTCHours();
const evtMin = evt.scheduledFor.getUTCMinutes();
const evtMinutes = evtHour * 60 + evtMin;
let bestSlot = slotCandidates[0];
let bestDist = Infinity;
for (const slot of slotCandidates) {
const dist = Math.abs(evtMinutes - slot.startMinutes);
if (dist < bestDist) {
bestDist = dist;
bestSlot = slot;
}
}
const timing = classifyIntakeTiming(
evt.takenAt,
bestSlot.windowStart,
bestSlot.windowEnd,
dayStart, // the scheduled date
);
// v1.4.34 IW-C — `early` is the new compliant bucket; it counts
// alongside `onTime` for the heatmap so a proactive logger reads
// green. The classifier still emits a distinct `"early"` value
// for downstream consumers that want to differentiate; the
// separate counter is surfaced on the daily entry below.
if (timing === "on_time") onTime++;
else if (timing === "early") early++;
else if (timing === "late") late++;
else veryLate++;
// Bucket each ledger row into its user-tz day. A slot row buckets on its
// anchor; an ad-hoc row on its real take time.
const byDay = new Map<string, DailyComplianceEntry>();
const ensureDay = (key: string): DailyComplianceEntry => {
let entry = byDay.get(key);
if (!entry) {
entry = {
expected: 0,
expectedCount: 0,
due: false,
taken: 0,
skipped: 0,
onTime: 0,
late: 0,
veryLate: 0,
early: 0,
};
byDay.set(key, entry);
}
return entry;
};
// v1.7.0 item 5 — the per-day expected count is the engine's actual
// due-slot count for THIS day, not the static schedule count. iOS
// history paints a "missed" mark only when `due === true`, so
// off-weeks / non-matching weekdays / PRN days no longer show a
// false miss. `expected` is kept populated (= expectedCount) for
// existing web consumers that read it; `due` + `expectedCount` are
// the explicit additive fields iOS keys off.
const expectedCount = expectedSlotCountForDay(
medication.schedules,
dayStart,
dayEnd,
medicationContext,
// v1.13.x — pass the intake history so a ROLLING schedule's per-day
// `due` flags come from the retrospective grid (each logged dose is a
// due day) instead of the engine's single forward slot — keeping the
// heatmap, the denominator, and the displayed rate in agreement.
mapped,
);
for (const row of ledgerRows) {
const key = userDayKey(row.at, userTz);
const entry = ensureDay(key);
bucketLedgerRow(entry, row);
}
dailyCompliance[dateKey] = {
expected: expectedCount,
expectedCount,
due: expectedCount > 0,
taken: takenEvents.length,
skipped: dayEvents.filter((e) => e.skipped).length,
onTime: onTime + early,
late,
veryLate,
early,
};
for (const [key, entry] of byDay) {
entry.due = entry.expectedCount > 0;
dailyCompliance[key] = entry;
}
annotate({
@@ -0,0 +1,211 @@
/**
* v1.15.18 — GET /api/medications/[id]/dose-history.
*
* The endpoint the medication "Verlauf" tab consumes. Pins the contract:
* - ownership-scoped (404 from the shared helper) + rate-limited (429);
* - reads only `deletedAt: null` rows;
* - returns the unified dose-history ledger (every expected slot with a
* status + ad-hoc takes), built from the SAME bands the compliance % uses,
* so the history view can never disagree with the rate;
* - serialises instants as ISO strings (iOS-safe additive shape).
*
* The band attribution itself is covered exhaustively by the pure-engine
* suites (`band-minter` / `attribution` / `dose-history` / `attribute-intake`);
* this file pins the route's wiring + envelope.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
role: "USER" as const,
timezone: TZ,
},
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function getReq(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/medications/med-1/dose-history${query}`,
{ method: "GET" },
);
}
function at(dayRef: Date, h: number, m: number): Date {
return localHmAsUtc(dayRef, TZ, h, m);
}
const DAY = new Date("2026-06-05T12:00:00Z"); // a couple days in the past
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-1",
windowStart: "07:00",
windowEnd: "07:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
});
describe("GET /api/medications/[id]/dose-history", () => {
it("returns the 404 from the shared ownership helper", async () => {
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
new Response(null, { status: 404 }) as never,
);
const res = await GET(getReq(), ROUTE_PARAMS);
expect(res.status).toBe(404);
});
it("returns 429 when the per-user cap is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await GET(getReq(), ROUTE_PARAMS);
expect(res.status).toBe(429);
});
it("reads only deletedAt:null rows", async () => {
await GET(getReq(), ROUTE_PARAMS);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toMatchObject({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
});
});
it("attributes an on-time take to its slot and orphans an off-window take", async () => {
const from = at(DAY, 0, 0).toISOString();
const to = at(DAY, 23, 59).toISOString();
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
// on-time morning take
{
id: "evt-morning",
scheduledFor: at(DAY, 7, 0),
takenAt: at(DAY, 7, 5),
skipped: false,
autoMissed: false,
},
// off-window midday take (was the ±6h-snapped "07:00 dose")
{
id: "evt-adhoc",
scheduledFor: at(DAY, 11, 29),
takenAt: at(DAY, 11, 29),
skipped: false,
autoMissed: false,
},
] as never);
const res = await GET(
getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
const rows = json.data.rows as Array<{
kind: string;
timeOfDay: string | null;
status: string;
intake: { id: string | null } | null;
}>;
// 07:00 slot taken on-time; 19:00 slot missed (it is in the past); the
// midday take is ad-hoc, NOT snapped onto a slot.
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.status).toBe("taken_on_time");
expect(morning?.intake?.id).toBe("evt-morning");
const evening = rows.find((r) => r.timeOfDay === "19:00");
expect(evening?.status).toBe("missed");
const adHoc = rows.find((r) => r.kind === "ad_hoc");
expect(adHoc?.status).toBe("ad_hoc");
expect(adHoc?.intake?.id).toBe("evt-adhoc");
});
it("422 on a reversed from/to window", async () => {
const from = at(DAY, 12, 0).toISOString();
const to = at(DAY, 6, 0).toISOString();
const res = await GET(
getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
ROUTE_PARAMS,
);
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,266 @@
/**
* v1.15.18 — traceable dose-history read endpoint (spec B; audit deliverable).
*
* GET /api/medications/[id]/dose-history?from=&to=
* → the full per-slot ledger for the medication over [from, to]: every
* expected slot with a status (taken on-time / taken late / skipped /
* missed / upcoming) plus every off-schedule intake tagged ad-hoc.
*
* This is the read-model the medication "Verlauf" tab renders. It is built
* from the SAME shared band minter + `reconstructDoseHistory` that the
* compliance % and the write/edit attribution consume, so the history view and
* the rate can never contradict each other. Additive (a new GET) → iOS-safe.
*
* Ownership-scoped via `assertMedicationOwnership`; rate-limited; reads only
* `deletedAt: null` rows.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandFamily,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import {
reconstructDoseHistory,
type DoseHistoryRow,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
type WorkerScheduleRow,
} from "@/lib/medications/scheduling/worker-helpers";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
const DAY_MS = 24 * 60 * 60 * 1000;
const MAX_WINDOW_DAYS = 366;
// Match the glp1 POST / bulk-delete cap so a caller can't pin the per-request
// band expansion. 60/min/user is generous for a hand-driven history view.
const RATE_LIMIT = 60;
const RATE_WINDOW_MS = 60_000;
const querySchema = z
.object({
from: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
to: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
})
.refine((q) => !q.from || !q.to || q.to.getTime() >= q.from.getTime(), {
message: "`to` must be on or after `from`",
path: ["to"],
});
/** The serialised shape the Verlauf tab consumes (instants as ISO strings). */
interface SerializedDoseHistoryRow {
kind: "slot" | "ad_hoc";
at: string;
timeOfDay: string | null;
status: DoseHistoryRow["status"];
intake: {
id: string | null;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
autoMissed: boolean;
} | null;
}
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const rl = await checkRateLimit(
`medication-dose-history:${user.id}`,
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const now = new Date();
const userTz = user.timezone || "Europe/Berlin";
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (!medication) {
return apiError("Medication not found", 404);
}
// Resolve the window. Default to the trailing 90 days; clamp the floor to
// the medication's creation so pre-existence days never mint phantom
// slots, and cap the span so a pathological `from=1970` can't expand a
// year-plus of bands. `to` defaults to `now`.
const to = parsed.data.to ?? now;
const requestedFrom =
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
const from = new Date(
Math.max(
requestedFrom.getTime(),
medication.createdAt.getTime(),
spanFloor.getTime(),
),
);
const events = await prisma.medicationIntakeEvent.findMany({
where: { medicationId: id, userId: user.id, deletedAt: null },
orderBy: { scheduledFor: "desc" },
select: {
id: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
},
});
const mapped = events.map((e) => ({
id: e.id,
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
const bandMedication: BandMinterMedication = {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
};
const ctx = buildRecurrenceContext({
medication: bandMedication,
userTz,
lastIntakeAt,
});
// A legacy daily schedule carrying only `windowStart` surfaces it as the
// single time-of-day so the minter mints its daily band (mirrors the
// compliance route + the ledger tally).
const canonicalSchedules = medication.schedules.map((s) => {
const canonical = buildCanonicalSchedule(s as WorkerScheduleRow);
if (
canonical.timesOfDay.length === 0 &&
canonical.rrule === null &&
canonical.rollingIntervalDays === null &&
canonical.scheduleType !== "PRN" &&
!medication.oneShot
) {
return { ...canonical, timesOfDay: [canonical.windowStart] };
}
return canonical;
});
// Rolling cadences anchor their retrospective grid AT each logged intake.
const intakeInstants = mapped
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
medication: bandMedication,
schedules: canonicalSchedules,
ctx,
userTz,
range: { from, to },
now,
intakeInstants,
});
const bands: SlotBand[] = [];
let family: BandFamily = "none";
for (const g of groups) {
if (g.hasExpectedSlots) {
bands.push(...g.bands);
if (family === "none") family = g.family;
}
}
// Intakes whose slot anchor falls in the window. An ad-hoc take buckets on
// its real time, but the ledger reads `scheduledFor` for the membership
// partition, so filter on the stored anchor (a take snapped to an
// out-of-window slot is excluded, matching the compliance read).
const historyIntakes: HistoryIntake[] = mapped
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
.map((e) => ({
id: e.id,
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
}));
const rows = reconstructDoseHistory(bands, historyIntakes, now);
const serialized: SerializedDoseHistoryRow[] = rows.map((row) => ({
kind: row.kind,
at: row.at.toISOString(),
timeOfDay: row.timeOfDay,
status: row.status,
intake: row.intake
? {
id: row.intake.id ?? null,
scheduledFor: row.intake.scheduledFor.toISOString(),
takenAt: row.intake.takenAt?.toISOString() ?? null,
skipped: row.intake.skipped,
autoMissed: row.intake.autoMissed ?? false,
}
: null,
}));
annotate({
action: {
name: "medication.dose_history",
entity_type: "medication",
entity_id: id,
},
meta: { row_count: serialized.length, family },
});
return apiSuccess({
from: from.toISOString(),
to: to.toISOString(),
family,
hasExpectedSlots: bands.length > 0,
rows: serialized,
});
},
);
@@ -12,10 +12,15 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
findFirst: vi.fn(),
// v1.15.18 — the band resolver + the canonical slot upsert read these.
findMany: vi.fn(),
create: vi.fn(),
},
medication: {
findUnique: vi.fn(),
findFirst: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
},
@@ -250,3 +255,129 @@ describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on s
});
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.18 — PUT re-runs window-band slot attribution on a takenAt change
// ────────────────────────────────────────────────────────────────────
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
const SESSION_TZ = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const, timezone: TZ },
};
const ATTR_DAY = new Date("2026-06-05T12:00:00Z");
function at(h: number, m: number): Date {
return localHmAsUtc(ATTR_DAY, TZ, h, m);
}
/** Stub the band resolver's medication load (07:00 / 19:00 twice-daily). */
function stubTwiceDailyMed() {
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
id: "m1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "s1",
windowStart: "07:00",
windowEnd: "07:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
}
describe("PUT — v1.15.18 band re-attribution", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_TZ as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
oneShot: false,
active: true,
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
stubTwiceDailyMed();
});
it("re-snaps the dose onto the matched slot when the edited takenAt moves it (slot move)", async () => {
// The row currently sits on the 07:00 slot; the edit moves takenAt to
// 19:05 (on-time for the 19:00 slot). The route tombstones the row + routes
// the dose through the canonical slot upsert onto the 19:00 anchor.
vi.mocked(prisma.medicationIntakeEvent.findFirst)
// PUT lookup of the edited event
.mockResolvedValueOnce({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: at(7, 0),
takenAt: at(7, 5),
skipped: false,
} as never)
// lifecycle liveIntake probe
.mockResolvedValue(null as never);
// applyCanonicalSlotWrite: no existing row at the 19:00 slot → create.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
{} as never,
);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
id: "e2",
userId: "user-1",
medicationId: "m1",
scheduledFor: at(19, 0),
takenAt: at(19, 5),
skipped: false,
} as never);
const res = await PUT(
putReq({ takenAt: at(19, 5).toISOString() }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
// The original row was tombstoned (deletedAt set).
const tombstone = vi
.mocked(prisma.medicationIntakeEvent.update)
.mock.calls.find((c) => "deletedAt" in (c[0]?.data ?? {}));
expect(tombstone).toBeTruthy();
// The corrected dose was written onto the 19:00 anchor.
const created = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0]?.[0]?.data;
expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime());
});
it("422s a forceSlotInstant that is not a real slot", async () => {
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: at(7, 0),
takenAt: at(7, 5),
skipped: false,
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const res = await PUT(
putReq({
takenAt: at(11, 29).toISOString(),
forceSlotInstant: at(11, 29).toISOString(), // not a slot
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
@@ -14,6 +14,11 @@ import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -70,20 +75,105 @@ export const PUT = apiHandler(
}
const data = parsed.data;
const updated = await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
...(data.scheduledFor !== undefined && {
scheduledFor: data.scheduledFor,
}),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
syncVersion: { increment: 1 },
},
});
// The post-edit state of the row (current values where the body omits a
// field) — drives the re-attribution decision.
const nextTakenAt =
data.takenAt !== undefined ? data.takenAt : event.takenAt;
const nextSkipped =
data.skipped !== undefined ? data.skipped : event.skipped;
const takenOrSkippedChanged =
(data.takenAt !== undefined &&
data.takenAt?.getTime() !== event.takenAt?.getTime()) ||
(data.skipped !== undefined && data.skipped !== event.skipped);
// v1.15.18 — re-run window-band attribution when the edit moves `takenAt`
// or `skipped`, so an edited time re-binds to the correct slot instead of
// leaving `scheduledFor` stale (audit HIGH-4). Precedence:
// 1. an explicit `scheduledFor` in the body wins (deliberate override);
// 2. else `forceSlotInstant` pins onto a named real slot (422 if it is
// not a slot of this med);
// 3. else a taken edit attributes by band (slot anchor on match, the
// take's own time on a miss → ad-hoc);
// 4. a skip / pending edit keeps the existing `scheduledFor` anchor.
let resolvedScheduledFor: Date | undefined = data.scheduledFor;
if (resolvedScheduledFor === undefined && takenOrSkippedChanged) {
if (data.forceSlotInstant !== undefined) {
const forced = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
slotInstant: data.forceSlotInstant,
});
if (forced === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: id, event_id: eventId },
});
return apiError(
"forceSlotInstant is not a scheduled slot of this medication",
422,
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
resolvedScheduledFor = forced;
} else if (nextTakenAt !== null && !nextSkipped) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: nextTakenAt,
});
// band.at on match; the take's own time on a miss (ad-hoc).
resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt;
}
// skip / pending edits leave `scheduledFor` on the existing anchor.
}
const targetScheduledFor = resolvedScheduledFor ?? event.scheduledFor;
const slotMoved =
targetScheduledFor.getTime() !== event.scheduledFor.getTime();
let updated;
if (!slotMoved) {
// The slot is unchanged — a bare in-place update can never collide with
// a sibling row (it is its own slot).
updated = await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
syncVersion: { increment: 1 },
},
});
} else {
// The edit moved the dose to a different slot. Tombstone the original
// row (the iOS "a correction is a tombstone + re-insert" model the
// DELETE handler documents) and route the corrected dose through the
// shared canonical-slot upsert, which converges onto any row already at
// the target slot (a pending REMINDER row) rather than bare-updating
// into an occupied slot and risking a P2002 (audit HIGH-4).
await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: id,
canonicalSlot: targetScheduledFor,
takenAt: nextSkipped ? null : nextTakenAt,
skipped: nextSkipped,
isExplicitTaken: !nextSkipped && nextTakenAt !== null,
isExplicitSkip: nextSkipped,
idempotencyKey: null,
createSource: "WEB",
});
updated = applied.row;
}
await auditLog("medication.intake.update", {
userId: user.id,
@@ -105,23 +195,20 @@ export const PUT = apiHandler(
invalidateUserMedications(user.id);
// v1.4.39 W-MED — refresh the rollup row for both the old + new
// scheduledFor day-keys when the timestamp moved across a day
// boundary; if only takenAt / skipped changed the second call
// collapses to a no-op upsert.
// scheduledFor day-keys when the dose moved across a day boundary
// (band re-attribution can move it as well as an explicit edit); if the
// slot is unchanged the second call collapses to a no-op upsert.
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
tz: user.timezone,
});
if (
data.scheduledFor !== undefined &&
data.scheduledFor.getTime() !== event.scheduledFor.getTime()
) {
if (slotMoved) {
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: data.scheduledFor,
scheduledFor: targetScheduledFor,
tz: user.timezone,
});
}
@@ -96,7 +96,14 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: {
id: "user-1",
username: "marc",
role: "USER" as const,
// Pin the user timezone so slot-instant resolution is deterministic
// regardless of the host TZ (CI runs in UTC, local in Europe/Berlin).
timezone: "Europe/Berlin",
},
};
const MED_OK = { id: "med-1", userId: "user-1" };
@@ -25,7 +25,8 @@ vi.mock("@/lib/db", () => ({
medication: { findUnique: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
deleteMany: vi.fn(),
// v1.15.18 LOW-6 — bulk delete is now a soft-delete updateMany.
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
@@ -115,7 +116,7 @@ beforeEach(() => {
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValue({
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
@@ -125,7 +126,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
it("422 on missing body", async () => {
const res = await POST(postWithBody({}), ROUTE_PARAMS);
expect(res.status).toBe(422);
expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("422 on empty eventIds", async () => {
@@ -156,7 +157,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toEqual({ deleted: 0 });
expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled();
});
@@ -168,7 +169,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
{ id: "evt-b", scheduledFor: day1 },
{ id: "evt-c", scheduledFor: day2 },
] as never);
vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 3,
} as never);
@@ -185,23 +186,31 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
});
it("scopes deleteMany by both userId and medicationId", async () => {
it("soft-deletes scoped by userId + medicationId + deletedAt:null", async () => {
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "evt-a", scheduledFor: day1 },
] as never);
vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 1,
} as never);
await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
const deleteArg = vi.mocked(prisma.medicationIntakeEvent.deleteMany).mock
.calls[0][0]?.where;
expect(deleteArg).toEqual({
// v1.15.18 LOW-6 — a bulk delete is a soft-delete updateMany: scoped by
// (id IN, user, medication, deletedAt:null) so a re-post counts zero, and
// the data sets deletedAt + bumps syncVersion for the iOS tombstone feed.
const call = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0];
expect(call?.where).toEqual({
id: { in: ["evt-a"] },
userId: "user-1",
medicationId: "med-1",
deletedAt: null,
});
expect(call?.data).toMatchObject({
deletedAt: expect.any(Date),
syncVersion: { increment: 1 },
});
});
});
@@ -220,7 +229,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("scopes the bucket key to the calling user", async () => {
@@ -116,11 +116,24 @@ export const POST = apiHandler(
dayKeysToRefresh.add(dayKeyForScheduledFor(row.scheduledFor, tz));
}
const { count } = await prisma.medicationIntakeEvent.deleteMany({
// v1.15.18 LOW-6 — soft-delete to match the single-event DELETE handler
// and the iOS tombstone contract: an intake is an immutable fact, so a
// bulk "delete" sets `deletedAt` (+ bumps `syncVersion`) so the
// `/api/sync/changes` feed surfaces each removal as a tombstone keyed on
// the server id to clients offline at delete time. Every today /
// compliance / list read filters `deletedAt: null`, so the rows are
// invisible to normal reads from here on. Scope `deletedAt: null` so a
// re-post of already-tombstoned ids counts zero rather than re-bumping.
const { count } = await prisma.medicationIntakeEvent.updateMany({
where: {
id: { in: targetRows.map((r) => r.id) },
userId: user.id,
medicationId: id,
deletedAt: null,
},
data: {
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
});
+64 -27
View File
@@ -24,6 +24,8 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
@@ -79,8 +81,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
return returnAllZodIssues(parsed.error, 422);
}
const { scheduledFor, takenAt, skipped, idempotencyKey, injectionSite } =
parsed.data;
const {
scheduledFor,
takenAt,
skipped,
idempotencyKey,
injectionSite,
forceSlotInstant,
} = parsed.data;
// v1.8.5 — resolve + server-validate the optional injection site. Load
// the medication's delivery form + tracking opt-in + per-medication
@@ -137,32 +145,61 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const isExplicitTaken = !skipped;
const isExplicitSkip = skipped === true;
// v1.8.2 — source-agnostic slot snap. A twice-daily med carries a
// pending REMINDER row the projector/worker minted at the canonical
// `localHmAsUtc` slot instant. Without this, a manual "Genommen" write
// (source WEB) inserted a SECOND row for the slot because the unique
// key includes `source` and the iOS-vs-server `scheduledFor` can drift
// by a minute — inflating compliance to 100% and suppressing the
// "take now" prompt for a dose the user hadn't taken. Snap the write
// to the canonical slot and update the existing row in place.
// v1.15.18 — window-band slot attribution (replaces the wide ±6h
// `snapToleranceMs` nearest-snap). The take is bound to a slot by
// membership in that slot's configurable dose window — the SAME bands the
// read ledger + the compliance % consume, so the three surfaces can never
// disagree. A take that lands in no window is ad-hoc (`canonicalSlot` null)
// and records as a standalone "taken now" row; PRN meds always insert
// standalone. Resolved BEFORE the idempotency/dedup window so a scheduled
// dose routes through the slot upsert (which is itself the dedup).
//
// Resolved BEFORE the idempotency/dedup window so a scheduled dose
// routes through the slot upsert (which is itself the dedup): the
// legacy 60-second window would otherwise short-circuit by returning
// the slot's pending REMINDER row WITHOUT applying the user's takenAt.
const canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
// Only a client-supplied `scheduledFor` names a real slot. A
// `takenAt`-only / defaulted-now write must not snap across the wide
// ±halfGap window onto a far slot (phantom morning dose).
instantIsExplicit: scheduledFor !== undefined,
// Dose-safety: a taken write (any non-skip on this route) must never
// snap forward onto a future slot — see resolve-slot-instant.ts.
isTakenWrite: isExplicitTaken,
});
// - SKIP: a skip is logged against a slot deliberately (it carries no
// `takenAt` to attribute by), so it keeps the canonical `scheduledFor`
// snap that binds it to the slot's pending REMINDER row.
// - TAKEN: attribute by `takenAt` band membership. The optional
// `forceSlotInstant` pins an off-window take onto a chosen real slot
// ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422.
let canonicalSlot: Date | null = null;
if (skipped) {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: scheduledFor !== undefined,
isTakenWrite: false,
});
} else if (forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
slotInstant: forceSlotInstant,
});
if (canonicalSlot === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: id },
});
return apiError(
"forceSlotInstant is not a scheduled slot of this medication",
422,
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
} else {
// A non-skip write on this route always carries a `takenAt` (defaulted to
// now), so `resolvedTakenAt` is non-null here; the fallback only guards the
// type.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: resolvedTakenAt ?? incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
}
// Idempotency check (explicit key or server-side dedup window)
if (idempotencyKey) {
+4
View File
@@ -318,6 +318,10 @@ export const PUT = apiHandler(
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
// replace re-creates the rows, so the column is re-written each
// time; absent leaves it NULL (default ±1h derivation).
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
};
}),
},
+101 -18
View File
@@ -36,6 +36,11 @@ import {
} from "@/lib/analytics/compliance";
import { getUserTodayBounds } from "@/lib/timezone";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
injectionSiteEnum,
@@ -62,6 +67,14 @@ const updateSchema = z.object({
// status toggle. Validated server-side against the medication's
// effective allowed set; ignored for skipped / snoozed.
injectionSite: injectionSiteEnum.optional(),
// v1.15.18 — late-take "attribute anyway" pin. When a "taken" toggle lands
// outside every dose window the UI can offer to pin the take onto a chosen
// real slot ("diesem Slot zuordnen?"); the server validates the instant is
// a real slot of the medication (422 otherwise). Ignored for skip / snooze.
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
});
function startOfDayInTz(date: Date, tz: string): Date {
@@ -380,7 +393,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { intakeId, status, takenAt, snoozedUntil, injectionSite } =
const { intakeId, status, takenAt, snoozedUntil, injectionSite, forceSlotInstant } =
parsed.data;
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
@@ -436,26 +449,96 @@ export const POST = apiHandler(async (request: NextRequest) => {
let updated;
if (status === "taken") {
[updated] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
const resolvedTakenAt = takenAt ?? new Date();
// v1.15.18 — re-run window-band attribution on a taken toggle so an
// edited / off-window take re-binds to the right slot instead of leaving
// `scheduledFor` stale (audit HIGH-4). `forceSlotInstant` pins onto a
// named real slot (422 if it is not one); otherwise band membership picks
// the slot (the take's own time on a miss → ad-hoc). Mirrors the
// per-event PUT route.
let targetScheduledFor: Date;
if (forceSlotInstant !== undefined) {
const forced = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: existing.medicationId,
userTz: userTzForHook,
slotInstant: forceSlotInstant,
});
if (forced === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: existing.medicationId, intake_id: intakeId },
});
return apiError(
"forceSlotInstant is not a scheduled slot of this medication",
422,
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
targetScheduledFor = forced;
} else {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: existing.medicationId,
userTz: userTzForHook,
takenAt: resolvedTakenAt,
});
targetScheduledFor = attribution.slotInstant ?? resolvedTakenAt;
}
const slotMoved =
targetScheduledFor.getTime() !== existing.scheduledFor.getTime();
if (!slotMoved) {
[updated] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the delta feed echoes a monotonic value.
data: {
takenAt: resolvedTakenAt,
skipped: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: null },
}),
]);
} else {
// The take re-attributed to a different slot. Tombstone the source row
// and route the dose through the shared canonical-slot upsert, which
// converges onto any row already at the target slot rather than
// bare-updating into an occupied slot (P2002-safe).
await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the delta feed echoes a monotonic value.
data: {
takenAt: takenAt ?? new Date(),
skipped: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
prisma.medication.update({
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: existing.medicationId,
canonicalSlot: targetScheduledFor,
takenAt: resolvedTakenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
injectionSite: resolvedInjectionSite,
});
updated = applied.row;
await prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: null },
}),
]);
});
}
} else if (status === "skipped") {
updated = await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
+4
View File
@@ -326,6 +326,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. Stored as the
// validated `{ timeOfDay, start, end }[]` JSON; absent leaves the
// column NULL (every slot on the default ±1h derivation).
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
};
}),
},
+16 -85
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useRef, useState, type ReactNode } from "react";
import { Fragment, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -21,7 +21,6 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
@@ -283,27 +282,11 @@ export default function InsightsPage() {
// per-tile order/visibility (passed into <VitalsDashboard>). Defaults to
// the canonical layout while in-flight so the first paint matches the
// default order with no flicker.
const {
layout,
isLoading: layoutLoading,
} = useInsightsLayoutQuery(isAuthenticated);
// v1.15.11 W3 — inline "Anpassen" edit mode. When on, the customizable
// region swaps the live (heavy) sections for lightweight edit cards; the
// section data is never refetched (the queries stay mounted via the page's
// own hooks but the section JSX is replaced). HeroStrip stays anchored.
const [editMode, setEditMode] = useState(false);
// v1.15.11 QA L5 — focus restoration. On entering edit mode focus moves to
// the edit-card heading; on close/save focus returns to the "Anpassen"
// toggle so keyboard users are not dropped at the top of the document.
const customizeToggleRef = useRef<HTMLButtonElement | null>(null);
// v1.15.11 QA L1 — never let a "Fertig" save flush DEFAULT_INSIGHTS_LAYOUT
// over the user's real saved layout. The editor seeds its draft once from
// the `layout` prop, which is the default while the GET is in-flight, so the
// edit surface stays gated (the toggle disabled) until the query has settled.
const canEdit = !layoutLoading;
// v1.15.18 — the overview layout (sections + tiles) still drives the section
// render order/visibility + the Vitals grid below. CUSTOMISING it (and the
// nav-pill order) moved out of an in-page "Anpassen" edit mode into
// Settings → Insights, reached via the top-right cog on the tab strip.
const { layout } = useInsightsLayoutQuery(isAuthenticated);
// Error branch — a transient 500 / network drop (after the query's
// retries are exhausted) settles the comprehensive query with no data.
@@ -450,21 +433,6 @@ export default function InsightsPage() {
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
};
// v1.15.11 W3 — sections whose feature/data gate is currently OFF. The edit
// row for these renders disabled with a hint so a toggle that does nothing
// never confuses the user (it stays orderable, just not enable-able past the
// gate). Mirrors the `null` registry entries above: `daily-briefing` +
// `period-review` ride the briefing flag; `cycle-summary` rides the cycle
// opt-in. Every other section is always available.
const gatedOffSectionIds = new Set<InsightsSectionId>();
if (!flags.briefing) {
gatedOffSectionIds.add("daily-briefing");
gatedOffSectionIds.add("period-review");
}
if (!user?.cycleTrackingEnabled) {
gatedOffSectionIds.add("cycle-summary");
}
const orderedSectionIds = orderedVisibleSectionIds(layout);
const everySectionHidden = orderedSectionIds.length === 0;
@@ -497,58 +465,21 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
{/* v1.15.11 W3 — the "Anpassen" toggle sits at the top of the
customizable region, below the anchored HeroStrip. v1.15.11 QA
L2-design: the toggle is HIDDEN while editing — the InsightsEditMode
card owns save (Fertig) + reset (Zurücksetzen), so a second top-right
"Fertig" that only closes-without-saving (a label-collision trap) no
longer exists. The toggle only ENTERS edit mode; the component's
Fertig/Zurücksetzen → onClose returns here. QA L1: disabled until the
layout query settles so a save can never flush defaults. */}
{!editMode && (
<div className="flex justify-end">
<Button
ref={customizeToggleRef}
type="button"
variant="outline"
size="sm"
onClick={() => setEditMode(true)}
disabled={!canEdit}
data-slot="insights-customize-toggle"
aria-pressed={editMode}
className="gap-1.5"
>
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.editMode.open")}</span>
</Button>
</div>
)}
{editMode ? (
/* v1.15.11 W3 — edit mode swaps the live, heavy sections for the
lightweight edit cards. The section data queries stay mounted on the
page, but their JSX is not rendered, so toggling into edit mode never
refetches. */
<InsightsEditMode
layout={layout}
gatedOffSectionIds={gatedOffSectionIds}
onClose={() => {
setEditMode(false);
// v1.15.11 QA L5 — return focus to the toggle that reappears.
requestAnimationFrame(() => customizeToggleRef.current?.focus());
}}
/>
) : everySectionHidden ? (
/* v1.15.11 W3 — empty-state: every section hidden. The page is never
blank — surface a hint + a button that opens edit mode so the user
can bring sections back. */
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
overview (section show/hide + order) and sorting the nav pills now
live ONLY in Settings → Insights, reached via the top-right cog on the
tab strip — no duplicate in-page entry point. */}
{everySectionHidden ? (
/* v1.15.18 — empty-state: every section hidden. The page is never
blank — surface a hint + a link to the Insights settings section so
the user can bring sections back. */
<EmptyState
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
action={
<Button size="sm" onClick={() => setEditMode(true)}>
{t("insights.editMode.open")}
<Button size="sm" asChild>
<Link href="/settings/insights">{t("insights.customize")}</Link>
</Button>
}
/>
+19 -132
View File
@@ -1,153 +1,40 @@
"use client";
/**
* v1.7.0 — full intake-history view.
* v1.15.18 — legacy intake-history route.
*
* Reached directly from the detail-page header's History button. This
* surface is intake-history ONLY: the estimated drug-level curve, the
* side-effect logbook, the schedule ("Rhythmus") editor and the
* titration ladder no longer dominate here. The only editable units
* are individual intakes (edit / delete / bulk-delete via the shared
* `<IntakeHistoryEditable>`); CSV import is present but de-emphasised
* as a ghost action in the header.
*
* Sort defaults to `scheduledFor desc` so the order reads
* today → yesterday → … and skipped rows (`takenAt: null`) never float
* to the top (O-1).
*
* The estimated active-ingredient curve stays available as a
* default-CLOSED disclosure at the bottom for GLP-1 medications (O-2);
* it is genuine history context but opt-in, not the default read.
* The full intake history folded into the medication detail page's
* Verlauf tab. This route stays only as a deep-link redirect so existing
* bookmarks and any iOS deep-link keep landing on the right surface.
*/
import { useEffect, useState, use } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2, Upload } from "lucide-react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function IntakeHistoryPage({
export default function IntakeHistoryRedirectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const { t } = useTranslations();
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/auth/login");
}
}, [authLoading, isAuthenticated, router]);
const { data: medication, isLoading: medLoading } = useQuery({
queryKey: queryKeys.medicationDetail(id),
queryFn: async () => {
const res = await fetch(`/api/medications/${id}`);
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.data as {
id: string;
name: string;
dose: string;
treatmentClass?: string;
notificationsEnabled?: boolean;
};
},
enabled: isAuthenticated,
});
if (authLoading || medLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
const isGlp1 = medication?.treatmentClass === "GLP1";
router.replace(`/medications/${id}?tab=verlauf`);
}, [id, router]);
return (
<div className="space-y-6">
{/* Back to the medication detail page — history is a drill-down
OF the medication, not a sibling of the list. */}
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href={`/medications/${id}`}>
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
{t("medications.detail.history.back")}
</Link>
</Button>
{medication && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
{medication.name}
</h1>
<p className="text-muted-foreground text-sm">{medication.dose}</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.history.subtitle")}
</p>
</div>
{/* De-emphasised import — ghost / muted, not a prominent CTA. */}
<Button
variant="ghost"
size="sm"
onClick={() => setImportOpen(true)}
className="text-muted-foreground min-h-11 sm:min-h-9"
data-slot="history-import-trigger"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
</div>
)}
{medication && (
<IntakeHistoryEditable
medicationId={id}
pageSize={25}
defaultSortBy="scheduledFor"
/>
)}
{/* Estimated drug-level curve (the modelled active-ingredient
level) + dose-strength (titration) curve for GLP-1 medications.
Visible by default — the drug-level estimate is the one the
medication concerns, so it leads; the dose-strength curve plots
the user's own logged dose-change history below it. */}
{medication && isGlp1 && (
<div className="space-y-6" data-slot="history-drug-level-section">
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<DoseStrengthCurve medicationId={id} />
</div>
)}
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
<div
className="flex h-64 items-center justify-center"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2
aria-hidden="true"
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
/>
</div>
);
}
+15 -155
View File
@@ -1,29 +1,18 @@
"use client";
/**
* v1.7.2 W3 — medication detail page as the history view.
* v1.15.18 — medication detail page as the full-page tabbed shell.
*
* Supersedes the v1.5.6 composition that carried an Edit / History /
* Advanced action row and a "Rhythmus" cadence block. Editing and
* advanced settings are now reached from the medications-list card kebab
* only; the detail page is purely history-centric:
*
* back link → compact, NON-editable header (name / dose / status +
* plain-language cadence line) → intake-history table → GLP-1
* drug-level / titration as a default-CLOSED disclosure.
*
* The side-effect logbook stays inline for GLP-1 medications (genuine
* history context). CSV import rides the intake-history section header.
*
* Reads:
*
* - `medicationDetail(id)` for the medication snapshot.
* - `medicationIntakeList(id, …)` for the header total.
*
* Mutations all cascade through `medicationDependentKeys`.
* Supersedes the v1.7.2 history-centric composition. The page now owns
* auth + the medication read and hands the snapshot to
* `<MedicationDetailTabs>`, which carries the tab strip (Übersicht ·
* Zeitplan · Erinnerung · Bestand · Verlauf · Injektion* · Erweitert),
* `?tab=` URL state, the read-only hero and the "Vollständig bearbeiten"
* jump into the wizard. The former modal advanced-settings sheet and the
* separate `/history` route both fold into tabs here.
*/
import { use, useEffect, useMemo, useState } from "react";
import { use, useEffect } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
@@ -32,75 +21,13 @@ import { ArrowLeft, Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
interface ScheduleSnapshot {
id: string;
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
daysOfWeek: string | null;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
reminderGraceMinutes?: number | null;
}
interface MedicationDetailSnapshot {
id: string;
name: string;
dose: string;
category: string;
treatmentClass?: string;
deliveryForm?: string;
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
schedules: ScheduleSnapshot[];
}
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
return {
id: med.id,
name: med.name,
dose: med.dose,
category: med.category,
treatmentClass: med.treatmentClass,
deliveryForm: med.deliveryForm,
dosesPerUnit: med.dosesPerUnit ?? null,
notificationsEnabled: med.notificationsEnabled,
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
...parseScheduleRecurrence(s.daysOfWeek),
timesOfDay: s.timesOfDay,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
})),
};
}
import {
MedicationDetailTabs,
type MedicationDetailSnapshot,
} from "@/components/medications/detail/MedicationDetailTabs";
export default function MedicationDetailPage({
params,
@@ -112,8 +39,6 @@ export default function MedicationDetailPage({
const router = useRouter();
const { t } = useTranslations();
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/auth/login");
@@ -139,13 +64,6 @@ export default function MedicationDetailPage({
retry: false,
});
// v1.7.2 W3 — derive the wizard payload purely to feed the read-only
// cadence summary line (the editor itself lives on the card kebab).
const wizardPayload = useMemo<MedicationPayload | null>(
() => (medication ? snapshotToWizardPayload(medication) : null),
[medication],
);
if (authLoading || isLoading) {
return (
<div
@@ -178,70 +96,12 @@ export default function MedicationDetailPage({
data-slot="medication-detail-error-card"
>
<p className="text-destructive text-sm">
{t("medications.loadFailed")}
{t("medications.detail.shell.loadFailed")}
</p>
</Card>
</div>
);
}
const oneShot = medication.oneShot === true;
// `wizardPayload` is non-null here — the early returns above bail
// before this point whenever `medication` is undefined.
const payload = wizardPayload as MedicationPayload;
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
return (
<div className="space-y-6" data-slot="medication-detail-page">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
{t("medications.back")}
</Link>
</Button>
{/* v1.7.2 W3 — compact, NON-editable header. Editing and advanced
settings are reached from the medications-list card kebab only;
the detail page is history-centric. The header is a read-only
summary: name / dose / status + plain-language cadence line. */}
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
{/* Intake history table — the primary surface. */}
<IntakeHistoryPreview
medicationId={id}
importOpen={importOpen}
onImportOpenChange={setImportOpen}
/>
{/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications
(genuine history context). */}
{isGlp1 && <SideEffectsSection medicationId={id} />}
{/* GLP-1 estimated drug-level curve (the modelled active-ingredient
level) + dose-strength (titration) curve. Visible by default for
GLP-1 medications — the drug-level estimate is the one the
medication concerns, so it leads; the dose-strength curve plots
the user's own logged dose-change history below it. */}
{isGlp1 && (
<div className="space-y-6" data-slot="detail-drug-level-section">
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<DoseStrengthCurve medicationId={id} />
</div>
)}
</div>
);
return <MedicationDetailTabs medication={medication} />;
}
@@ -1,19 +1,15 @@
/**
* v1.7.2 W3 — the `/medications/[id]` detail page IS the history view.
* v1.15.18 — the `/medications/[id]` detail page is the tabbed shell.
*
* Editing and advanced settings are reached from the medications-list
* card kebab only; the detail page is purely history-centric. Source-
* level guards (the page is a client component with `use()` + hooks, so
* a full render is heavier than the contract warrants):
* - no `TodaysDoseCard` import or render (lives only on the list page);
* - no `isToday` / `todayEvent` derivations (dead once the card is
* gone);
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
* plumbing;
* - the page no longer hosts the wizard, the `AdvancedSettingsSheet`,
* the `MedicationDetailHeader` action row, or an editable cadence
* ("Rhythmus") block — those moved to the card kebab. It renders the
* read-only `MedicationDetailSummary` + the intake-history table.
* The page owns auth + the medication read and delegates the rest to
* `<MedicationDetailTabs>` (the tab strip, `?tab=` URL state, the hero
* and the wizard / advanced affordances). Source-level guards (the page
* is a client component with `use()` + hooks, so a full render is
* heavier than the contract warrants):
* - it renders `MedicationDetailTabs` and nothing tab-specific itself;
* - it carries no `TodaysDoseCard` (lives only on the list page);
* - it hosts neither the wizard nor the modal advanced sheet directly
* (those live inside the tab component).
*/
import { readFileSync } from "node:fs";
@@ -25,35 +21,18 @@ const source = readFileSync(
"utf8",
);
describe("medication detail page is the history view (W3)", () => {
describe("medication detail page is the tabbed shell (v1.15.18)", () => {
it("delegates to MedicationDetailTabs", () => {
expect(source).toContain("MedicationDetailTabs");
});
it("does not import or render TodaysDoseCard", () => {
expect(source).not.toContain("TodaysDoseCard");
expect(source).not.toContain("todays-dose-card");
});
it("drops the dead isToday / todayEvent derivations", () => {
expect(source).not.toContain("isToday");
expect(source).not.toContain("todayEvent");
});
it("drops the landingIntent plumbing", () => {
expect(source).not.toContain("landingIntent");
expect(source).not.toContain("wizardIntent");
expect(source).not.toContain("openWizardWithIntent");
});
it("no longer hosts the wizard, advanced sheet, detail-header, or cadence row", () => {
// v1.7.2 W3 — editing + advanced moved to the medications-list card
// kebab; the detail page carries none of those affordances.
it("does not host the wizard or the modal advanced sheet directly", () => {
expect(source).not.toContain("AdvancedSettingsSheet");
expect(source).not.toContain("MedicationWizardDialog");
expect(source).not.toContain("MedicationDetailHeader");
expect(source).not.toContain("CadenceSummaryRow");
expect(source).not.toContain("hideEdit");
});
it("renders the read-only summary header + the intake-history table", () => {
expect(source).toContain("MedicationDetailSummary");
expect(source).toContain("IntakeHistoryPreview");
});
});
+14 -117
View File
@@ -3,13 +3,10 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
import { LogIntakeDialog } from "@/components/medications/log-intake-dialog";
@@ -130,12 +127,6 @@ export default function MedicationsPage() {
// the param from the URL so a manual close + refresh stays closed.
const shouldOpenFromUrl = searchParams?.get("new") === "1";
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
const [editingMed, setEditingMed] = useState<Medication | null>(null);
// v1.7.1 — the medications-card History + Advanced actions. History
// navigates to the full intake-history view; Advanced opens one shared
// `<AdvancedSettingsSheet>` mounted below, keyed to the selected
// medication (reused, not duplicated per card).
const [advancedMed, setAdvancedMed] = useState<Medication | null>(null);
// v1.14.0 — the medications-page "Add" choice. The top button now offers
// two paths: log an intake (incl. a backdated one) against an existing
// medication, or create a new medication (the existing wizard).
@@ -165,61 +156,29 @@ export default function MedicationsPage() {
enabled: isAuthenticated,
});
// v1.7.1 — the advanced sheet's danger zone needs `intakeCount` to
// gate the purge button. Fetch it lazily once a medication is selected
// (the list view never pays for it otherwise), mirroring the
// detail-page query shape.
const advancedIntakeParams = {
sortBy: "takenAt",
sortDir: "desc" as const,
limit: 1,
offset: 0,
status: "completed",
};
const { data: advancedIntakeCount } = useQuery({
queryKey: advancedMed
? queryKeys.medicationIntakeList(advancedMed.id, advancedIntakeParams)
: ["medications", "intake-count", "none"],
queryFn: async () => {
if (!advancedMed) return 0;
const params = new URLSearchParams({
sortBy: advancedIntakeParams.sortBy,
sortDir: advancedIntakeParams.sortDir,
limit: String(advancedIntakeParams.limit),
offset: String(advancedIntakeParams.offset),
status: advancedIntakeParams.status,
});
const res = await fetch(
`/api/medications/${advancedMed.id}/intake?${params.toString()}`,
);
if (!res.ok) return 0;
const json = await res.json();
return (json.data?.meta?.total ?? 0) as number;
},
enabled: isAuthenticated && !!advancedMed,
});
function openCreate() {
setEditingMed(null);
setDialogOpen(true);
}
// v1.15.18 — the card kebab actions now navigate to the full-page
// tabbed detail surface on the right tab rather than opening a modal
// editor / sheet. Edit lands on Zeitplan (the everyday schedule view +
// the hero's "Vollständig bearbeiten" jump into the wizard), History on
// Verlauf, Advanced on Erweitert (the dissolved advanced-settings).
function openEdit(med: Medication) {
setEditingMed(med);
setDialogOpen(true);
router.push(`/medications/${med.id}?tab=zeitplan`);
}
function openHistory(med: Medication) {
router.push(`/medications/${med.id}/history`);
router.push(`/medications/${med.id}?tab=verlauf`);
}
function openAdvanced(med: Medication) {
setAdvancedMed(med);
router.push(`/medications/${med.id}?tab=erweitert`);
}
function closeDialog() {
setDialogOpen(false);
setEditingMed(null);
}
if (authLoading) {
@@ -412,15 +371,15 @@ export default function MedicationsPage() {
the API endpoint moves to the detail-page Settings → Externe
Integration sub-row. */}
{/* v1.5.4 — modal-wizard mount. The same component drives both
create (no initial) and edit (hydrates from the medication's
payload). The wizard owns its own ResponsiveSheet shell with
the dialog/sheet split and the sticky footer. */}
{/* v1.15.18 — create-only wizard mount. Editing an existing
medication now navigates to the detail page's tabs (the hero's
"Vollständig bearbeiten" reopens the wizard in edit mode from
there); the list page wizard only ever creates. The wizard owns
its own ResponsiveSheet shell with the sticky footer. */}
<MedicationWizardDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode={editingMed ? "edit" : "create"}
initial={editingMed ? medicationToPayload(editingMed) : undefined}
mode="create"
onSuccess={closeDialog}
/>
@@ -446,68 +405,6 @@ export default function MedicationsPage() {
}))}
/>
)}
{/* v1.7.1 — one shared advanced-settings sheet for every card.
Reuses the exact component the detail page mounts (Data /
Reminders / Lifecycle / Danger zone); the card's sliders button
selects the medication and opens it. Not duplicated per card. */}
{advancedMed && (
<AdvancedSettingsSheet
open={!!advancedMed}
onOpenChange={(open) => {
if (!open) setAdvancedMed(null);
}}
medicationId={advancedMed.id}
medicationName={advancedMed.name}
treatmentClass={advancedMed.treatmentClass}
active={advancedMed.active}
startsOn={advancedMed.startsOn}
endsOn={advancedMed.endsOn}
notificationsEnabled={advancedMed.notificationsEnabled}
reminderGraceMinutes={
advancedMed.schedules[0]?.reminderGraceMinutes ?? null
}
intakeCount={advancedIntakeCount ?? 0}
atcCode={advancedMed.atcCode}
rxNormCode={advancedMed.rxNormCode}
/>
)}
</div>
);
}
/**
* Map a `Medication` row from `GET /api/medications` onto the
* `MedicationPayload` shape the wizard's edit-path hydrator consumes.
* Mirrors the schedule pass-through the v1.5.3 flat form relied on so
* legacy cadences round-trip through the bridge cleanly.
*/
function medicationToPayload(med: Medication): MedicationPayload {
return {
id: med.id,
name: med.name,
dose: med.dose,
category: med.category,
treatmentClass: med.treatmentClass,
deliveryForm: med.deliveryForm,
dosesPerUnit: med.dosesPerUnit ?? null,
trackInjectionSites: med.trackInjectionSites ?? false,
allowedInjectionSites: med.allowedInjectionSites ?? [],
notificationsEnabled: med.notificationsEnabled,
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
...parseScheduleRecurrence(s.daysOfWeek),
timesOfDay: s.timesOfDay,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
})),
};
}
+2
View File
@@ -8,6 +8,7 @@ import { AiSection } from "@/components/settings/ai-section";
import { ApiSection } from "@/components/settings/api-section";
import { DashboardSection } from "@/components/settings/dashboard-section";
import { ExportSection } from "@/components/settings/export-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
@@ -49,6 +50,7 @@ const SECTION_COMPONENTS: Record<
integrations: IntegrationsSection,
notifications: NotificationsSection,
dashboard: DashboardSection,
insights: InsightsSection,
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
@@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
SleepStageStackedBar,
STAGE_ORDER,
type SleepStageBreakdown,
} from "../sleep-stage-stacked-bar";
@@ -92,6 +93,55 @@ describe("<SleepStageStackedBar>", () => {
).not.toThrow();
});
/**
* v1.15.18 — IN_BED is the TOTAL time in bed (≈ CORE + DEEP + REM +
* AWAKE), not a sleep PHASE. Stacking it doubled every bar and inflated
* the tooltip per-night total. It must not be a stack segment.
*/
describe("IN_BED is not double-counted in the stack (v1.15.18)", () => {
it("excludes IN_BED from the stacked phase order", () => {
expect(STAGE_ORDER).not.toContain("IN_BED");
// The genuine phases (+ the legacy ASLEEP bucket) still stack.
expect(STAGE_ORDER).toEqual(["DEEP", "REM", "CORE", "ASLEEP", "AWAKE"]);
});
it("the per-night stack total is CORE + DEEP + REM + AWAKE, not the inflated IN_BED-inclusive sum", () => {
// The tooltip sums `payload.reduce` over exactly the stacked series.
// With IN_BED out of STAGE_ORDER it is no longer a Bar, so it cannot
// enter the payload and the total self-corrects to the real night.
const night = { CORE: 240.7, DEEP: 70.6, REM: 90.6, AWAKE: 34.9 };
const IN_BED = 436.7; // the reported total in bed
const stackedTotal = STAGE_ORDER.reduce(
(sum, stage) => sum + ((night as Record<string, number>)[stage] ?? 0),
0,
);
// Sums to the true night (~6 h 56 m), close to the real IN_BED total,
// and crucially NOT IN_BED + phases (~14 h) the old stack produced.
expect(stackedTotal).toBeCloseTo(436.8, 1);
expect(stackedTotal).toBeLessThan(IN_BED + 1);
expect(stackedTotal).not.toBeCloseTo(IN_BED + stackedTotal, 1);
});
it("does not stack IN_BED even when the payload carries it", () => {
// A night whose breakdown includes the IN_BED total must render the
// phase bars only; the chart must still mount without IN_BED dominating.
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 1,
totalMinutes: 437,
stages: { DEEP: 71, REM: 91, CORE: 241, AWAKE: 35, IN_BED: 437 },
perNight: [
{
dayKey: "2026-06-08",
stages: { DEEP: 71, REM: 91, CORE: 241, AWAKE: 35, IN_BED: 437 },
},
],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toContain('data-slot="sleep-stage-stacked-bar"');
});
});
/**
* v1.4.25 W3f — per-night surface + window toggle.
*/
@@ -141,6 +141,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
<InsightsTabStrip
onRegenerate={advisorEnabled ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
regenerateOutcome={advisor.regenerateOutcome}
availability={availability}
visibleTileIds={visibleTileIds}
tileOrder={tileOrder}
+51 -6
View File
@@ -3,7 +3,7 @@
import { memo, useEffect, useMemo, useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Loader2, RefreshCw } from "lucide-react";
import { ChevronDown, Loader2, RefreshCw, Settings2 } from "lucide-react";
import { toast } from "sonner";
import {
@@ -66,6 +66,15 @@ export interface InsightsTabStripProps {
onRegenerate?: () => void;
/** Spinner state — disables the button and swaps the icon. */
regenerating?: boolean;
/**
* v1.15.18 — the outcome of the last settled regenerate. The falling-edge
* toast fires "refreshed" ONLY when this is `"fresh"`; a slow generation the
* client gave up on (`"timeout"`) shows a "still working" hint instead of a
* misleading success, and a missing provider (`"no-provider"`) stays silent
* (the surface already shows the connect-AI empty state). Absent (legacy
* mounts / no regenerate) falls back to the prior unconditional success.
*/
regenerateOutcome?: "fresh" | "empty" | "timeout" | "no-provider" | null;
/**
* v1.4.27 F19 — analytics + event-driven availability inputs the
* gating helper reads. When omitted the strip falls back to its
@@ -440,6 +449,7 @@ function buildTabs(
function InsightsTabStripImpl({
onRegenerate,
regenerating = false,
regenerateOutcome,
availability,
visibleTileIds,
tileOrder,
@@ -463,18 +473,30 @@ function InsightsTabStripImpl({
[availability, visibleTileIds, tileOrder],
);
// Fire success toast on the falling edge of `regenerating`. Same
// rising-edge ref guard as the W3 implementation so the toast fires
// exactly once per regenerate cycle.
// Fire a toast on the falling edge of `regenerating`. Same rising-edge ref
// guard as the W3 implementation so the toast fires exactly once per
// regenerate cycle. v1.15.18 — the toast is now HONEST: only a `"fresh"`
// outcome reads "refreshed". A `"timeout"` (a slow generation the client
// gave up on while the server may still be writing) shows a "still working,
// try again" hint rather than claiming success; a `"no-provider"` stays
// silent (the surface already shows the connect-AI empty state). The latest
// outcome is read through a ref so the effect doesn't re-fire when only the
// outcome (not the spinner edge) changed.
const lastRegeneratingRef = useRef<boolean>(regenerating);
useEffect(() => {
if (lastRegeneratingRef.current && !regenerating) {
toast.success(t("insights.regenerateSuccess"));
if (regenerateOutcome === "timeout") {
toast.error(t("insights.regenerateError"));
} else if (regenerateOutcome !== "no-provider") {
// `"fresh"`, `"empty"`, or legacy `undefined`/null → success.
toast.success(t("insights.regenerateSuccess"));
}
}
lastRegeneratingRef.current = regenerating;
}, [regenerating, t]);
}, [regenerating, regenerateOutcome, t]);
const regenerateLabel = t("insights.regenerateAnalysis");
const customizeLabel = t("insights.customize");
return (
<nav
@@ -623,6 +645,29 @@ function InsightsTabStripImpl({
"from-background/95 pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l to-transparent sm:hidden",
)}
/>
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
regenerate button and links to the Insights settings section
(overview arrange + pill sort), matching the Dashboard cog
idiom. Rendered alongside the regenerate affordance so the two
top-right controls travel together. The inline "Anpassen"
button under the hero was removed — this is the single entry
point. */}
{onRegenerate && (
<Link
href="/settings/insights"
aria-label={customizeLabel}
title={customizeLabel}
data-slot="insights-tab-strip-customize"
className={cn(
"inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
>
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Link>
)}
{onRegenerate && (
<button
type="button"
@@ -72,14 +72,21 @@ type WindowSize = (typeof WINDOW_DAYS)[number];
/**
* Order on the stack — deepest restorative stages first so a user
* scanning left → right reads quality before context.
*
* IN_BED is deliberately NOT a stack segment. It is the TOTAL time in
* bed (≈ CORE + DEEP + REM + AWAKE), so stacking it on top of those
* phases doubled every bar (~14 h for a 7 h night) and inflated the
* tooltip's per-night total. With it out of the stack the bar height
* and the tooltip total are the real night. The `STAGE_COLORS.IN_BED`
* token + the `insights.sleep.stages.inBed` label survive for the
* last-night hypnogram, which still renders the in-bed span.
*/
const STAGE_ORDER = [
export const STAGE_ORDER = [
"DEEP",
"REM",
"CORE",
"ASLEEP",
"AWAKE",
"IN_BED",
] as const;
/**
+70 -19
View File
@@ -51,25 +51,53 @@ export interface InsightAdvisorPayload {
}
/**
* v1.4.31 — bound the advisor POST with an 8-second
* `AbortController` so a cache-miss path (server still waiting on
* the provider chain) does not pin the mother-page main thread for
* the LLM's full completion tail. The strip stays interactive in
* the DOM, but every re-render of a parent during a long pending
* fetch competes with WebKit's gesture-recognition timeout —
* dropping the worst case from 30 s to 8 s eliminates the
* mobile-tap-block window per
* v1.4.31 — bound the advisor READ (GET) with an 8-second
* `AbortController` so a cache-miss path (server still warming out of
* band) does not pin the mother-page main thread. The strip stays
* interactive in the DOM; dropping the worst case from 30 s to 8 s
* eliminates the mobile-tap-block window per
* `.planning/research/v15-insights-blocking-bug.md` fix 1.
*/
const ADVISOR_TIMEOUT_MS = 8_000;
/**
* v1.15.18 — the user-initiated regenerate (`force`) POSTs an INLINE
* generation: a ~1500-token warm completion that routinely runs longer
* than the 8 s read budget. Bounding the force branch at 8 s silently
* discarded a slow-but-successful generation — the abort returned a null
* payload, the mutation only invalidated (re-reading the OLD cache), yet
* the server had often already written the fresh briefing. Give the force
* branch the same 45 s budget the out-of-band warm job uses
* (`FORCE_WARM_COMPREHENSIVE_TIMEOUT_MS`) so a slow success is kept, not
* dropped. The READ path stays fast at 8 s — only the explicit user tap
* may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b.
*/
const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
/**
* v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST:
* only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen
* the client gave up on) and a `no-provider` (422) are distinct failure
* modes the audit's Fix 2 + Fix 4 call out.
*/
export type AdvisorFetchOutcome =
| "fresh"
| "empty"
| "timeout"
| "no-provider";
interface AdvisorFetchResult {
payload: InsightAdvisorPayload | null;
outcome: AdvisorFetchOutcome;
}
async function fetchAdvisor(
options: { force?: boolean } = {},
): Promise<InsightAdvisorPayload | null> {
): Promise<AdvisorFetchResult> {
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
ADVISOR_TIMEOUT_MS,
options.force ? FORCE_ADVISOR_TIMEOUT_MS : ADVISOR_TIMEOUT_MS,
);
let res: Response;
try {
@@ -92,7 +120,8 @@ async function fetchAdvisor(
if (err instanceof DOMException && err.name === "AbortError") {
// Graceful empty payload — the UI surfaces the empty / regen
// CTA exactly as it does for the 422 / 429 / 503 paths below.
return null;
// `timeout` lets the regenerate path avoid claiming success.
return { payload: null, outcome: "timeout" };
}
throw err;
} finally {
@@ -101,9 +130,14 @@ async function fetchAdvisor(
if (!res.ok) {
// 422 (no provider configured) and 429 (rate-limited) are expected
// surfaces — return null so the consuming UI shows the empty / error
// state without the query slipping into an `isError` retry loop.
if (res.status === 422 || res.status === 429 || res.status === 503) {
return null;
// state without the query slipping into an `isError` retry loop. 422
// surfaces as `no-provider` so the regenerate path can distinguish a
// missing provider from a slow generation (audit Fix 4).
if (res.status === 422) {
return { payload: null, outcome: "no-provider" };
}
if (res.status === 429 || res.status === 503) {
return { payload: null, outcome: "empty" };
}
throw new Error(`HTTP ${res.status}`);
}
@@ -139,7 +173,7 @@ async function fetchAdvisor(
} else {
payload.trendAnnotations = null;
}
return payload;
return { payload, outcome: "fresh" };
}
export interface UseInsightsAdvisorResult {
@@ -150,6 +184,13 @@ export interface UseInsightsAdvisorResult {
regenerate: () => void;
isRegenerating: boolean;
regenerateError: Error | null;
/**
* v1.15.18 — the outcome of the LAST settled regenerate. The tab strip
* fires the "refreshed" toast only when this is `"fresh"`, so a slow gen
* the client gave up on (`"timeout"`) or a missing provider
* (`"no-provider"`) never reads as "done".
*/
regenerateOutcome: AdvisorFetchOutcome | null;
}
/**
@@ -162,7 +203,9 @@ export function useInsightsAdvisorQuery(
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.insightsAdvisor(),
queryFn: () => fetchAdvisor(),
// Map the tagged fetch result back onto the payload the cache stores —
// the read path only cares about the payload, not the outcome tag.
queryFn: async () => (await fetchAdvisor()).payload,
enabled,
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
@@ -171,10 +214,17 @@ export function useInsightsAdvisorQuery(
const mutation = useMutation({
mutationFn: () => fetchAdvisor({ force: true }),
onSuccess: (next) => {
if (next) {
queryClient.setQueryData(queryKeys.insightsAdvisor(), next);
onSuccess: ({ payload }) => {
if (payload) {
// A genuinely fresh generation landed — write it into the shared
// cache so the hero subtitle + briefing card repaint immediately.
queryClient.setQueryData(queryKeys.insightsAdvisor(), payload);
} else {
// No fresh payload (timeout / no-provider / transient). The server's
// inline POST may STILL have written a fresh briefing after the
// client gave up at 45 s, so re-read the GET to converge — but the
// honest toast is gated on `regenerateOutcome` below, not on this
// invalidate.
queryClient.invalidateQueries({
queryKey: queryKeys.insightsAdvisor(),
});
@@ -202,5 +252,6 @@ export function useInsightsAdvisorQuery(
regenerate,
isRegenerating: mutation.isPending,
regenerateError: (mutation.error as Error | null) ?? null,
regenerateOutcome: mutation.data?.outcome ?? null,
};
}
@@ -1,210 +0,0 @@
/**
* v1.7.0 — AdvancedSettingsSheet composition contract.
*
* The sheet hosts five blocks — Reminders → Lifecycle → Data →
* Externe API → Danger zone — composed from bare section bodies.
* `<ResponsiveSheet>` wraps Radix portals that `renderToStaticMarkup`
* does not materialise, so we mock it to a passthrough that renders
* children only when `open` and echoes `contentWidth`. The bodies are
* mocked to ordered markers. The test pins:
* - the five blocks render in the documented order;
* - the external-API row lives in its own group, not in Data;
* - the sheet opens at the `2xl` width token;
* - `onRequestPhaseSheet` threads through to the phases row;
* - `onOpenImport` wires the import button (co-located with export);
* - the export control sits beside the import control;
* - the lifecycle + danger bodies receive an `onAfterAction` closer;
* - the sheet renders nothing when `open` is false.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
children,
title,
contentWidth,
}: {
open: boolean;
children: React.ReactNode;
title: React.ReactNode;
contentWidth?: string;
}) =>
open ? (
<div data-slot="mock-responsive-sheet" data-content-width={contentWidth}>
<div data-slot="mock-title">{title}</div>
{children}
</div>
) : null,
}));
vi.mock("@/components/medications/sections/api-tokens-row", () => ({
ApiTokensRow: () => <div data-slot="mock-api-tokens" />,
}));
vi.mock("@/components/medications/sections/notifications-section", () => ({
NotificationsBody: () => <div data-slot="mock-notifications" />,
}));
vi.mock("@/components/medications/sections/settings-section", () => ({
GraceRow: () => <div data-slot="mock-grace" />,
DrugCodingRow: () => <div data-slot="mock-drug-coding" />,
PhasesRow: ({
onRequestPhaseSheet,
}: {
onRequestPhaseSheet?: () => void;
}) => (
<div
data-slot="mock-phases"
data-has-phase-swap={onRequestPhaseSheet ? "yes" : "no"}
/>
),
}));
vi.mock("@/components/medications/sections/destructive-zone-section", () => ({
LifecycleManageBody: ({ onAfterAction }: { onAfterAction?: () => void }) => (
<div
data-slot="mock-lifecycle"
data-has-after-action={onAfterAction ? "yes" : "no"}
/>
),
DangerZoneBody: ({ onAfterAction }: { onAfterAction?: () => void }) => (
<button
data-slot="mock-danger"
data-has-after-action={onAfterAction ? "yes" : "no"}
onClick={onAfterAction}
/>
),
}));
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
function baseProps(
open: boolean,
extra: Partial<{
onRequestPhaseSheet: () => void;
onOpenImport: () => void;
}> = {},
) {
return {
open,
onOpenChange: () => {},
medicationId: "med-1",
medicationName: "Test Drug",
treatmentClass: "GLP1",
active: true,
startsOn: "2026-01-01",
endsOn: "2026-06-01",
notificationsEnabled: true,
reminderGraceMinutes: 30,
intakeCount: 5,
...extra,
};
}
describe("AdvancedSettingsSheet (v1.7.0)", () => {
it("renders the five blocks in Reminders → Lifecycle → Data → Externe API → Danger order", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet
{...baseProps(true, { onRequestPhaseSheet: () => {} })}
/>,
);
const reminders = html.indexOf("advanced-group-reminders");
const lifecycle = html.indexOf("advanced-group-lifecycle");
const data = html.indexOf("advanced-group-data");
const externalApi = html.indexOf("advanced-group-external-api");
const danger = html.indexOf("mock-danger");
expect(reminders).toBeGreaterThan(-1);
expect(lifecycle).toBeGreaterThan(reminders);
expect(data).toBeGreaterThan(lifecycle);
expect(externalApi).toBeGreaterThan(data);
expect(danger).toBeGreaterThan(externalApi);
});
it("homes the external-API row in its own group, not in the Data group", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
// The API row marker must sit after the external-API group opens,
// not inside the Data group it used to share.
const dataGroup = html.indexOf("advanced-group-data");
const externalApiGroup = html.indexOf("advanced-group-external-api");
const apiRow = html.indexOf("mock-api-tokens");
expect(apiRow).toBeGreaterThan(externalApiGroup);
expect(externalApiGroup).toBeGreaterThan(dataGroup);
});
it("opens at the 2xl width token", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
expect(html).toContain('data-content-width="2xl"');
});
it("threads onRequestPhaseSheet through to the phases row", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet
{...baseProps(true, { onRequestPhaseSheet: () => {} })}
/>,
);
expect(html).toContain('data-has-phase-swap="yes"');
});
it("wires the import button", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet
{...baseProps(true, { onOpenImport: () => {} })}
/>,
);
expect(html).toContain("advanced-import-button");
expect(html).toContain(
"medications.detail.advanced.dataPortability.import.button",
);
});
it("co-locates an export control beside the import control", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
expect(html).toContain("advanced-export-button");
expect(html).toContain(
"medications.detail.advanced.dataPortability.export.button",
);
const importIdx = html.indexOf("advanced-import-block");
const exportIdx = html.indexOf("advanced-export-block");
expect(importIdx).toBeGreaterThan(-1);
expect(exportIdx).toBeGreaterThan(importIdx);
});
it("passes an onAfterAction to the lifecycle and danger bodies so they can close the sheet", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
const lifecycle = html.match(
/mock-lifecycle"[^>]*data-has-after-action="yes"/,
);
const danger = html.match(/mock-danger"[^>]*data-has-after-action="yes"/);
expect(lifecycle).not.toBeNull();
expect(danger).not.toBeNull();
});
it("renders nothing when closed", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(false)} />,
);
expect(html).toBe("");
});
it("titles the sheet with the advanced-settings key", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
expect(html).toContain("medications.detail.advanced.title");
});
});
@@ -373,10 +373,11 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
});
/**
* v1.15.9 — the ONE shared `<MedicationCardBody>` both cards render. These
* tests pin the state-driven presentation (green take-window highlight,
* overdue / heavily-overdue top line) and the structural identity that makes
* the two variants impossible to diverge — they pass content into THIS body.
* v1.15.18 — the ONE shared `<MedicationCardBody>` both cards render. These
* tests pin that the card surface is a CONSTANT neutral surface — dose status
* is never expressed as a background / border tint (Marc, recurring) — while
* status is still surfaced via the discreet top line / pill, plus the
* structural identity that makes the two variants impossible to diverge.
*/
describe("medication card body — shared shell + dose-state presentation", () => {
function renderBody(doseStatus: DoseStatus, active = true) {
@@ -406,17 +407,30 @@ describe("medication card body — shared shell + dose-state presentation", () =
);
}
it("highlights the card green when the dose is in its take-window", () => {
it("never tints the card surface when the dose is in its take-window", () => {
// Marc (recurring): the whole card washing green reads as "take it now"
// and is a permanent removal. The take-window status survives ONLY as the
// discreet "Take now" pill, never as a card background / ring tint.
const html = renderBody("on_time_window");
// Green ring + tinted background on the Card shell.
expect(html).toContain("ring-success/40");
expect(html).toContain("bg-success/5");
expect(html).not.toContain("ring-success/40");
expect(html).not.toContain("bg-success/5");
// The discreet status pill still communicates the state.
expect(html).toContain("Take now");
});
it("stays calm (no green highlight) for upcoming / taken states", () => {
for (const status of ["upcoming", "taken_on_time", "taken_late", "skipped"] as const) {
it("never tints the card surface across any dose status", () => {
for (const status of [
"on_time_window",
"upcoming",
"taken_on_time",
"taken_late",
"skipped",
"overdue",
"missed",
] as const) {
const html = renderBody(status);
expect(html).not.toContain("ring-success/40");
expect(html).not.toContain("bg-success/5");
}
});
@@ -433,9 +447,10 @@ describe("medication card body — shared shell + dose-state presentation", () =
expect(html).toContain("text-destructive");
});
it("never highlights green on an inactive medication", () => {
it("keeps the inactive surface muted without any status tint", () => {
const html = renderBody("on_time_window", false);
expect(html).not.toContain("ring-success/40");
expect(html).not.toContain("bg-success/5");
expect(html).toContain("opacity-60");
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
classifyTake,
nearestSlotForTake,
} from "@/components/medications/dose-history-add-dialog";
/**
* v1.15.18 WE — the late-take nudge classifier behind the Verlauf add dialog.
* A take inside the ±1h on-time window pins to its slot directly; a near-miss
* (≤4h gap) prompts "diesem Slot zuordnen?"; anything further is ad-hoc; a PRN
* med (no times) is always ad-hoc.
*/
describe("nearestSlotForTake", () => {
it("finds the closest slot on the take's own day with the gap in minutes", () => {
const near = nearestSlotForTake("2026-06-01T08:30", ["07:00", "19:00"]);
expect(near?.slotHm).toBe("07:00");
expect(near?.gapMinutes).toBe(90);
});
it("returns null for a PRN medication with no time-anchored slots", () => {
expect(nearestSlotForTake("2026-06-01T08:30", [])).toBeNull();
});
});
describe("classifyTake", () => {
it("treats an in-window take (≤60 min) as an exact slot match", () => {
expect(
classifyTake(nearestSlotForTake("2026-06-01T07:30", ["07:00"])),
).toBe("exact");
});
it("prompts the nudge for a near-miss take (61–240 min off the slot)", () => {
expect(
classifyTake(nearestSlotForTake("2026-06-01T09:00", ["07:00"])),
).toBe("nudge");
});
it("records ad-hoc when the take is far from every slot", () => {
expect(
classifyTake(nearestSlotForTake("2026-06-01T14:00", ["07:00"])),
).toBe("ad_hoc");
});
it("records ad-hoc for a PRN medication (no slots)", () => {
expect(classifyTake(null)).toBe("ad_hoc");
});
});
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import {
applyOptimisticSlotMark,
complianceFromLedger,
groupLedgerByDay,
isSlotActionable,
type LedgerPayload,
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
/**
* v1.15.18 WE — the pure helpers behind the Verlauf ledger. These cover the
* instant-recompute contract (the optimistic % must track the ledger the user
* sees), the day grouping, the actionability guard, and the optimistic-mark
* mutation that flips a slot before the server round-trip.
*/
function slotRow(
at: string,
status: LedgerRow["status"],
intake?: LedgerRow["intake"],
): LedgerRow {
return { kind: "slot", at, timeOfDay: "07:00", status, intake: intake ?? null };
}
describe("complianceFromLedger", () => {
it("counts taken (on-time + late) over taken + missed, excluding skip/ad-hoc/upcoming", () => {
const rows: LedgerRow[] = [
slotRow("2026-06-01T05:00:00.000Z", "taken_on_time"),
slotRow("2026-06-02T05:00:00.000Z", "taken_late"),
slotRow("2026-06-03T05:00:00.000Z", "missed"),
slotRow("2026-06-04T05:00:00.000Z", "skipped"),
slotRow("2026-06-05T05:00:00.000Z", "upcoming"),
{
kind: "ad_hoc",
at: "2026-06-06T12:00:00.000Z",
timeOfDay: null,
status: "ad_hoc",
intake: null,
},
];
const c = complianceFromLedger(rows);
expect(c.takenOnTime).toBe(1);
expect(c.takenLate).toBe(1);
expect(c.missed).toBe(1);
expect(c.denominator).toBe(3); // 2 taken + 1 missed
expect(c.rate).toBe(67); // round(2/3 * 100)
});
it("returns a null rate when nothing counts yet (only upcoming / skipped)", () => {
const rows: LedgerRow[] = [
slotRow("2026-06-05T05:00:00.000Z", "upcoming"),
slotRow("2026-06-04T05:00:00.000Z", "skipped"),
];
expect(complianceFromLedger(rows).rate).toBeNull();
});
it("caps the rate at 100", () => {
const rows: LedgerRow[] = [
slotRow("2026-06-01T05:00:00.000Z", "taken_on_time"),
slotRow("2026-06-02T05:00:00.000Z", "taken_on_time"),
];
expect(complianceFromLedger(rows).rate).toBe(100);
});
});
describe("groupLedgerByDay", () => {
it("groups by local day, most-recent day first, chronological within a day", () => {
const rows: LedgerRow[] = [
slotRow("2026-06-02T17:00:00.000Z", "taken_on_time"), // 19:00 Berlin
slotRow("2026-06-01T05:00:00.000Z", "taken_on_time"), // 07:00 Berlin
slotRow("2026-06-02T05:00:00.000Z", "missed"), // 07:00 Berlin
];
const groups = groupLedgerByDay(rows, "Europe/Berlin");
expect(groups.map((g) => g.dayKey)).toEqual(["2026-06-02", "2026-06-01"]);
// Within 2026-06-02 the 07:00 row precedes the 19:00 row.
expect(groups[0].rows.map((r) => r.at)).toEqual([
"2026-06-02T05:00:00.000Z",
"2026-06-02T17:00:00.000Z",
]);
});
it("keeps a late-evening dose on its own local day, not the next UTC day", () => {
// 23:30 Berlin on 2026-06-01 is 21:30 UTC the same day.
const rows: LedgerRow[] = [
slotRow("2026-06-01T21:30:00.000Z", "taken_late"),
];
const groups = groupLedgerByDay(rows, "Europe/Berlin");
expect(groups[0].dayKey).toBe("2026-06-01");
});
});
describe("isSlotActionable", () => {
it("is true only for unfilled slot rows (upcoming / missed)", () => {
expect(isSlotActionable(slotRow("x", "upcoming"))).toBe(true);
expect(isSlotActionable(slotRow("x", "missed"))).toBe(true);
expect(isSlotActionable(slotRow("x", "taken_on_time"))).toBe(false);
expect(isSlotActionable(slotRow("x", "skipped"))).toBe(false);
expect(
isSlotActionable({
kind: "ad_hoc",
at: "x",
timeOfDay: null,
status: "ad_hoc",
intake: null,
}),
).toBe(false);
});
});
describe("applyOptimisticSlotMark", () => {
const payload: LedgerPayload = {
from: "2026-06-01T00:00:00.000Z",
to: "2026-06-02T00:00:00.000Z",
family: "daily",
hasExpectedSlots: true,
rows: [
slotRow("2026-06-01T05:00:00.000Z", "missed"),
slotRow("2026-06-01T17:00:00.000Z", "upcoming"),
],
};
it("flips the matched missed slot to taken_on_time with a synthetic intake, bumping the rate instantly", () => {
expect(complianceFromLedger(payload.rows).rate).toBe(0); // 0 taken / 1 missed
const next = applyOptimisticSlotMark(
payload,
"2026-06-01T05:00:00.000Z",
"taken",
);
const marked = next.rows.find(
(r) => r.at === "2026-06-01T05:00:00.000Z",
)!;
expect(marked.status).toBe("taken_on_time");
expect(marked.intake?.takenAt).toBe("2026-06-01T05:00:00.000Z");
expect(marked.intake?.skipped).toBe(false);
expect(complianceFromLedger(next.rows).rate).toBe(100);
});
it("flips a slot to skipped without entering the denominator", () => {
const next = applyOptimisticSlotMark(
payload,
"2026-06-01T05:00:00.000Z",
"skipped",
);
const marked = next.rows.find(
(r) => r.at === "2026-06-01T05:00:00.000Z",
)!;
expect(marked.status).toBe("skipped");
expect(marked.intake?.skipped).toBe(true);
// skip excluded → denominator drops to 0 → null rate.
expect(complianceFromLedger(next.rows).rate).toBeNull();
});
it("leaves an already-filled (non-actionable) slot untouched", () => {
const filled: LedgerPayload = {
...payload,
rows: [slotRow("2026-06-01T05:00:00.000Z", "taken_late")],
};
const next = applyOptimisticSlotMark(
filled,
"2026-06-01T05:00:00.000Z",
"skipped",
);
expect(next.rows[0].status).toBe("taken_late");
});
});
@@ -1,208 +0,0 @@
"use client";
/**
* v1.7.0 — Advanced settings sheet.
* v1.7.2 — widened to `4xl` and re-laid into a responsive two-column
* grid.
* v1.8.6 — rebuilt as a single column. The two-column data|reminders
* grid read as overloaded; the sheet now narrows to `2xl` (~672px) and
* stacks bordered section cards in most-used-first order. One spacing
* rule throughout — `space-y-4` between cards, `divide-y` + `py-3` rows
* inside each card (the `<SettingsGroup>` card owns the dividers).
*
* v1.9.0 — the Data group split. It had carried both operator data
* portability and the developer-facing external API in one frame; the
* API endpoint now sits in its own `Externe API` group so the two
* audiences no longer share a card. Order: Reminders → Lifecycle →
* Data → Externe API → Danger.
*
* - Reminders: notifications switch + reminder window (grace).
* - Lifecycle: pause/resume + end course + phases.
* - Data: intake Import + medications CSV Export (co-located).
* - Externe API: the per-medication ingest endpoint + token, with the
* request examples collapsed by default.
* - Danger zone: purge intake history + delete medication, set off by
* its own destructive-tinted card so an irreversible action never
* sits next to a routine toggle.
*
* Button styling is consistent: reversible / neutral actions are
* `outline`, destructive actions are `destructive font-semibold`,
* toggles are switches wrapped in labels.
*
* The Phasen button is sibling-swapped, not nested: `onRequestPhaseSheet`
* closes this sheet first and lets the parent open the phase sheet so
* the modal stack never exceeds two (D-3 §10 invariant 7). Each control
* self-saves, so the footer slot stays empty.
*/
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SettingsGroup } from "@/components/medications/settings-group";
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row";
import { NotificationsBody } from "@/components/medications/sections/notifications-section";
import {
DrugCodingRow,
GraceRow,
PhasesRow,
} from "@/components/medications/sections/settings-section";
import {
DangerZoneBody,
LifecycleManageBody,
} from "@/components/medications/sections/destructive-zone-section";
import { useTranslations } from "@/lib/i18n/context";
export interface AdvancedSettingsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
medicationId: string;
medicationName: string;
treatmentClass?: string;
active: boolean;
startsOn?: string | null;
endsOn?: string | null;
notificationsEnabled: boolean;
reminderGraceMinutes: number | null;
intakeCount: number;
/** v1.9.0 — optional drug-classification codes for the FHIR export. */
atcCode?: string | null;
rxNormCode?: string | null;
/**
* Sibling-swap the phase sheet: the parent closes this sheet and
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
*/
onRequestPhaseSheet?: () => void;
/**
* v1.7.0 — opens the page-owned `<IntakeImportDialog>` from the Data
* group's import button.
*/
onOpenImport?: () => void;
}
export function AdvancedSettingsSheet({
open,
onOpenChange,
medicationId,
medicationName,
treatmentClass,
active,
startsOn,
endsOn,
notificationsEnabled,
reminderGraceMinutes,
intakeCount,
atcCode,
rxNormCode,
onRequestPhaseSheet,
onOpenImport,
}: AdvancedSettingsSheetProps) {
const { t } = useTranslations();
const isGlp1 = treatmentClass === "GLP1";
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.detail.advanced.title")}
contentWidth="2xl"
bodyClassName="gap-4"
>
<div className="space-y-4" data-slot="advanced-settings-sheet-body">
{/* REMINDERS — notifications + reminder window. Most-used group
first; each row is a padded `<div>` so the group card's
`divide-y` draws the hairline between them. */}
<SettingsGroup
label={t("medications.detail.advanced.group.reminders")}
dataSlot="advanced-group-reminders"
>
<div className="py-3">
<NotificationsBody
medicationId={medicationId}
notificationsEnabled={notificationsEnabled}
/>
</div>
<div className="py-3">
<GraceRow
medicationId={medicationId}
reminderGraceMinutes={reminderGraceMinutes}
/>
</div>
</SettingsGroup>
{/* LIFECYCLE — pause/resume + end course + phases */}
<SettingsGroup
label={t("medications.detail.advanced.group.lifecycle")}
dataSlot="advanced-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={medicationId}
medicationName={medicationName}
active={active}
onAfterAction={() => onOpenChange(false)}
/>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={medicationId}
treatmentClass={treatmentClass}
startsOn={startsOn}
endsOn={endsOn}
onRequestPhaseSheet={onRequestPhaseSheet}
/>
</div>
)}
</SettingsGroup>
{/* DATA — import + export. The external API lives in its own
group below; this group stays scoped to operator data
portability so the two audiences don't share one frame. */}
<SettingsGroup
label={t("medications.detail.advanced.group.data")}
dataSlot="advanced-group-data"
>
<div className="py-3">
<DataPortabilityRow
medicationId={medicationId}
onOpenImport={onOpenImport}
/>
</div>
</SettingsGroup>
{/* EXTERNAL API — the per-medication ingest endpoint + token.
Pulled out of the Data group into its own frame so the
developer/integration surface reads as a distinct, lowest-
frequency block right before the danger zone. */}
<SettingsGroup
label={t("medications.detail.advanced.group.externalApi")}
dataSlot="advanced-group-external-api"
>
<div className="py-3">
<ApiTokensRow
medicationId={medicationId}
medicationName={medicationName}
/>
</div>
<div className="py-3">
<DrugCodingRow
medicationId={medicationId}
atcCode={atcCode}
rxNormCode={rxNormCode}
/>
</div>
</SettingsGroup>
{/* DANGER ZONE — purge + delete. Set off by the destructive-
tinted card so an irreversible action never sits next to a
routine toggle. The body supplies its own matched-pair card,
so it slots in without the neutral group frame. */}
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount}
onAfterAction={() => onOpenChange(false)}
/>
</div>
</ResponsiveSheet>
);
}
@@ -35,11 +35,16 @@ import type { MedicationWindowStatus } from "@/lib/medications/window-status";
* the single `mt-auto pt-0` action wrapper — there is no per-variant spacing
* left to diverge.
*
* State-driven presentation (like the iOS app):
* - `on_time_window` → the card highlights green (`ring` + tinted bg): the
* actionable "take it now" state stands out at a glance.
* State-driven presentation:
* - The card surface itself is a CONSTANT neutral surface — dose status
* never tints the card background or border. A whole card washing green
* reads as "take it now everywhere" and drowns the page; status is shown
* ONLY through the discreet top line / pill below.
* - `on_time_window` → the discreet "Take now" pill (success-toned text +
* glyph), no card wash.
* - `overdue` → a calm top status line; `missed` (at / past the miss
* cutoff) escalates to "Stark überfällig" in the destructive tone.
* cutoff) escalates to "Stark überfällig" in the destructive tone — both
* are a single text line with an icon, not a surface tint.
* - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm.
*/
export interface MedicationCardBodyProps {
@@ -123,10 +128,6 @@ export function MedicationCardBody({
}: MedicationCardBodyProps) {
const { t } = useTranslations();
// Green take-window highlight — the actionable "take it now" state, mirrored
// from the iOS card. Suppressed on an inactive med (it carries no actions).
const inTakeWindow = active && doseStatus === "on_time_window";
// Overdue escalation line: a dose past its on-time window. `missed` (at /
// past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable
// `overdue` tail reads the calmer "Überfällig". Both use the destructive
@@ -140,11 +141,10 @@ export function MedicationCardBody({
return (
<Card
className={cn(
"h-full",
active ? "" : "opacity-60",
inTakeWindow && "ring-success/40 bg-success/5 ring-1",
)}
// The card surface is a constant neutral surface — dose status is never
// expressed as a background / border tint (Marc, recurring): only the
// discreet status line / pill below communicates take-now / overdue.
className={cn("h-full", active ? "" : "opacity-60")}
>
<MedicationCardHeader
name={name}
@@ -0,0 +1,604 @@
"use client";
/**
* v1.15.18 — medication detail full-page tabbed shell.
*
* Promotes the detail page from a read-only history view to the
* canonical home for ONE medication. The modal `<AdvancedSettingsSheet>`
* is dissolved into the Erweitert tab (no more five-card stack in a
* sheet) and the separate `/history` route folds into the Verlauf tab
* (the route now redirects to `?tab=verlauf`).
*
* Tab state lives in the URL (`?tab=<slug>`), so a tab is deep-linkable,
* survives reload, and is reachable from the medications-list card kebab
* (Edit→zeitplan, History→verlauf, Advanced→erweitert). Slugs are ASCII /
* locale-independent; the visible labels are i18n.
*
* Only the active tab body mounts (Radix `Tabs` default) so inactive tabs
* do not fetch. The hero stays above the tabs as the always-visible
* read-only summary plus the "Vollständig bearbeiten" jump into the
* creation/edit wizard (the structural-edit tool — name / class /
* cadence kind / schedules). Everyday levers (reminders, supply, history)
* live inline in the tabs.
*
* Verlauf renders the existing editable intake history and Zeitplan the
* read-only cadence summary for now — the dose-history ledger and the
* per-dose window editor land in the next wave. Injektion is gated to
* injectable routes of administration.
*/
import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
import { ScheduleTimesEditor } from "@/components/medications/scheduling/ScheduleTimesEditor";
import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { InventorySection } from "@/components/medications/sections/inventory-section";
import { NotificationsBody } from "@/components/medications/sections/notifications-section";
import {
DrugCodingRow,
GraceRow,
PhasesRow,
} from "@/components/medications/sections/settings-section";
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row";
import {
DangerZoneBody,
LifecycleManageBody,
} from "@/components/medications/sections/destructive-zone-section";
import { SettingsGroup } from "@/components/medications/settings-group";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import {
parseScheduleRecurrence,
} from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
interface ScheduleSnapshot {
id: string;
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
daysOfWeek: string | null;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
reminderGraceMinutes?: number | null;
scheduleType?: string | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
// v1.15.18 — per-dose on-time windows; the GET returns the raw Json
// column, the Zeitplan inline editor round-trips it on save.
doseWindows?: DoseWindowEntry[] | null;
}
export interface MedicationDetailSnapshot {
id: string;
name: string;
dose: string;
category: string;
treatmentClass?: string;
deliveryForm?: string;
dosesPerUnit?: number | null;
trackInjectionSites?: boolean;
allowedInjectionSites?: string[];
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
atcCode?: string | null;
rxNormCode?: string | null;
schedules: ScheduleSnapshot[];
}
// Slugs are ASCII / locale-independent so the URL stays stable across
// locales; the visible labels resolve through i18n. The list drives both
// the tab strip render and the `?tab=` round-trip validation.
const TAB_SLUGS = [
"uebersicht",
"zeitplan",
"erinnerung",
"bestand",
"verlauf",
"injektion",
"erweitert",
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
return {
id: med.id,
name: med.name,
dose: med.dose,
category: med.category,
treatmentClass: med.treatmentClass,
deliveryForm: med.deliveryForm,
dosesPerUnit: med.dosesPerUnit ?? null,
trackInjectionSites: med.trackInjectionSites ?? false,
allowedInjectionSites: med.allowedInjectionSites ?? [],
notificationsEnabled: med.notificationsEnabled,
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
...parseScheduleRecurrence(s.daysOfWeek),
timesOfDay: s.timesOfDay,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
doseWindows: s.doseWindows ?? null,
})),
};
}
export function MedicationDetailTabs({
medication,
}: {
medication: MedicationDetailSnapshot;
}) {
const { t } = useTranslations();
const router = useRouter();
const searchParams = useSearchParams();
const [editOpen, setEditOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
const id = medication.id;
const oneShot = medication.oneShot === true;
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
const isInjectable = medication.deliveryForm === "INJECTION";
const payload = useMemo(
() => snapshotToWizardPayload(medication),
[medication],
);
// Only the active tab body mounts, but the tab REGISTRY must agree with
// whatever `?tab=` carries — Injektion is absent for non-injectable
// medications, so a stale `?tab=injektion` deep-link falls back to the
// landing tab instead of rendering an empty surface.
const availableTabs = useMemo<TabSlug[]>(
() =>
TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
[isInjectable],
);
const requested = searchParams?.get("tab") as TabSlug | null;
const activeTab: TabSlug =
requested && availableTabs.includes(requested) ? requested : "uebersicht";
const onTabChange = useCallback(
(next: string) => {
const params = new URLSearchParams(
Array.from(searchParams?.entries() ?? []),
);
params.set("tab", next);
// Shallow URL swap so the tab is deep-linkable + survives reload
// without a full navigation. `scroll: false` keeps the viewport
// pinned to the tab strip on switch.
router.replace(`/medications/${id}?${params.toString()}`, {
scroll: false,
});
},
[id, router, searchParams],
);
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(id),
queryFn: async () => {
const res = await fetch(`/api/medications/${id}/compliance`);
if (!res.ok) return null;
return (await res.json()).data as {
compliance7?: { rate: number; streak: number };
compliance30?: { rate: number };
complianceDisplay?: ComplianceDisplay;
};
},
enabled: activeTab === "uebersicht",
staleTime: 30_000,
});
const display = compliance?.complianceDisplay;
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
return (
<div className="space-y-6" data-slot="medication-detail-page">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
{t("medications.back")}
</Link>
</Button>
{/* HERO — always-visible read-only summary + the structural-edit
jump into the wizard. Everyday levers live in the tabs below. */}
<div className="flex items-start justify-between gap-3">
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="medication-detail-full-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">
{t("medications.detail.shell.fullEdit")}
</span>
</Button>
</div>
<Tabs
value={activeTab}
onValueChange={onTabChange}
data-slot="medication-detail-tabs"
>
<TabsList
className="w-full justify-start"
aria-label={t("medications.detail.shell.tablistLabel")}
>
{availableTabs.map((slug) => (
<TabsTrigger
key={slug}
value={slug}
data-slot={`medication-tab-${slug}`}
>
{t(`medications.detail.tabs.${slug}`)}
</TabsTrigger>
))}
</TabsList>
{/* ÜBERSICHT — landing read view: summary echo + compliance. */}
<TabsContent value="uebersicht" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
dataSlot="medication-uebersicht-compliance"
>
{compliance ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
shortDays={shortDays}
longDays={longDays}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.detail.uebersicht.complianceEmpty")}
</p>
)}
</MedicationDetailSection>
</TabsContent>
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
each dose's on-time window. Cadence-kind stays structural
(the hero's "Vollständig bearbeiten"); grace is a read-only
echo here, owned by the Erinnerung tab. */}
<TabsContent value="zeitplan" className="space-y-6 pt-2">
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
{medication.schedules.length > 0 ? (
<MedicationDetailSection
titleId="medication-zeitplan-times-heading"
title={t("medications.detail.zeitplan.title")}
dataSlot="medication-zeitplan-section"
>
<ScheduleTimesEditor
medicationId={id}
schedules={medication.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
daysOfWeek: s.daysOfWeek,
timesOfDay: s.timesOfDay,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
reminderGraceMinutes: s.reminderGraceMinutes,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: s.doseWindows,
}))}
onRequestReminderTab={() => onTabChange("erinnerung")}
/>
</MedicationDetailSection>
) : null}
</TabsContent>
{/* ERINNERUNG — notifications switch + reminder grace (the single
owner of `reminderGraceMinutes`). */}
<TabsContent value="erinnerung" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.advanced.group.reminders")}
dataSlot="erinnerung-group"
>
<div className="py-3">
<NotificationsBody
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
/>
</div>
<div className="py-3">
<GraceRow
medicationId={id}
reminderGraceMinutes={
medication.schedules[0]?.reminderGraceMinutes ?? null
}
/>
</div>
</SettingsGroup>
</TabsContent>
{/* BESTAND — inventory readout for all meds. */}
<TabsContent value="bestand" className="space-y-4 pt-2">
<InventorySection medicationId={id} />
</TabsContent>
{/* VERLAUF — the dose-history ledger: every expected slot with its
status + ad-hoc takes tagged, inline Genommen/Übersprungen with
instant optimistic recompute, edit/add (incl. the late-take "diesem
Slot zuordnen?" nudge). CSV import rides the header ghost. */}
<TabsContent value="verlauf" className="space-y-6 pt-2">
<MedicationDetailSection
titleId="medication-verlauf-heading"
title={t("medications.detail.intake.title")}
dataSlot="medication-verlauf-section"
headerExtras={
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="verlauf-import"
>
{t("medications.detail.intake.importButton")}
</Button>
}
>
<DoseHistoryLedger
medicationId={id}
medicationName={medication.name}
schedules={medication.schedules.map((s) => ({
windowStart: s.windowStart,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
}))}
/>
</MedicationDetailSection>
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
</TabsContent>
{/* INJEKTION — injectable routes only. Current GLP-1 charts +
side-effect log; the body-map + adherence detail land next
wave. */}
{isInjectable && (
<TabsContent value="injektion" className="space-y-6 pt-2">
{isGlp1 && <SideEffectsSection medicationId={id} />}
{isGlp1 && (
<div
className="space-y-6"
data-slot="injektion-drug-level-section"
>
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<DoseStrengthCurve medicationId={id} />
</div>
)}
{!isGlp1 && (
<DoseStrengthCurve medicationId={id} />
)}
</TabsContent>
)}
{/* ERWEITERT — the dissolved advanced-settings sheet, now grouped
un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */}
<TabsContent value="erweitert" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={id}
medicationName={medication.name}
active={medication.active}
/>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={id}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
/>
</div>
)}
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="erweitert-group-external-api"
>
<div className="py-3">
<ApiTokensRow
medicationId={id}
medicationName={medication.name}
/>
</div>
<div className="py-3">
<DrugCodingRow
medicationId={id}
atcCode={medication.atcCode}
rxNormCode={medication.rxNormCode}
/>
</div>
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.erweitert.group.data")}
dataSlot="erweitert-group-data"
>
<div className="py-3">
<DataPortabilityRow
medicationId={id}
onOpenImport={() => setImportOpen(true)}
/>
</div>
</SettingsGroup>
<ComplianceGatedDangerZone
medicationId={id}
medicationName={medication.name}
/>
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
</TabsContent>
</Tabs>
{/* Structural edit — the wizard stays the name / class / cadence-kind
/ schedule tool. Hydrates from the medication payload in edit
mode; closing it leaves the user on the same tab. */}
<MedicationWizardDialog
open={editOpen}
onOpenChange={setEditOpen}
mode="edit"
initial={payload}
onSuccess={() => setEditOpen(false)}
/>
{/* GLP-1 phase/titration editor — sibling-swapped from the Lifecycle
group so the modal stack never exceeds the tab + one sheet. */}
{isGlp1 && (
<PhaseConfigSheet
medicationId={id}
open={phaseSheetOpen}
onOpenChange={setPhaseSheetOpen}
/>
)}
</div>
);
}
/**
* The danger zone's purge gate needs `intakeCount`. Fetch it lazily here
* (the Erweitert tab pays for it; the other tabs never do) rather than
* threading a count the page doesn't otherwise need.
*/
function ComplianceGatedDangerZone({
medicationId,
medicationName,
}: {
medicationId: string;
medicationName: string;
}) {
const params = {
sortBy: "takenAt",
sortDir: "desc" as const,
limit: 1,
offset: 0,
status: "completed",
};
const { data: intakeCount } = useQuery({
queryKey: queryKeys.medicationIntakeList(medicationId, params),
queryFn: async () => {
const search = new URLSearchParams({
sortBy: params.sortBy,
sortDir: params.sortDir,
limit: String(params.limit),
offset: String(params.offset),
status: params.status,
});
const res = await fetch(
`/api/medications/${medicationId}/intake?${search.toString()}`,
);
if (!res.ok) return 0;
const json = await res.json();
return (json.data?.meta?.total ?? 0) as number;
},
staleTime: 30_000,
});
const { t } = useTranslations();
return (
<SettingsGroup
label={t("medications.detail.erweitert.group.danger")}
dataSlot="erweitert-group-danger"
className="border-destructive/40 bg-destructive/5"
>
<div className="py-3">
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount ?? 0}
/>
</div>
</SettingsGroup>
);
}
@@ -0,0 +1,331 @@
"use client";
/**
* v1.15.18 WE — add-intake dialog scoped to one medication, reached from the
* Verlauf tab header. A trimmed sibling of `<LogIntakeDialog>` (which spans
* every active medication from the list page): this one is pre-bound to the
* medication whose ledger the user is editing, so it drops the medication
* picker and adds the late-take nudge.
*
* The nudge (Marc decision): when the picked `takenAt` falls JUST OUTSIDE a
* slot's on-time window — close enough that it is plausibly that dose, but
* past the ±1h default band — the dialog offers "diesem Slot zuordnen?". On
* accept the write carries `forceSlotInstant` (the nearest slot's instant on
* the chosen day), pinning the take onto that scheduled slot instead of
* orphaning it to an ad-hoc row. The server validates the instant is a real
* slot (422 otherwise, surfaced as a toast). Decline → the take records
* ad-hoc (no `scheduledFor`); an exact slot match pins directly with no nudge.
*/
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
} from "@/lib/query-keys";
import type { QueryKey } from "@tanstack/react-query";
import type { LedgerSchedule } from "@/components/medications/dose-history-ledger";
export interface LedgerAddDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
medicationId: string;
medicationName: string;
schedules: LedgerSchedule[];
/** The Verlauf tab's ledger key so the add invalidates it alongside the
* shared medication bundle. */
ledgerKey: QueryKey;
}
/** The default on-time half-window in minutes (±1h, `DOSE_WINDOW_DEFAULTS`). A
* take within this of a slot is an exact match (no nudge); past it but inside
* the nudge ceiling triggers "diesem Slot zuordnen?". */
const ON_TIME_MINUTES = 60;
/** Past this gap a take is clearly its own ad-hoc event — no nudge. */
const NUDGE_CEILING_MINUTES = 240;
/** `<input type="datetime-local">` shape for a Date in local time. */
function toDateTimeLocal(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
/** Distinct HH:mm slot times across the medication's schedules. */
function slotTimes(schedules: LedgerSchedule[]): string[] {
const times = new Set<string>();
for (const s of schedules) {
if (s.timesOfDay && s.timesOfDay.length > 0) {
for (const tod of s.timesOfDay) times.add(tod);
} else if (s.windowStart) {
times.add(s.windowStart);
}
}
return Array.from(times).sort((a, b) => a.localeCompare(b));
}
/** The slot instant on the take's local day for an "HH:mm" slot. */
function slotInstantOnDay(takenAtLocal: string, slotHm: string): Date | null {
const datePart = takenAtLocal.slice(0, 10);
if (datePart.length !== 10) return null;
const d = new Date(`${datePart}T${slotHm}`);
return Number.isNaN(d.getTime()) ? null : d;
}
interface NearestSlot {
slotHm: string;
instant: Date;
gapMinutes: number;
}
/** The nearest schedule slot to the picked `takenAt` on its own day, with the
* absolute gap in minutes. `null` when the medication has no time-anchored
* slots (PRN) or the input is unparseable. */
export function nearestSlotForTake(
takenAtLocal: string,
times: string[],
): NearestSlot | null {
const taken = new Date(takenAtLocal);
if (Number.isNaN(taken.getTime())) return null;
let best: NearestSlot | null = null;
for (const slotHm of times) {
const instant = slotInstantOnDay(takenAtLocal, slotHm);
if (!instant) continue;
const gapMinutes = Math.abs(instant.getTime() - taken.getTime()) / 60_000;
if (!best || gapMinutes < best.gapMinutes) {
best = { slotHm, instant, gapMinutes };
}
}
return best;
}
/** Classify the take against its nearest slot: an exact in-window match pins
* directly; a near-miss prompts the nudge; anything else records ad-hoc. */
export function classifyTake(
nearest: NearestSlot | null,
): "exact" | "nudge" | "ad_hoc" {
if (!nearest) return "ad_hoc";
if (nearest.gapMinutes <= ON_TIME_MINUTES) return "exact";
if (nearest.gapMinutes <= NUDGE_CEILING_MINUTES) return "nudge";
return "ad_hoc";
}
export function LedgerAddDialog({
open,
onOpenChange,
medicationId,
medicationName,
schedules,
ledgerKey,
}: LedgerAddDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date()));
const [skipped, setSkipped] = useState(false);
const [busy, setBusy] = useState(false);
const [nudgeOpen, setNudgeOpen] = useState(false);
const times = useMemo(() => slotTimes(schedules), [schedules]);
const nearest = useMemo(
() => (skipped ? null : nearestSlotForTake(takenAt, times)),
[skipped, takenAt, times],
);
const classification = useMemo(() => classifyTake(nearest), [nearest]);
/** Fire the write. `pin` true sends `forceSlotInstant` to bind the take onto
* the nearest slot; false/exact uses `scheduledFor` for an in-window match,
* else records ad-hoc. */
async function write(pin: boolean) {
if (busy) return;
setBusy(true);
try {
const body: Record<string, unknown> = { skipped };
if (!skipped) body.takenAt = new Date(takenAt).toISOString();
if (!skipped && nearest) {
if (classification === "exact") {
body.scheduledFor = nearest.instant.toISOString();
} else if (pin) {
body.forceSlotInstant = nearest.instant.toISOString();
}
}
const res = await fetch(`/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
// A 422 here is the force-slot guard rejecting a non-slot instant;
// surface it rather than silently dropping the dose.
toast.error(
t("medications.intakeToastFailed", { name: medicationName }),
);
return;
}
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medicationName },
),
);
await invalidateKeys(queryClient, [...medicationDependentKeys, ledgerKey]);
setNudgeOpen(false);
onOpenChange(false);
} catch {
toast.error(t("medications.intakeToastFailed", { name: medicationName }));
} finally {
setBusy(false);
}
}
function submit() {
// A near-miss take prompts the slot-attribution nudge before writing;
// everything else writes straight through.
if (!skipped && classification === "nudge") {
setNudgeOpen(true);
return;
}
void write(false);
}
return (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent data-slot="ledger-add-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.addDialog.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.addDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="ledger-add-taken-at">
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
</Label>
<Input
id="ledger-add-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
disabled={skipped}
/>
</div>
<label
htmlFor="ledger-add-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.detail.verlauf.addDialog.skippedLabel")}
</span>
<Switch
id="ledger-add-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
{/* A near-miss take previews where it will land before the user
commits — calm, not alarming. */}
{!skipped && classification === "nudge" && nearest && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-add-nudge-hint"
>
{t("medications.detail.verlauf.addDialog.nudgeHint", {
slot: nearest.slotHm,
})}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.detail.verlauf.addDialog.cancel")}
</Button>
<Button
onClick={submit}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-submit"
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.addDialog.submit")}
</Button>
</DialogFooter>
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
take as a standalone ad-hoc entry. */}
{nudgeOpen && nearest && (
<Dialog
open={nudgeOpen}
onOpenChange={(o) => !busy && setNudgeOpen(o)}
>
<DialogContent data-slot="ledger-add-nudge-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.nudge.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.nudge.body", {
slot: nearest.slotHm,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => void write(false)}
disabled={busy}
data-slot="ledger-add-nudge-adhoc"
>
{t("medications.detail.verlauf.nudge.keepAdHoc")}
</Button>
<Button
onClick={() => void write(true)}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-nudge-pin"
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.nudge.pin")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,176 @@
/**
* v1.15.18 WE — pure helpers behind the Verlauf (dose-history) tab.
*
* The Verlauf tab renders the server-minted ledger
* (`GET /api/medications/[id]/dose-history`) and lets the user flip a slot
* Genommen / Übersprungen with INSTANT feedback. The instant feel comes from
* an optimistic local rebuild: on a tap the cached `rows` array is mutated
* synchronously and the headline compliance % is recomputed from those same
* rows in the SAME paint — only then does the server write fire and the
* authoritative refetch reconcile. The engines that mint the ledger are pure,
* so the client can mirror their status accounting without re-running the band
* minter it does not hold.
*
* Everything here is pure / synchronous / instant-free of React so it is unit-
* testable without a render (the repo convention — no `renderHook`).
*/
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
/** The serialised row shape the `dose-history` endpoint returns. */
export interface LedgerRow {
kind: "slot" | "ad_hoc";
/** Slot anchor (slot rows) or the ad-hoc take's own time, ISO. */
at: string;
/** The slot's "HH:mm" label, or null for an ad-hoc row. */
timeOfDay: string | null;
status: DoseHistoryStatus;
intake: {
id: string | null;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
autoMissed: boolean;
} | null;
}
/** The full `data` envelope the endpoint returns. */
export interface LedgerPayload {
from: string;
to: string;
family: "daily" | "weekly" | "one_shot" | "none";
hasExpectedSlots: boolean;
rows: LedgerRow[];
}
/**
* Headline compliance derived from the ledger — the SAME accounting the
* server-side unification uses (audit CRITICAL-2): taken = on-time + late;
* denominator = taken + missed; skipped + ad-hoc + upcoming are excluded.
* Surfacing one rate from the ledger the user is looking at means the history
* view and the percentage can never contradict.
*/
export interface LedgerCompliance {
/** Whole-percent adherence (0–100), capped. `null` when nothing counts yet. */
rate: number | null;
takenOnTime: number;
takenLate: number;
missed: number;
/** taken + missed — the doses that count toward the rate. */
denominator: number;
}
/**
* Compute the headline adherence from the ledger rows. Ad-hoc rows, skips,
* and still-takeable upcoming slots never enter the denominator, so a forward-
* looking window does not depress the rate.
*/
export function complianceFromLedger(rows: LedgerRow[]): LedgerCompliance {
let takenOnTime = 0;
let takenLate = 0;
let missed = 0;
for (const row of rows) {
if (row.status === "taken_on_time") takenOnTime += 1;
else if (row.status === "taken_late") takenLate += 1;
else if (row.status === "missed") missed += 1;
}
const taken = takenOnTime + takenLate;
const denominator = taken + missed;
const rate =
denominator > 0 ? Math.min(100, Math.round((taken / denominator) * 100)) : null;
return { rate, takenOnTime, takenLate, missed, denominator };
}
/** A day-grouped view of the ledger, most-recent day first. */
export interface LedgerDayGroup {
/** `YYYY-MM-DD` in the user's locale day, for the heading + key. */
dayKey: string;
/** Rows for that day, chronological (morning first). */
rows: LedgerRow[];
}
/**
* Group rows by their local calendar day (most-recent day first; rows inside a
* day stay chronological so the morning slot leads). The day key is derived in
* the user's timezone via `Intl` so a 23:30 dose does not bleed into the next
* UTC day.
*/
export function groupLedgerByDay(
rows: LedgerRow[],
timeZone: string,
): LedgerDayGroup[] {
const dayKeyFmt = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const byDay = new Map<string, LedgerRow[]>();
for (const row of rows) {
const dayKey = dayKeyFmt.format(new Date(row.at));
const bucket = byDay.get(dayKey);
if (bucket) bucket.push(row);
else byDay.set(dayKey, [row]);
}
const groups: LedgerDayGroup[] = Array.from(byDay.entries()).map(
([dayKey, dayRows]) => ({
dayKey,
rows: dayRows
.slice()
.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime()),
}),
);
// Most-recent day first; chronological within the day above.
groups.sort((a, b) => (a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0));
return groups;
}
/**
* Whether a slot row exposes a Genommen / Übersprungen quick action. A slot is
* actionable when it has no confirmed intake yet (`upcoming` / `missed`) — the
* user is filling in a pending or forgotten dose. A row that already carries a
* take / skip is edited via the row's Bearbeiten dialog, not the quick toggle.
*/
export function isSlotActionable(row: LedgerRow): boolean {
return (
row.kind === "slot" &&
(row.status === "upcoming" || row.status === "missed")
);
}
/**
* Optimistically apply a Genommen / Übersprungen tap to the cached payload so
* the row status + the headline % flip before the server round-trip. The
* matched slot (by `at`) gets a synthetic intake and the matching status; an
* already-claimed or non-slot row is returned untouched. The server write +
* the authoritative refetch reconcile afterwards, so any divergence (e.g. the
* take landing late rather than on-time) self-heals on the next read.
*
* `nowIso` is the optimistic `takenAt` for a Genommen tap (the slot's own
* anchor stands in for a clean on-time mark until the server re-attributes).
*/
export function applyOptimisticSlotMark(
payload: LedgerPayload,
slotAt: string,
action: "taken" | "skipped",
): LedgerPayload {
const rows = payload.rows.map((row) => {
if (row.kind !== "slot" || row.at !== slotAt) return row;
if (!isSlotActionable(row)) return row;
const status: DoseHistoryStatus =
action === "taken" ? "taken_on_time" : "skipped";
return {
...row,
status,
intake: {
// Optimistic placeholder id; replaced by the server row on refetch.
id: row.intake?.id ?? null,
scheduledFor: row.at,
takenAt: action === "taken" ? row.at : null,
skipped: action === "skipped",
autoMissed: false,
},
};
});
return { ...payload, rows };
}
@@ -0,0 +1,626 @@
"use client";
/**
* v1.15.18 WE — the Verlauf tab's dose-history ledger.
*
* Renders the server-minted ledger (`GET /api/medications/[id]/dose-history`):
* EVERY expected slot with its status (pünktlich / verspätet / übersprungen /
* verpasst / ausstehend) even when never taken, plus every off-schedule intake
* as a tagged ad-hoc row at its real time. Rows are grouped by day, most-recent
* day first, chronological inside the day.
*
* Three interactions land here:
* - a pending / missed slot exposes Genommen / Übersprungen ON the row
* (~2 taps, not buried), which mark the dose with INSTANT feedback — the
* cached ledger + the headline % are mutated optimistically (the engines
* are pure, so the client mirrors their accounting) before the server
* write fires and the authoritative refetch reconciles;
* - any row with an intake can be edited (time / skipped) or deleted via the
* shared `<IntakeEditDialog>` + delete confirm;
* - a header "+ Eintrag" opens an add dialog scoped to this medication
* (incl. backdated / off-schedule); an off-window take surfaces the
* "diesem Slot zuordnen?" nudge, pinning it onto a chosen slot via
* `forceSlotInstant` rather than orphaning it to an ad-hoc row.
*
* A compact took / skipped history chart sits at the top — the ledger already
* carries the counts. The card body never tints by status (removed earlier);
* the ledger uses the semantic feedback vocabulary on the row, not the card.
*/
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
Check,
CircleCheck,
Loader2,
Plus,
SkipForward,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import { LedgerAddDialog } from "@/components/medications/dose-history-add-dialog";
import {
applyOptimisticSlotMark,
complianceFromLedger,
groupLedgerByDay,
isSlotActionable,
type LedgerPayload,
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
const DAY_MS = 24 * 60 * 60 * 1000;
/** A schedule slot the add dialog offers for pinning. */
export interface LedgerSchedule {
windowStart: string;
label: string | null;
dose: string | null;
timesOfDay?: string[];
}
export interface DoseHistoryLedgerProps {
medicationId: string;
medicationName: string;
schedules: LedgerSchedule[];
/** Trailing window in days the ledger renders. Defaults to 90. */
windowDays?: number;
}
/** Status → semantic colour token + glyph. Mirrors the card status pill's
* success / warning / destructive ramp so the surface reads as one vocabulary;
* skipped + upcoming are calm muted, ad-hoc is the neutral-positive accent. */
const STATUS_TONE: Record<DoseHistoryStatus, string> = {
taken_on_time: "text-success",
taken_late: "text-warning",
skipped: "text-muted-foreground",
missed: "text-destructive",
upcoming: "text-muted-foreground",
ad_hoc: "text-primary",
};
function StatusGlyph({ status }: { status: DoseHistoryStatus }) {
const cls = "size-3.5 shrink-0";
switch (status) {
case "taken_on_time":
return <CircleCheck aria-hidden="true" className={cls} />;
case "taken_late":
return <AlertCircle aria-hidden="true" className={cls} />;
case "missed":
return <AlertTriangle aria-hidden="true" className={cls} />;
case "ad_hoc":
return <Plus aria-hidden="true" className={cls} />;
case "skipped":
return <SkipForward aria-hidden="true" className={cls} />;
default:
return null;
}
}
export function DoseHistoryLedger({
medicationId,
medicationName,
schedules,
windowDays = 90,
}: DoseHistoryLedgerProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
const [addOpen, setAddOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<{
id: string;
takenAt: string | null;
skipped: boolean;
} | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [marking, setMarking] = useState<string | null>(null);
// Stable window captured once at mount (a lazy state initializer keeps the
// impure `Date.now()` out of render) so the query key + the `from`/`to`
// request stay byte-equal across renders rather than re-keying every paint.
const [{ fromIso, toIso }] = useState(() => {
const now = Date.now();
return {
toIso: new Date(now).toISOString(),
fromIso: new Date(now - windowDays * DAY_MS).toISOString(),
};
});
const queryKey = queryKeys.medicationDoseHistory(medicationId, fromIso, toIso);
const { data, isLoading, isError } = useQuery<LedgerPayload>({
queryKey,
queryFn: async () => {
const search = new URLSearchParams({ from: fromIso, to: toIso });
const res = await fetch(
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
);
if (!res.ok) throw new Error("dose_history_failed");
return (await res.json()).data as LedgerPayload;
},
staleTime: 15_000,
});
const compliance = useMemo(
() => (data ? complianceFromLedger(data.rows) : null),
[data],
);
const groups = useMemo(
() => (data ? groupLedgerByDay(data.rows, timeZone) : []),
[data, timeZone],
);
/**
* Genommen / Übersprungen on a pending / missed slot. The cached ledger is
* mutated optimistically (row status + the headline % flip in this paint),
* then the server write fires and the dependent-key invalidation reconciles.
* A failed write rolls the optimistic snapshot back + toasts (mirrors the
* C1/C2 pattern in `use-medication-intake.ts`).
*/
const markSlot = useCallback(
async (row: LedgerRow, action: "taken" | "skipped") => {
if (marking || !isSlotActionable(row)) return;
setMarking(`${row.at}:${action}`);
const prev = queryClient.getQueryData<LedgerPayload>(queryKey);
if (prev) {
queryClient.setQueryData<LedgerPayload>(
queryKey,
applyOptimisticSlotMark(prev, row.at, action),
);
}
try {
const res = await fetch(`/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// Pin the displayed slot so the server marks THAT dose (the canonical
// slot upsert), not a now-snap to the nearest one.
body: JSON.stringify(
action === "skipped"
? { skipped: true, scheduledFor: row.at }
: {
skipped: false,
scheduledFor: row.at,
takenAt: new Date().toISOString(),
},
),
});
if (!res.ok) {
if (prev) queryClient.setQueryData(queryKey, prev);
toast.error(
t("medications.intakeToastFailed", { name: medicationName }),
);
return;
}
toast.success(
t(
action === "skipped"
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medicationName },
),
);
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKey,
]);
} catch {
if (prev) queryClient.setQueryData(queryKey, prev);
toast.error(
t("medications.intakeToastFailed", { name: medicationName }),
);
} finally {
setMarking(null);
}
},
[marking, medicationId, medicationName, queryClient, queryKey, t],
);
async function confirmRowDelete() {
if (!pendingDeleteId) return;
const id = pendingDeleteId;
setPendingDeleteId(null);
try {
const res = await fetch(
`/api/medications/${medicationId}/intake/${id}`,
{ method: "DELETE" },
);
if (!res.ok) {
toast.error(t("medications.detail.intake.deleteRow.failed"));
return;
}
toast.success(t("medications.detail.intake.deleteRow.toast"));
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
} catch {
toast.error(t("medications.detail.intake.deleteRow.failed"));
}
}
return (
<div className="space-y-4" data-slot="dose-history-ledger">
{/* Header: the took/skipped summary chart + the add affordance. */}
<div className="flex items-start justify-between gap-3">
{compliance && compliance.denominator > 0 ? (
<LedgerSummaryChart compliance={compliance} />
) : (
<p className="text-muted-foreground text-sm">
{t("medications.detail.verlauf.summaryEmpty")}
</p>
)}
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="ledger-add"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.verlauf.add")}
</Button>
</div>
{isLoading && (
<div
className="text-muted-foreground flex items-center gap-2 py-6 text-sm"
role="status"
aria-busy="true"
>
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
{t("medications.detail.verlauf.loading")}
</div>
)}
{isError && (
<p className="text-destructive py-6 text-sm">
{t("medications.detail.verlauf.loadError")}
</p>
)}
{data && !isLoading && groups.length === 0 && (
<p className="text-muted-foreground py-6 text-sm">
{t("medications.detail.verlauf.empty")}
</p>
)}
{data &&
groups.map((group) => (
<section
key={group.dayKey}
className="space-y-1"
data-slot="ledger-day"
data-day={group.dayKey}
>
<h3 className="text-muted-foreground px-1 text-xs font-medium">
{fmt.dateWithWeekday(new Date(`${group.dayKey}T00:00:00`))}
</h3>
<ul className="border-border/60 divide-border/60 divide-y rounded-md border">
{group.rows.map((row, i) => (
<LedgerRowItem
key={`${row.kind}:${row.at}:${row.intake?.id ?? i}`}
row={row}
marking={marking}
onMark={markSlot}
onEdit={(ev) => setEditingEvent(ev)}
onDelete={(id) => setPendingDeleteId(id)}
/>
))}
</ul>
</section>
))}
<IntakeEditDialog
medicationId={medicationId}
event={editingEvent}
onClose={() => setEditingEvent(null)}
/>
{addOpen && (
<LedgerAddDialog
open={addOpen}
onOpenChange={setAddOpen}
medicationId={medicationId}
medicationName={medicationName}
schedules={schedules}
ledgerKey={queryKey}
/>
)}
{pendingDeleteId && (
<DeleteRowConfirm
onConfirm={() => void confirmRowDelete()}
onCancel={() => setPendingDeleteId(null)}
/>
)}
</div>
);
}
/**
* One ledger row: time + status pill + (when an intake is attributed) its real
* take time, plus the inline actions. A pending/missed slot offers Genommen /
* Übersprungen; a row with an intake offers Bearbeiten / Löschen.
*/
function LedgerRowItem({
row,
marking,
onMark,
onEdit,
onDelete,
}: {
row: LedgerRow;
marking: string | null;
onMark: (row: LedgerRow, action: "taken" | "skipped") => void;
onEdit: (event: {
id: string;
takenAt: string | null;
skipped: boolean;
}) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const actionable = isSlotActionable(row);
const intake = row.intake;
const takeBusy = marking === `${row.at}:taken`;
const skipBusy = marking === `${row.at}:skipped`;
// The leading time label: the slot's HH:mm for a slot row, else the ad-hoc
// take's real clock time.
const timeLabel =
row.timeOfDay ?? fmt.time(new Date(intake?.takenAt ?? row.at));
// When a take lands away from its slot, show both anchors so the row reads
// "geplant 07:00 · genommen 11:29".
const showTakeDetail =
row.kind === "slot" &&
intake?.takenAt &&
(row.status === "taken_on_time" || row.status === "taken_late");
return (
<li
className="flex items-center justify-between gap-2 px-3 py-2"
data-slot="ledger-row"
data-status={row.status}
data-kind={row.kind}
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-foreground text-sm font-medium tabular-nums">
{timeLabel}
</span>
<span
className={`inline-flex items-center gap-1 text-xs font-medium ${STATUS_TONE[row.status]}`}
data-slot="ledger-status"
>
<StatusGlyph status={row.status} />
{t(`medications.detail.verlauf.status.${row.status}`)}
</span>
{row.kind === "ad_hoc" && (
<Badge variant="outline" className="text-[0.65rem]">
{t("medications.detail.verlauf.adHocTag")}
</Badge>
)}
</div>
{showTakeDetail && (
<p className="text-muted-foreground text-xs">
{t("medications.detail.verlauf.takenDetail", {
planned: row.timeOfDay ?? fmt.time(new Date(row.at)),
taken: fmt.time(new Date(intake!.takenAt!)),
})}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{actionable && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onMark(row, "taken")}
disabled={takeBusy || skipBusy}
aria-busy={takeBusy || undefined}
className="min-h-9"
data-slot="ledger-mark-taken"
>
{takeBusy ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<Check aria-hidden="true" className="size-4" />
)}
<span className="hidden sm:inline">
{t("medications.detail.verlauf.markTaken")}
</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onMark(row, "skipped")}
disabled={takeBusy || skipBusy}
aria-busy={skipBusy || undefined}
className="min-h-9"
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="size-4" />
)}
<span className="hidden sm:inline">
{t("medications.detail.verlauf.markSkipped")}
</span>
</Button>
</>
)}
{intake?.id && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
})
}
className="min-h-9"
data-slot="ledger-edit"
>
{t("medications.detail.intake.rowActions.edit")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(intake.id as string)}
className="text-destructive min-h-9"
data-slot="ledger-delete"
>
{t("medications.detail.intake.rowActions.delete")}
</Button>
</>
)}
</div>
</li>
);
}
/**
* Compact took / skipped history chart — a single proportional bar split into
* on-time / late / missed, with the headline rate beside it. No Recharts: a
* three-segment CSS bar is calmer + lighter for a summary this small, and it
* tracks the ledger the user is already reading.
*/
function LedgerSummaryChart({
compliance,
}: {
compliance: NonNullable<ReturnType<typeof complianceFromLedger>>;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { takenOnTime, takenLate, missed, denominator, rate } = compliance;
const pct = (n: number) => (denominator > 0 ? (n / denominator) * 100 : 0);
return (
<div className="min-w-0 flex-1 space-y-1.5" data-slot="ledger-summary">
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground text-xs font-medium">
{t("medications.detail.verlauf.adherenceLabel")}
</span>
<span className="text-foreground text-sm font-semibold tabular-nums">
{rate !== null ? `${fmt.number(rate)}%` : "—"}
</span>
</div>
<div
className="bg-muted flex h-2 overflow-hidden rounded-full"
role="img"
aria-label={t("medications.detail.verlauf.adherenceBarLabel", {
onTime: takenOnTime,
late: takenLate,
missed,
})}
>
<div
className="bg-success h-full"
style={{ width: `${pct(takenOnTime)}%` }}
/>
<div
className="bg-warning h-full"
style={{ width: `${pct(takenLate)}%` }}
/>
<div
className="bg-destructive h-full"
style={{ width: `${pct(missed)}%` }}
/>
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 text-[0.7rem]">
<span className="text-success">
{t("medications.detail.verlauf.status.taken_on_time")}: {takenOnTime}
</span>
<span className="text-warning">
{t("medications.detail.verlauf.status.taken_late")}: {takenLate}
</span>
<span className="text-destructive">
{t("medications.detail.verlauf.status.missed")}: {missed}
</span>
</div>
</div>
);
}
/** Lightweight single-row delete confirm — the ledger does not need the full
* bulk-delete state machine, just a guard. */
function DeleteRowConfirm({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog open onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.deleteRow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.deleteRow.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -124,15 +124,15 @@ interface Glp1MedicationCardProps {
medication: Glp1Medication;
onEdit: (med: Glp1Medication) => void;
/**
* v1.7.1 — routes to the medication's full intake-history view
* (`/medications/{id}/history`), mirroring the generic medication card
* and the detail-header History button. The parent owns the navigation.
* v1.15.18 — navigates to the medication detail page's Verlauf tab
* (`/medications/{id}?tab=verlauf`), mirroring the generic medication
* card. The parent owns the navigation.
*/
onOpenHistory: (med: Glp1Medication) => void;
/**
* v1.7.1 — opens the shared `<AdvancedSettingsSheet>` (mounted by the
* list page) for this medication, mirroring the generic medication card
* and the detail-header sliders button.
* v1.15.18 — navigates to the medication detail page's Erweitert tab
* (`/medications/{id}?tab=erweitert`), the home of the dissolved
* advanced settings.
*/
onOpenAdvanced: (med: Glp1Medication) => void;
onLogSideEffect?: (med: Glp1Medication) => void;
@@ -94,15 +94,14 @@ interface MedicationCardProps {
medication: Medication;
onEdit: (med: Medication) => void;
/**
* v1.7.1 — routes to the medication's full intake-history view
* (`/medications/{id}/history`), mirroring the detail-header History
* button. The parent owns the navigation.
* v1.15.18 — navigates to the medication detail page's Verlauf tab
* (`/medications/{id}?tab=verlauf`). The parent owns the navigation.
*/
onOpenHistory: (med: Medication) => void;
/**
* v1.7.1 — opens the shared `<AdvancedSettingsSheet>` (mounted by the
* list page) for this medication, mirroring the detail-header sliders
* button.
* v1.15.18 — navigates to the medication detail page's Erweitert tab
* (`/medications/{id}?tab=erweitert`), the home of the dissolved
* advanced settings.
*/
onOpenAdvanced: (med: Medication) => void;
}
@@ -0,0 +1,240 @@
"use client";
/**
* v1.15.18 — per-dose configurable on-time WINDOW editor (Marc's headline
* lever). Shared verbatim by the Zeitplan tab (everyday inline edit) and
* the wizard's Step 7 (structural create / edit) so the window semantics
* are identical wherever a dose time is set.
*
* Per dose time the user expresses EITHER a point time (e.g. 19:00 →
* implies the ±1h default band) OR an explicit RANGE (e.g. 07:00–09:00).
* The editor shows the resulting on-time band plus a cadence-aware
* "verspätet bis …" hint derived from the engine's late tail. It is a
* controlled component over the persisted `DoseWindowEntry[]` contract
* (`{ timeOfDay, start, end }`), validated client-side to match the W7
* zod: HH:mm, `start <= end`, `timeOfDay ∈ timesOfDay`.
*
* Calm, inset-grouped, AA. No card tint — the surface follows the muted
* section chrome the other Zeitplan / Erinnerung rows use.
*/
import { useCallback, useId, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import {
buildRows,
defaultBandForTime,
isOrderedRange,
isValidHhmm,
lateTailDays,
lateTailEndHhmm,
rowsToEntries,
type DoseWindowEntry,
type DoseWindowRow,
type DoseWindowScale,
} from "./dose-window";
export interface DoseWindowEditorProps {
/** The schedule's dose times (HH:mm) — one editor row per time. */
timesOfDay: string[];
/** The persisted explicit windows for this schedule. */
value: DoseWindowEntry[];
/** Emits the next explicit-window set (point-equivalent rows dropped). */
onChange: (next: DoseWindowEntry[]) => void;
/**
* Cadence scale — drives the late-tail hint. `intraday` (daily /
* multi-daily) shows a wall-clock late end; `dayScale` (weekly /
* rolling) shows the day-count tail. Defaults to `intraday`.
*/
scale?: DoseWindowScale;
/** Disables every control (e.g. while a save is in flight). */
disabled?: boolean;
/** Stable id-prefix for the rendered inputs (defaults to a generated id). */
idPrefix?: string;
}
export function DoseWindowEditor({
timesOfDay,
value,
onChange,
scale = "intraday",
disabled = false,
idPrefix,
}: DoseWindowEditorProps) {
const { t } = useTranslations();
const generatedId = useId();
const prefix = idPrefix ?? generatedId;
const rows = useMemo(
() => buildRows(timesOfDay, value),
[timesOfDay, value],
);
// Rebuild the persisted set from the next row state and bubble it up.
const emit = useCallback(
(nextRows: DoseWindowRow[]) => {
onChange(rowsToEntries(nextRows));
},
[onChange],
);
const setRow = useCallback(
(timeOfDay: string, patch: Partial<DoseWindowRow>) => {
emit(
rows.map((r) =>
r.timeOfDay === timeOfDay ? { ...r, ...patch } : r,
),
);
},
[emit, rows],
);
const toggleCustom = useCallback(
(row: DoseWindowRow, custom: boolean) => {
if (custom) {
// Switching ON seeds the range from the current default band so
// the user starts from the same band the engine would derive.
const def = defaultBandForTime(row.timeOfDay);
setRow(row.timeOfDay, { custom: true, start: def.start, end: def.end });
} else {
setRow(row.timeOfDay, { custom: false });
}
},
[setRow],
);
if (rows.length === 0) {
return (
<p
className="text-muted-foreground text-sm"
data-slot="dose-window-editor-empty"
>
{t("medications.detail.zeitplan.window.noTimes")}
</p>
);
}
return (
<div className="space-y-3" data-slot="dose-window-editor">
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.window.intro")}
</p>
<ul className="divide-border divide-y rounded-md border" role="list">
{rows.map((row) => {
const ordered = isOrderedRange(row.start, row.end);
const lateEnd = lateTailEndHhmm(row.end, scale);
const switchId = `${prefix}-custom-${row.timeOfDay}`;
const startId = `${prefix}-start-${row.timeOfDay}`;
const endId = `${prefix}-end-${row.timeOfDay}`;
return (
<li
key={row.timeOfDay}
className="space-y-2 p-3"
data-slot="dose-window-row"
data-time={row.timeOfDay}
data-custom={row.custom ? "true" : "false"}
>
<div className="flex items-center justify-between gap-3">
<span className="text-foreground text-sm font-medium tabular-nums">
{row.timeOfDay}
</span>
<div className="flex items-center gap-2">
<Label
htmlFor={switchId}
className="text-muted-foreground text-xs"
>
{t("medications.detail.zeitplan.window.explicitToggle")}
</Label>
<Switch
id={switchId}
checked={row.custom}
disabled={disabled}
onCheckedChange={(checked) => toggleCustom(row, checked)}
data-slot="dose-window-explicit-switch"
/>
</div>
</div>
{row.custom ? (
<div className="space-y-2" data-slot="dose-window-range">
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor={startId} className="sr-only">
{t("medications.detail.zeitplan.window.startLabel")}
</Label>
<Input
id={startId}
type="time"
value={row.start}
disabled={disabled}
onChange={(e) =>
setRow(row.timeOfDay, { start: e.target.value })
}
className="h-11 w-32 sm:h-9"
aria-label={t(
"medications.detail.zeitplan.window.startLabel",
)}
data-slot="dose-window-start"
/>
<span className="text-muted-foreground text-sm">–</span>
<Label htmlFor={endId} className="sr-only">
{t("medications.detail.zeitplan.window.endLabel")}
</Label>
<Input
id={endId}
type="time"
value={row.end}
disabled={disabled}
onChange={(e) =>
setRow(row.timeOfDay, { end: e.target.value })
}
className="h-11 w-32 sm:h-9"
aria-label={t(
"medications.detail.zeitplan.window.endLabel",
)}
data-slot="dose-window-end"
/>
</div>
{isValidHhmm(row.start) &&
isValidHhmm(row.end) &&
!ordered ? (
<p
className="text-destructive text-xs"
role="alert"
data-slot="dose-window-error"
>
{t("medications.detail.zeitplan.window.orderError")}
</p>
) : null}
</div>
) : null}
{/* The resulting on-time band + the late tail. Always shown so
the point case still communicates its implied ±1h band. */}
<p
className="text-muted-foreground text-xs"
data-slot="dose-window-band"
>
{t("medications.detail.zeitplan.window.onTimeBand", {
start: row.start,
end: row.end,
})}
{scale === "dayScale"
? ` · ${t("medications.detail.zeitplan.window.lateDays", {
days: lateTailDays(),
})}`
: lateEnd
? ` · ${t("medications.detail.zeitplan.window.lateUntil", {
time: lateEnd,
})}`
: ""}
</p>
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,276 @@
"use client";
/**
* v1.15.18 — Zeitplan tab inline schedule editor.
*
* The everyday levers on an EXISTING schedule — the dose times-of-day and
* each dose's on-time window (via `<DoseWindowEditor>`) — without
* re-walking the wizard. Frequency / cadence-kind stays a structural
* concern: the tab links out to "Vollständig bearbeiten" for that.
*
* Self-saves via `PUT /api/medications/[id]` with the FULL `schedules`
* array (the route replaces schedules wholesale, so every cadence field
* is preserved verbatim from the snapshot and only the times + windows
* change). Reminder grace is a READ-ONLY echo here — its single owner is
* the Erinnerung tab.
*
* Calm, inset-grouped, AA. No card tint.
*/
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
import { DoseWindowEditor } from "@/components/medications/scheduling/DoseWindowEditor";
import {
type DoseWindowEntry,
type DoseWindowScale,
} from "@/components/medications/scheduling/dose-window";
import { useTranslations } from "@/lib/i18n/context";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
/** The schedule fields the inline editor reads + round-trips on save. */
export interface EditableSchedule {
id: string;
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
daysOfWeek: string | null;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
reminderGraceMinutes?: number | null;
scheduleType?: string | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
doseWindows?: DoseWindowEntry[] | null;
}
/** Per-schedule working state (times + windows are the only mutable parts). */
interface ScheduleEdit {
timesOfDay: string[];
doseWindows: DoseWindowEntry[];
}
/**
* Classify the cadence scale for the late-tail hint. A weekly / monthly
* RRULE or any rolling interval is day-scale (the 4-day late rule); a
* daily / multi-daily med is intraday (the ±1h / +3h minute bands).
*/
function scaleForSchedule(s: EditableSchedule): DoseWindowScale {
if (typeof s.rollingIntervalDays === "number" && s.rollingIntervalDays >= 2) {
return "dayScale";
}
const rrule = s.rrule ?? "";
if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/.test(rrule)) return "dayScale";
return "intraday";
}
/** Effective dose times for a schedule (timesOfDay, else legacy windowStart). */
function effectiveTimes(s: EditableSchedule): string[] {
return s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
}
export function ScheduleTimesEditor({
medicationId,
schedules,
onRequestReminderTab,
}: {
medicationId: string;
schedules: EditableSchedule[];
/** Jump the user to the Erinnerung tab (the grace owner). */
onRequestReminderTab?: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const initial = useMemo<ScheduleEdit[]>(
() =>
schedules.map((s) => ({
timesOfDay: effectiveTimes(s),
doseWindows: s.doseWindows ?? [],
})),
[schedules],
);
const [edits, setEdits] = useState<ScheduleEdit[]>(initial);
const [busy, setBusy] = useState(false);
function patchSchedule(index: number, patch: Partial<ScheduleEdit>) {
setEdits((prev) =>
prev.map((e, i) => (i === index ? { ...e, ...patch } : e)),
);
}
async function save() {
if (busy) return;
setBusy(true);
try {
// Replace schedules wholesale — preserve every cadence field from
// the snapshot field-by-field, change only the times + windows.
const body = {
schedules: schedules.map((s, i) => {
const edit = edits[i];
const times =
edit.timesOfDay.length > 0 ? edit.timesOfDay : [s.windowStart];
const recurrence = parseScheduleRecurrence(s.daysOfWeek);
// Drop windows whose timeOfDay no longer names a live dose time
// (so a removed time can't leave an orphaned window the server
// would 422 on).
const liveTimes = new Set(times);
const windows = edit.doseWindows.filter((w) =>
liveTimes.has(w.timeOfDay),
);
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? undefined,
dose: s.dose ?? undefined,
timesOfDay: times,
daysOfWeek: recurrence.daysOfWeek,
intervalWeeks: recurrence.intervalWeeks,
...(s.rrule ? { rrule: s.rrule } : {}),
...(typeof s.rollingIntervalDays === "number"
? { rollingIntervalDays: s.rollingIntervalDays }
: {}),
...(typeof s.reminderGraceMinutes === "number"
? { reminderGraceMinutes: s.reminderGraceMinutes }
: {}),
...(s.scheduleType
? {
scheduleType: s.scheduleType as
| "SCHEDULED"
| "PRN"
| "CYCLIC",
}
: {}),
...(typeof s.cyclicOnWeeks === "number"
? { cyclicOnWeeks: s.cyclicOnWeeks }
: {}),
...(typeof s.cyclicOffWeeks === "number"
? { cyclicOffWeeks: s.cyclicOffWeeks }
: {}),
doseWindows: windows,
};
}),
};
const res = await fetch(`/api/medications/${medicationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
toast.error(t("medications.detail.zeitplan.failed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.zeitplan.saved"));
} catch {
toast.error(t("medications.detail.zeitplan.failed"));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6" data-slot="schedule-times-editor">
{schedules.map((s, i) => {
const edit = edits[i];
const scale = scaleForSchedule(s);
const grace =
typeof s.reminderGraceMinutes === "number"
? s.reminderGraceMinutes
: null;
return (
<div
key={s.id}
className="space-y-4"
data-slot="schedule-times-editor-schedule"
data-schedule-id={s.id}
>
{schedules.length > 1 ? (
<h4 className="text-foreground text-sm font-medium">
{s.label ??
t("medications.detail.zeitplan.scheduleLabel", {
index: i + 1,
})}
</h4>
) : null}
<div className="space-y-2">
<Label className="text-foreground text-sm font-medium">
{t("medications.detail.zeitplan.timesTitle")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.timesNote")}
</p>
<TimesOfDayChips
value={edit.timesOfDay}
onChange={(timesOfDay) => patchSchedule(i, { timesOfDay })}
maxChips={
typeof s.rollingIntervalDays === "number" ? 1 : 8
}
/>
</div>
<DoseWindowEditor
timesOfDay={edit.timesOfDay}
value={edit.doseWindows}
onChange={(doseWindows) => patchSchedule(i, { doseWindows })}
scale={scale}
disabled={busy}
idPrefix={`dose-window-${s.id}`}
/>
{grace !== null ? (
<p
className="text-muted-foreground text-xs"
data-slot="zeitplan-grace-echo"
>
{t("medications.detail.zeitplan.graceEcho", {
minutes: grace,
})}
{onRequestReminderTab ? (
<>
{" · "}
<button
type="button"
onClick={onRequestReminderTab}
className="text-primary underline-offset-2 hover:underline focus-visible:ring-ring rounded-sm focus-visible:ring-2 focus-visible:outline-none"
data-slot="zeitplan-grace-link"
>
{t("medications.detail.zeitplan.graceLink")}
</button>
</>
) : null}
</p>
) : null}
</div>
);
})}
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.fullEditNote")}
</p>
<Button
onClick={() => void save()}
disabled={busy}
aria-busy={busy || undefined}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="zeitplan-save"
>
{t("medications.detail.zeitplan.save")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import {
buildRows,
defaultBandForTime,
entriesByTime,
hhmmToMinutes,
isExplicitRange,
isOrderedRange,
isValidHhmm,
lateTailDays,
lateTailEndHhmm,
minutesToHhmm,
rowsToEntries,
type DoseWindowEntry,
type DoseWindowRow,
} from "../dose-window";
describe("dose-window helpers", () => {
describe("hh:mm conversion", () => {
it("round-trips minutes", () => {
expect(hhmmToMinutes("07:00")).toBe(420);
expect(hhmmToMinutes("00:00")).toBe(0);
expect(hhmmToMinutes("23:59")).toBe(1439);
expect(minutesToHhmm(420)).toBe("07:00");
expect(minutesToHhmm(0)).toBe("00:00");
});
it("wraps minutes into the day", () => {
expect(minutesToHhmm(-60)).toBe("23:00");
expect(minutesToHhmm(1440 + 30)).toBe("00:30");
});
it("validates HH:mm", () => {
expect(isValidHhmm("07:00")).toBe(true);
expect(isValidHhmm("24:00")).toBe(false);
expect(isValidHhmm("7:00")).toBe(false);
expect(isValidHhmm("07:60")).toBe(false);
});
});
describe("defaultBandForTime", () => {
it("centres a ±1h band on the dose time", () => {
expect(defaultBandForTime("08:00")).toEqual({
start: "07:00",
end: "09:00",
});
expect(defaultBandForTime("19:00")).toEqual({
start: "18:00",
end: "20:00",
});
});
it("wraps a band near midnight", () => {
expect(defaultBandForTime("00:30")).toEqual({
start: "23:30",
end: "01:30",
});
});
});
describe("late tail", () => {
it("intraday returns end + 3h", () => {
// dailyOverdueMinutes = 240 - 60 = 180.
expect(lateTailEndHhmm("09:00", "intraday")).toBe("12:00");
});
it("day-scale returns null HH:mm (counted in days)", () => {
expect(lateTailEndHhmm("09:00", "dayScale")).toBeNull();
});
it("exposes the 4-day day-scale tail", () => {
expect(lateTailDays()).toBe(4);
});
});
describe("isExplicitRange", () => {
it("is false for a point-equivalent band", () => {
expect(
isExplicitRange({ timeOfDay: "08:00", start: "07:00", end: "09:00" }),
).toBe(false);
});
it("is true for a widened band", () => {
expect(
isExplicitRange({ timeOfDay: "08:00", start: "07:00", end: "11:00" }),
).toBe(true);
expect(
isExplicitRange({ timeOfDay: "08:00", start: "08:00", end: "09:00" }),
).toBe(true);
});
});
describe("entriesByTime", () => {
it("indexes by timeOfDay and tolerates undefined", () => {
expect(entriesByTime(undefined).size).toBe(0);
const map = entriesByTime([
{ timeOfDay: "08:00", start: "07:00", end: "11:00" },
]);
expect(map.get("08:00")?.end).toBe("11:00");
});
});
describe("buildRows", () => {
it("sorts times and applies stored explicit ranges", () => {
const rows = buildRows(
["20:00", "08:00"],
[{ timeOfDay: "08:00", start: "07:00", end: "12:00" }],
);
expect(rows.map((r) => r.timeOfDay)).toEqual(["08:00", "20:00"]);
expect(rows[0]).toEqual({
timeOfDay: "08:00",
start: "07:00",
end: "12:00",
custom: true,
});
// 20:00 has no stored window → default ±1h, not custom.
expect(rows[1]).toEqual({
timeOfDay: "20:00",
start: "19:00",
end: "21:00",
custom: false,
});
});
it("treats a point-equivalent stored entry as the default (not custom)", () => {
const rows = buildRows(
["08:00"],
[{ timeOfDay: "08:00", start: "07:00", end: "09:00" }],
);
expect(rows[0].custom).toBe(false);
});
it("drops malformed times", () => {
expect(buildRows(["bad", "08:00"], []).map((r) => r.timeOfDay)).toEqual([
"08:00",
]);
});
});
describe("rowsToEntries", () => {
it("keeps only custom rows that differ from default", () => {
const rows: DoseWindowRow[] = [
{ timeOfDay: "08:00", start: "07:00", end: "12:00", custom: true },
{ timeOfDay: "20:00", start: "19:00", end: "21:00", custom: false },
// custom flag on, but band equals default → dropped.
{ timeOfDay: "12:00", start: "11:00", end: "13:00", custom: true },
];
const out = rowsToEntries(rows);
expect(out).toEqual<DoseWindowEntry[]>([
{ timeOfDay: "08:00", start: "07:00", end: "12:00" },
]);
});
it("drops rows with malformed bounds", () => {
const rows: DoseWindowRow[] = [
{ timeOfDay: "08:00", start: "07:00", end: "", custom: true },
];
expect(rowsToEntries(rows)).toEqual([]);
});
});
describe("isOrderedRange", () => {
it("accepts start <= end", () => {
expect(isOrderedRange("07:00", "09:00")).toBe(true);
expect(isOrderedRange("09:00", "09:00")).toBe(true);
});
it("rejects start > end and malformed input", () => {
expect(isOrderedRange("10:00", "09:00")).toBe(false);
expect(isOrderedRange("bad", "09:00")).toBe(false);
});
});
});
@@ -0,0 +1,184 @@
/**
* v1.15.18 — per-dose configurable on-time WINDOW helpers (Marc's
* headline lever).
*
* A dose time can be expressed two ways:
* - a POINT (e.g. 19:00) → the engine's symmetric ±1h default band;
* - an explicit RANGE (e.g. 07:00–09:00) → persisted as a
* `{ timeOfDay, start, end }` entry on the schedule's `doseWindows`.
*
* These pure helpers translate between the persisted `DoseWindowEntry[]`
* contract (validated by `doseWindowEntrySchema`) and the per-time view
* model the `<DoseWindowEditor>` renders, and derive the displayed
* on-time band + the cadence-aware late tail ("verspätet bis …"). They
* are pure + framework-free so the window math is unit-tested without a
* render.
*
* Defaults mirror `DOSE_WINDOW_DEFAULTS` so a POINT time reads identically
* to the engine's no-override derivation:
* - intraday (daily / multi-daily): ±60 min on-time, +180 min late tail;
* - day-scale (weekly / rolling): ±1 day on-time, +4 days late tail.
*/
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
/** One persisted explicit window. Matches `doseWindowEntrySchema`. */
export interface DoseWindowEntry {
timeOfDay: string;
start: string;
end: string;
}
/**
* Window scale. `intraday` = a daily / multi-daily med (minute-scale
* bands, the HH:mm range is the on-time window). `dayScale` = a weekly /
* rolling injectable (the on-time window is whole-day; the explicit HH:mm
* range still anchors the same wall-clock target but the late tail is
* counted in days). The caller derives this from the schedule cadence.
*/
export type DoseWindowScale = "intraday" | "dayScale";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/** Minutes-since-midnight for a regex-valid HH:mm. */
export function hhmmToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":");
return Number(h) * 60 + Number(m);
}
/** Wrap minutes-since-midnight back to HH:mm (clamps into the day, 24h wrap). */
export function minutesToHhmm(total: number): string {
const wrapped = ((total % 1440) + 1440) % 1440;
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** True when the literal is a well-formed HH:mm. */
export function isValidHhmm(value: string): boolean {
return TIME_RE.test(value);
}
/**
* The symmetric default on-time band around a POINT time (±1h for
* intraday). Day-scale targets still surface the ±1h clock band as the
* default range — the whole-day on-time tolerance is the engine's
* concern; the editor only ever stores a wall-clock HH:mm range.
*/
export function defaultBandForTime(time: string): { start: string; end: string } {
if (!isValidHhmm(time)) return { start: time, end: time };
const half = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes;
const centre = hhmmToMinutes(time);
return {
start: minutesToHhmm(centre - half),
end: minutesToHhmm(centre + half),
};
}
/**
* The late-tail end after the on-time window closes, scale-aware. For an
* intraday med this is `end + dailyOverdueMinutes` (a wall-clock HH:mm); a
* day-scale med returns `null` for the HH:mm (the tail is counted in days,
* surfaced via {@link lateTailDays}).
*/
export function lateTailEndHhmm(
end: string,
scale: DoseWindowScale,
): string | null {
if (scale === "dayScale") return null;
if (!isValidHhmm(end)) return null;
return minutesToHhmm(
hhmmToMinutes(end) + DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
);
}
/** The late-tail length in days for a day-scale med (the 4-day rule). */
export function lateTailDays(): number {
return DOSE_WINDOW_DEFAULTS.weeklyOverdueDays;
}
/**
* An entry is an EXPLICIT range (not the default point band) when its
* `[start, end]` differs from the symmetric ±1h band around `timeOfDay`.
* A point-equivalent entry is dropped on serialise so the column stays
* NULL-equivalent when the user touched nothing meaningful.
*/
export function isExplicitRange(entry: DoseWindowEntry): boolean {
const def = defaultBandForTime(entry.timeOfDay);
return entry.start !== def.start || entry.end !== def.end;
}
/** Index the persisted entries by `timeOfDay` for O(1) per-time lookup. */
export function entriesByTime(
entries: DoseWindowEntry[] | undefined,
): Map<string, DoseWindowEntry> {
const map = new Map<string, DoseWindowEntry>();
for (const e of entries ?? []) map.set(e.timeOfDay, e);
return map;
}
/**
* Build the per-time view model the editor renders: for every dose time,
* the explicit range when one is stored, else the default ±1h band, plus a
* `custom` flag (a stored explicit range that differs from the default).
*/
export interface DoseWindowRow {
timeOfDay: string;
start: string;
end: string;
/** True when a stored explicit range differs from the default band. */
custom: boolean;
}
export function buildRows(
timesOfDay: string[],
entries: DoseWindowEntry[] | undefined,
): DoseWindowRow[] {
const byTime = entriesByTime(entries);
return [...timesOfDay]
.filter(isValidHhmm)
.sort((a, b) => a.localeCompare(b))
.map((timeOfDay) => {
const stored = byTime.get(timeOfDay);
if (stored && isExplicitRange(stored)) {
return {
timeOfDay,
start: stored.start,
end: stored.end,
custom: true,
};
}
const def = defaultBandForTime(timeOfDay);
return { timeOfDay, start: def.start, end: def.end, custom: false };
});
}
/**
* Serialise the editor rows back to the persisted `DoseWindowEntry[]`.
* Only rows the user marked `custom` and whose `[start, end]` actually
* differs from the default band survive — a point-equivalent row is
* dropped so the column stays minimal (and a med with no custom windows
* serialises to `[]`, i.e. every slot keeps the default derivation).
* Returns `null`-safe normalised entries; the caller decides whether `[]`
* means "send empty array" or "omit".
*/
export function rowsToEntries(rows: DoseWindowRow[]): DoseWindowEntry[] {
const out: DoseWindowEntry[] = [];
for (const r of rows) {
if (!r.custom) continue;
if (!isValidHhmm(r.start) || !isValidHhmm(r.end)) continue;
const entry: DoseWindowEntry = {
timeOfDay: r.timeOfDay,
start: r.start,
end: r.end,
};
if (isExplicitRange(entry)) out.push(entry);
}
return out;
}
/** True when `start <= end` within the day (mirrors the server refine). */
export function isOrderedRange(start: string, end: string): boolean {
if (!isValidHhmm(start) || !isValidHhmm(end)) return false;
return hhmmToMinutes(start) <= hhmmToMinutes(end);
}
@@ -1,71 +0,0 @@
/**
* v1.5.6 G-1 §5 — phase sibling-swap wiring on `<SettingsSection>`.
*
* When the hosting `<AdvancedSettingsSheet>` passes
* `onRequestPhaseSheet`, the section must NOT mount its own
* `<PhaseConfigSheet>` — the parent orchestrates the swap (close the
* advanced sheet, then open the phase sheet) so the two never stack.
* Without the callback the section keeps owning its own phase sheet,
* preserving the standalone-surface fallback.
*
* `<PhaseConfigSheet>` is mocked to a marker so its presence /
* absence is observable in static markup regardless of open-state.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@/components/medications/sections/api-tokens-row", () => ({
ApiTokensRow: () => <div data-slot="mock-api-tokens" />,
}));
vi.mock("@/components/medications/sections/phase-config-sheet", () => ({
PhaseConfigSheet: () => <div data-slot="mock-phase-config-sheet" />,
}));
import { SettingsSection } from "@/components/medications/sections/settings-section";
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
},
});
}
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<QueryClientProvider client={makeClient()}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
// GLP-1 + a set course window so the Phasen row + button mount.
const glp1Props = {
medicationId: "med-1",
medicationName: "Test Drug",
treatmentClass: "GLP1",
startsOn: "2026-01-01",
endsOn: "2026-06-01",
reminderGraceMinutes: 30,
};
describe("SettingsSection phase sibling-swap (G-1 §5)", () => {
it("does NOT self-mount the phase sheet when onRequestPhaseSheet is provided", () => {
const html = render(
<SettingsSection {...glp1Props} onRequestPhaseSheet={() => {}} />,
);
expect(html).toContain("medication-detail-phase-management-button");
expect(html).not.toContain("mock-phase-config-sheet");
});
it("self-mounts the phase sheet on the standalone surface (no callback)", () => {
const html = render(<SettingsSection {...glp1Props} />);
expect(html).toContain("medication-detail-phase-management-button");
expect(html).toContain("mock-phase-config-sheet");
});
});
@@ -3,10 +3,10 @@
/**
* v1.5.5 D-3 §9.8 — Verwaltung & Gefahrenzone.
*
* v1.7.0 — split into two reusable bodies so the redesigned
* `<AdvancedSettingsSheet>` can slot them under different groups:
* `<LifecycleManageBody>` (Pause + End → Lifecycle group) and
* `<DangerZoneBody>` (Purge + Delete → Danger zone group).
* v1.7.0 — split into two reusable bodies so the detail page's Erweitert
* tab can slot them under different groups: `<LifecycleManageBody>`
* (Pause + End → Lifecycle group) and `<DangerZoneBody>` (Purge + Delete
* → Danger zone group).
*
* Mutation contracts per D-3 §3:
*
@@ -0,0 +1,130 @@
"use client";
/**
* v1.15.18 — Bestand (supply) tab body.
*
* Read-only inventory readout for the medication detail page's Supply
* tab. Reads `GET /api/medications/[id]/inventory` (the same per-item
* list the pen/vial CRUD route serves) and renders a calm summary — the
* doses remaining across every non-terminal item plus a per-item list
* with its state badge. Shown for ALL medications (pill packs count too);
* the richer pen math is implicit in the per-item doses figures.
*
* No write affordance here yet — registering a pen still rides the
* dedicated inventory route; the tab is the read surface the spec asks
* for. An empty inventory shows a quiet empty state, not a spinner.
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, PackageOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
interface InventoryItem {
id: string;
state: InventoryState;
dosesTotal: number;
dosesRemaining: number;
}
interface InventoryResponse {
items: InventoryItem[];
meta?: { total: number };
}
const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "destructive"> = {
ACTIVE: "secondary",
IN_USE: "secondary",
EXPIRED: "destructive",
USED_UP: "outline",
};
export function InventorySection({ medicationId }: { medicationId: string }) {
const { t } = useTranslations();
const { data, isLoading } = useQuery<InventoryResponse>({
queryKey: queryKeys.medicationInventory(medicationId),
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/inventory`);
if (!res.ok) throw new Error("inventory_failed");
return (await res.json()).data as InventoryResponse;
},
staleTime: 30_000,
});
if (isLoading) {
return (
<div
className="flex h-32 items-center justify-center"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2
aria-hidden="true"
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
/>
</div>
);
}
const items = Array.isArray(data?.items) ? data.items : [];
// Active supply: doses left across every non-terminal (not used-up) item.
const live = items.filter((i) => i.state !== "USED_UP");
const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0);
const total = live.reduce((sum, i) => sum + i.dosesTotal, 0);
if (items.length === 0) {
return (
<EmptyState
icon={<PackageOpen className="size-6" />}
title={t("medications.detail.bestand.empty")}
description={t("medications.detail.bestand.emptyHelper")}
/>
);
}
return (
<div className="space-y-4" data-slot="medication-inventory-section">
<SettingsGroup
label={t("medications.detail.bestand.title")}
dataSlot="inventory-summary-group"
>
<div className="py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.summary", { remaining, total })}
</p>
</div>
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.bestand.itemsTitle")}
dataSlot="inventory-items-group"
>
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-3 py-3"
data-slot="inventory-item-row"
>
<span className="text-foreground text-sm">
{t("medications.detail.bestand.doses", {
remaining: item.dosesRemaining,
total: item.dosesTotal,
})}
</span>
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
</div>
))}
</SettingsGroup>
</div>
);
}
@@ -4,12 +4,12 @@
* v1.5.5 D-3 §9.7 — Settings rows.
*
* v1.7.0 — the monolithic "Settings" card is decomposed into reusable
* rows so the redesigned `<AdvancedSettingsSheet>` can slot each one
* under the right group: API tokens → Data, Grace → Reminders,
* Phasen → Lifecycle. The standalone `<SettingsSection>` wrapper stays
* (it composes the rows under one section card) so existing callers +
* tests keep working. The CSV-import stub is gone — the sheet's Data
* group carries a real import button.
* rows so each can slot under the right group: API tokens → Data, Grace →
* Reminders, Phasen → Lifecycle.
*
* v1.15.18 — the standalone `<SettingsSection>` wrapper is retired with
* the modal advanced-settings sheet; the detail page's tabs consume the
* individual rows (`GraceRow` / `DrugCodingRow` / `PhasesRow`) directly.
*
* Per H-4-UX: the Grace row's label flags it as primary-schedule
* scoped so the user knows a multi-schedule medication does not get a
@@ -23,31 +23,10 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface SettingsSectionProps {
medicationId: string;
medicationName: string;
treatmentClass?: string;
startsOn?: string | null;
endsOn?: string | null;
reminderGraceMinutes?: number | null;
/**
* v1.5.6 G-1 §5 — sibling-swap the phase sheet. When provided, the
* Phasen button hands control to the parent (which closes the
* hosting sheet before opening `<PhaseConfigSheet>`) instead of the
* self-managed `phaseSheetOpen` state, so the two sheets never
* stack. Absent on the standalone surface, where the section owns
* its own phase sheet.
*/
onRequestPhaseSheet?: () => void;
}
/**
* v1.7.0 — primary-schedule reminder-window (grace) row. Self-saves via
* `PUT /api/medications/[id]` with a top-level `reminderGraceMinutes`.
@@ -313,54 +292,3 @@ export function PhasesRow({
);
}
/**
* Standalone section wrapper — composes the rows under one section
* card. Retained for callers / tests that expect the bundled shape.
*/
export function SettingsSection({
medicationId,
medicationName,
treatmentClass,
startsOn,
endsOn,
reminderGraceMinutes,
onRequestPhaseSheet,
}: SettingsSectionProps) {
const { t } = useTranslations();
const isGlp1 = treatmentClass === "GLP1";
return (
<MedicationDetailSection
titleId="medication-detail-settings-heading"
title={t("medications.detail.settings.title")}
dataSlot="medication-detail-settings-section"
>
<div className="space-y-4" data-slot="medication-detail-settings-body">
<ApiTokensRow
medicationId={medicationId}
medicationName={medicationName}
/>
{isGlp1 && (
<>
<Separator />
<PhasesRow
medicationId={medicationId}
treatmentClass={treatmentClass}
startsOn={startsOn}
endsOn={endsOn}
onRequestPhaseSheet={onRequestPhaseSheet}
/>
</>
)}
<Separator />
<GraceRow
medicationId={medicationId}
reminderGraceMinutes={reminderGraceMinutes}
/>
</div>
</MedicationDetailSection>
);
}
@@ -895,3 +895,100 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => {
]);
});
});
describe("v1.15.18 per-dose windows — encode + hydrate round-trip", () => {
it("emits only explicit, live-time windows in the create body", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["08:00", "20:00"],
doseWindows: [
// explicit widened band → kept
{ timeOfDay: "08:00", start: "07:00", end: "12:00" },
// point-equivalent (±1h) → dropped
{ timeOfDay: "20:00", start: "19:00", end: "21:00" },
],
};
const body = buildCreateBody(p);
expect(body.schedules[0].doseWindows).toEqual([
{ timeOfDay: "08:00", start: "07:00", end: "12:00" },
]);
});
it("omits doseWindows when every dose uses the default band", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["08:00"],
doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "09:00" }],
};
expect(buildCreateBody(p).schedules[0].doseWindows).toBeUndefined();
});
it("drops a window whose timeOfDay is no longer a live dose time", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["08:00"],
doseWindows: [{ timeOfDay: "20:00", start: "18:00", end: "23:00" }],
};
expect(buildCreateBody(p).schedules[0].doseWindows).toBeUndefined();
});
it("hydrates persisted windows back onto the active draft mirror", () => {
const initial: MedicationPayload = {
id: "med1",
name: "Foo",
dose: "5 mg",
category: "OTHER",
treatmentClass: "GENERIC",
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [
{
id: "sched1",
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "12:00" }],
},
],
};
const payload = hydrateWizardPayload(initial);
expect(payload.doseWindows).toEqual([
{ timeOfDay: "08:00", start: "07:00", end: "12:00" },
]);
expect(payload.schedules[0].doseWindows).toEqual([
{ timeOfDay: "08:00", start: "07:00", end: "12:00" },
]);
});
it("survives the edit → re-encode round-trip", () => {
const initial: MedicationPayload = {
id: "med1",
name: "Foo",
dose: "5 mg",
category: "OTHER",
treatmentClass: "GENERIC",
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [
{
id: "sched1",
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "12:00" }],
},
],
};
const payload = hydrateWizardPayload(initial);
const body = buildCreateBody(payload, "edit");
expect(body.schedules[0].doseWindows).toEqual([
{ timeOfDay: "08:00", start: "07:00", end: "12:00" },
]);
});
});
@@ -3,6 +3,8 @@
import { Moon, Sun, Sunrise, Sunset } from "lucide-react";
import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
import { DoseWindowEditor } from "@/components/medications/scheduling/DoseWindowEditor";
import type { DoseWindowScale } from "@/components/medications/scheduling/dose-window";
import { useTranslations } from "@/lib/i18n/context";
import type { StepProps } from "./Step1Name";
@@ -21,6 +23,20 @@ const PRESETS = [
{ time: "22:00", icon: Moon, key: "night" },
] as const;
/**
* Window scale for the late-tail hint — a weekly / monthly RRULE or a
* ≥2-day rolling cadence is day-scale (the 4-day rule); everything else
* is intraday (the ±1h / +3h minute bands).
*/
function scaleForCadence(payload: StepProps["payload"]): DoseWindowScale {
const rolling = payload.cadence.rollingIntervalDays;
if (typeof rolling === "number" && rolling >= 2) return "dayScale";
if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/.test(payload.cadence.rrule ?? "")) {
return "dayScale";
}
return "intraday";
}
export function Step7Times({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
function togglePreset(time: string) {
@@ -82,6 +98,15 @@ export function Step7Times({ payload, applyPartial }: StepProps) {
// each suggestion isn't shown twice.
showPresets={false}
/>
{/* v1.15.18 — the SAME per-dose window editor the Zeitplan tab uses,
so a new med sets windows with identical semantics to an edit. */}
<DoseWindowEditor
timesOfDay={payload.timesOfDay}
value={payload.doseWindows}
onChange={(doseWindows) => applyPartial({ doseWindows })}
scale={scaleForCadence(payload)}
/>
</div>
);
}
@@ -64,6 +64,10 @@ import {
type MedicationDeliveryForm,
} from "@/lib/validations/medication";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import {
isExplicitRange,
type DoseWindowEntry,
} from "@/components/medications/scheduling/dose-window";
// ────────────────────────────────────────────────────────────────────
// Step 2 — treatment-class taxonomy
@@ -176,6 +180,13 @@ export interface ScheduleDraft {
cadence: CadenceValue;
subControls: CadenceSubControls;
timesOfDay: string[];
/**
* v1.15.18 — per-dose explicit on-time windows. One entry per dose
* time the user widened beyond the symmetric ±1h default; a time with
* no entry keeps the default band. Empty when every dose uses the
* default (the wire then omits / sends `[]`, leaving the column NULL).
*/
doseWindows?: DoseWindowEntry[];
}
/**
@@ -228,6 +239,12 @@ export interface WizardPayload {
subControls: CadenceSubControls;
/** Mirrors `schedules[activeScheduleIndex].timesOfDay`. */
timesOfDay: string[];
/**
* v1.15.18 — mirrors `schedules[activeScheduleIndex].doseWindows`. The
* per-dose window editor in Step 7 reads + writes this flat slot; the
* active-draft helpers keep it in sync with the schedule list.
*/
doseWindows: DoseWindowEntry[];
startsOn: Date | null;
endsOn: Date | null;
notificationsEnabled: boolean;
@@ -272,6 +289,7 @@ export function emptyWizardPayload(): WizardPayload {
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
doseWindows: draft.doseWindows ?? [],
startsOn: todayUtc(),
endsOn: null,
notificationsEnabled: true,
@@ -299,6 +317,7 @@ export function commitActiveDraft(payload: WizardPayload): WizardPayload {
cadence: payload.cadence,
subControls: payload.subControls,
timesOfDay: payload.timesOfDay,
doseWindows: payload.doseWindows,
};
const schedules = payload.schedules.slice();
schedules[idx] = next;
@@ -323,6 +342,7 @@ export function setActiveSchedule(
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
doseWindows: draft.doseWindows ?? [],
};
}
@@ -533,6 +553,8 @@ export interface CreateMedicationBody {
rollingIntervalDays?: number;
daysOfWeek?: number[];
intervalWeeks?: number;
/** v1.15.18 — per-dose explicit on-time windows. Omitted when none. */
doseWindows?: DoseWindowEntry[];
}>;
}
@@ -591,6 +613,16 @@ export function encodeScheduleDraft(
timesOfDay: times,
};
if (draft.id) out.id = draft.id;
// v1.15.18 — emit only the explicit windows that still name a live dose
// time and actually differ from the default ±1h band (a point-equivalent
// window leaves the column on the default derivation).
if (draft.doseWindows && draft.doseWindows.length > 0) {
const liveTimes = new Set(times);
const windows = draft.doseWindows.filter(
(w) => liveTimes.has(w.timeOfDay) && isExplicitRange(w),
);
if (windows.length > 0) out.doseWindows = windows;
}
if (!isOneShot) {
if (draft.cadence.rrule !== null) {
out.rrule = draft.cadence.rrule;
@@ -885,6 +917,8 @@ export interface MedicationPayload {
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
/** v1.15.18 — per-dose explicit on-time windows (edit-hydrate). */
doseWindows?: DoseWindowEntry[] | null;
}>;
}
@@ -913,6 +947,7 @@ interface MedicationScheduleSnapshot {
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
doseWindows?: DoseWindowEntry[] | null;
}
function hydrateScheduleDraft(
@@ -933,6 +968,11 @@ function hydrateScheduleDraft(
timesOfDay: times,
};
if (schedule.id) draft.id = schedule.id;
// v1.15.18 — round-trip the persisted per-dose windows so an edit keeps
// the user's explicit ranges instead of resetting them to the default.
if (schedule.doseWindows && schedule.doseWindows.length > 0) {
draft.doseWindows = schedule.doseWindows;
}
return draft;
}
@@ -976,6 +1016,7 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
cadence: first.cadence,
subControls: first.subControls,
timesOfDay: first.timesOfDay,
doseWindows: first.doseWindows ?? [],
startsOn: initial.startsOn ?? base.startsOn,
endsOn: initial.endsOn,
notificationsEnabled: initial.notificationsEnabled ?? true,
@@ -35,7 +35,7 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares the twelve sections", () => {
it("declares every section", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
@@ -48,11 +48,14 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// IW-D merged it into `thresholds`.
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
// between `thresholds` (Targets) and `ai`.
// v1.15.18 — `insights` sits between `dashboard` and `thresholds`: the
// overview-arrange + pill-sort customise surface for `/insights`.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"insights",
"thresholds",
"sources",
"ai",
@@ -0,0 +1,85 @@
"use client";
import { useMemo } from "react";
import { Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { type InsightsSectionId } from "@/lib/insights-layout";
/**
* v1.15.18 — overview-arrange block for the Insights settings section.
*
* Embeds the existing v1.15.11 `<InsightsEditMode>` (section show/hide + order,
* persisted to `insightsLayoutJson.sections`) as an always-open surface. There
* is no "Anpassen" toggle here — Settings IS the customise surface — so `onClose`
* is a no-op (after a "Fertig" save the layout query has already settled into the
* shared cache; the editor simply stays mounted).
*
* `gatedOffSectionIds` mirrors the mother page's gate logic so a section whose
* feature flag / data gate is off renders its row disabled-with-a-hint rather
* than offering a toggle that does nothing.
*/
export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
const flags = useFeatureFlags();
const { layout, isLoading } = useInsightsLayoutQuery(isAuthenticated);
const gatedOffSectionIds = useMemo(() => {
const gated = new Set<InsightsSectionId>();
if (!flags.briefing) {
gated.add("daily-briefing");
gated.add("period-review");
}
if (!user?.cycleTrackingEnabled) {
gated.add("cycle-summary");
}
return gated;
}, [flags.briefing, user?.cycleTrackingEnabled]);
return (
<section
id={id}
data-slot="insights-overview-arrange-section"
aria-labelledby="insights-overview-arrange-title"
className="space-y-3"
>
<header className="space-y-1">
<h2
id="insights-overview-arrange-title"
className="text-lg font-semibold"
>
{t("insights.settings.overviewTitle")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.settings.overviewDescription")}
</p>
</header>
{/* Gate the editor mount until the layout GET settles. The editor seeds
its draft once from `layout` on mount, which is the canonical default
while in flight; mounting it early would let a "Fertig" save flush
defaults over the user's real saved layout (the same QA-L1 gate the
mother page applies to its "Anpassen" toggle). */}
{isLoading ? (
<div
className="bg-card border-border text-muted-foreground flex items-center gap-2 rounded-xl border p-4 text-sm sm:p-6"
data-slot="insights-overview-arrange-loading"
>
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
</div>
) : (
<InsightsEditMode
layout={layout}
gatedOffSectionIds={gatedOffSectionIds}
onClose={() => {}}
/>
)}
</section>
);
}
@@ -0,0 +1,329 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { GripVertical, Loader2 } from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import {
reorderById,
rebuildTilesWithReorderedVitals,
} from "@/lib/insights-layout-reorder";
import {
type InsightsLayout,
type InsightsTileConfig,
} from "@/lib/insights-layout";
import {
MANAGER_GROUP_ORDER,
SUB_PAGE_MANAGER_GROUP_SLUGS,
type ManagerGroup,
} from "@/lib/insights/sub-page-metric";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
/**
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
*
* The Insights nav PILLS persist to `insightsLayoutJson.tiles[].order`, a field
* that is ALREADY separate from the overview `sections[].order` in layout v2.
* Until now the pill order could only be changed implicitly through the
* overview "Anpassen" → "Kacheln verwalten" disclosure (the Vitals row), which
* the maintainer found unintuitive. This is the explicit surface: drag the
* pills, "Speichern" PUTs the merged `{ tiles }` blob to `/api/insights/layout`,
* which the tab strip + overview share via `queryKeys.insightsLayout()`.
*
* Drag mirrors `insights-edit-mode` — each manager group is its OWN
* `DndContext` + `SortableContext` so a drag never crosses a group boundary.
* The pills here are NOT visibility toggles; visibility lives on the overview
* "Übersicht anordnen" block so the two concerns stay distinct.
*/
const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
vitals: "insights.tabStrip.vitalsParent.label",
body: "insights.tabStrip.bodyParent.label",
activity: "insights.tabStrip.activityParent.label",
sleep: "insights.editMode.groupSleep",
cardiovascular: "insights.tabStrip.cardiovascularParent.label",
hearing: "insights.tabStrip.hearingParent.label",
environment: "insights.tabStrip.environmentParent.label",
metabolic: "insights.tabStrip.metabolicParent.label",
mood: "insights.editMode.groupMood",
events: "insights.editMode.groupEvents",
};
/** Stable id-order fingerprint used to detect when the server copy changed. */
function orderSignature(tiles: readonly InsightsTileConfig[]): string {
return [...tiles]
.sort((a, b) => a.order - b.order)
.map((tile) => tile.id)
.join(",");
}
export function InsightsPillOrderSection({ id }: { id?: string }) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const { layout, isLoading, isSuccess } =
useInsightsLayoutQuery(isAuthenticated);
// Local draft of the tiles, seeded from the resolved layout and re-seeded in
// render (the React-sanctioned "adjust state on prop change" pattern, no
// effect) whenever the server copy's id-order changes — e.g. after a save or
// once the GET settles. Tracking the last-seeded signature in STATE (not a
// ref) keeps the reseed render-safe and idempotent while leaving in-flight
// drag edits untouched: the baseline only advances when the *server* order
// differs from what we last seeded from.
const serverSignature = orderSignature(layout.tiles);
const [seededSignature, setSeededSignature] = useState(serverSignature);
const [draftTiles, setDraftTiles] = useState<InsightsTileConfig[]>(
() => [...layout.tiles].sort((a, b) => a.order - b.order),
);
if (isSuccess && serverSignature !== seededSignature) {
setSeededSignature(serverSignature);
setDraftTiles([...layout.tiles].sort((a, b) => a.order - b.order));
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const saveMutation = useMutation({
mutationFn: async (tiles: InsightsTileConfig[]) => {
const res = await fetch("/api/insights/layout", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version: 2,
// Preserve the overview section order/visibility verbatim — this
// surface owns the PILL order only.
sections: layout.sections,
tiles,
}),
});
if (!res.ok) throw new Error("save failed");
return (await res.json()).data as InsightsLayout;
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
toast.success(t("insights.pillOrder.saveSuccess"));
},
onError: () => toast.error(t("insights.pillOrder.saveError")),
});
const busy = saveMutation.isPending;
// Group the draft tiles the same way the tab strip + manager do, so each
// group's pills sort within their own `SortableContext`.
const tilesByGroup = useMemo(() => {
const byId = new Map<string, InsightsTileConfig>(
draftTiles.map((tile) => [tile.id, tile]),
);
return MANAGER_GROUP_ORDER.map((group) => {
const rows = SUB_PAGE_MANAGER_GROUP_SLUGS[group]
.map((slug) => {
const cfg = byId.get(slug);
return {
id: slug as string,
labelKey: SUB_PAGE_TABS[slug].labelKey,
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
};
})
.sort((a, b) => a.order - b.order);
return { group, rows };
}).filter((g) => g.rows.length > 0);
}, [draftTiles]);
function handleGroupDragEnd(group: ManagerGroup, event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const groupRows = tilesByGroup.find((g) => g.group === group)?.rows ?? [];
const groupMemberIds = new Set(groupRows.map((r) => r.id));
const subset = groupRows.map((r) => ({ id: r.id, order: r.order }));
const reordered = reorderById(subset, String(active.id), String(over.id));
const reorderedIds = reordered.map((r) => r.id);
// Substitute this group's slots in their new relative order through the
// same tested, total-order helper the inline edit mode uses, leaving every
// other tile's relative order untouched (QA M2).
setDraftTiles((tiles) =>
rebuildTilesWithReorderedVitals(tiles, reorderedIds, (id) =>
groupMemberIds.has(id),
),
);
}
const dirty = useMemo(() => {
const original = [...layout.tiles].sort((a, b) => a.order - b.order);
if (original.length !== draftTiles.length) return true;
const draftByOrder = [...draftTiles].sort((a, b) => a.order - b.order);
return original.some((tt, i) => tt.id !== draftByOrder[i]?.id);
}, [layout.tiles, draftTiles]);
return (
<section
id={id}
data-slot="insights-pill-order-section"
aria-labelledby="insights-pill-order-title"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="space-y-1">
<h2
id="insights-pill-order-title"
className="text-lg font-semibold"
>
{t("insights.pillOrder.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.pillOrder.description")}
</p>
</div>
<Button
size="sm"
onClick={() => saveMutation.mutate(draftTiles)}
disabled={busy || !dirty}
data-slot="insights-pill-order-save"
className="self-end sm:self-auto"
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.pillOrder.save")}
</Button>
</div>
{isLoading ? (
<p
className="text-muted-foreground text-sm"
data-slot="insights-pill-order-loading"
>
<Loader2 className="mr-2 inline h-3.5 w-3.5 animate-spin align-text-bottom motion-reduce:animate-none" />
</p>
) : tilesByGroup.length === 0 ? (
<p
className="text-muted-foreground text-sm"
data-slot="insights-pill-order-empty"
>
{t("insights.pillOrder.empty")}
</p>
) : (
<div className="max-h-[60vh] space-y-3 overflow-y-auto overscroll-contain">
{tilesByGroup.map(({ group, rows }) => (
<div
key={group}
data-slot="insights-pill-order-group"
data-group={group}
>
<p className="text-muted-foreground px-1 py-1 text-[11px] font-semibold tracking-wide uppercase">
{t(MANAGER_GROUP_HEADER_KEYS[group])}
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => handleGroupDragEnd(group, event)}
>
<SortableContext
items={rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5">
{rows.map((tile) => (
<SortablePillRow
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
disabled={busy}
dragHandleLabel={t("insights.pillOrder.dragHandle")}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
))}
</div>
)}
</section>
);
}
const DRAG_HANDLE_CLASS =
"text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex h-11 w-11 shrink-0 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9 sm:before:absolute sm:before:inset-[-6px] sm:before:content-['']";
function SortablePillRow({
id,
title,
disabled,
dragHandleLabel,
}: {
id: string;
title: string;
disabled: boolean;
dragHandleLabel: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="insights-pill-order-row"
data-tile={id}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"border-border bg-background/30 flex items-center gap-2 rounded-md border px-2 py-1.5",
isDragging && "ring-primary z-10 opacity-90 shadow-md ring-2",
)}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${dragHandleLabel} — ${title}`}
title={dragHandleLabel}
disabled={disabled}
data-slot="insights-pill-order-handle"
className={DRAG_HANDLE_CLASS}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.15.18 — the "Insights" settings section (analogous to the Dashboard
* section). Hosts two blocks:
* 1. Arrange the OVERVIEW — section show/hide + order, persisted to
* `insightsLayoutJson.sections` (the v1.15.11 inline edit mode).
* 2. Sort the navigation PILLS — pill order, persisted to
* `insightsLayoutJson.tiles[].order` (already a separate field in v2).
*
* Both write through the same `/api/insights/layout` contract the tab strip
* and the overview share, so a save here repaints both surfaces in lockstep.
*/
export function InsightsSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-insights-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-insights-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.insights.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.insights.description")}
</p>
</header>
<InsightsOverviewArrangeSection id="insights-overview-arrange" />
<InsightsPillOrderSection id="insights-pill-order" />
</section>
);
}
@@ -22,6 +22,7 @@ import {
Settings2,
SlidersHorizontal,
Sparkles,
TrendingUp,
User,
type LucideIcon,
} from "lucide-react";
@@ -35,6 +36,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
integrations: Link2,
notifications: Bell,
dashboard: LayoutDashboard,
insights: TrendingUp,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
+1
View File
@@ -18,6 +18,7 @@ export const SETTINGS_SECTION_SLUGS = [
"integrations",
"notifications",
"dashboard",
"insights",
"thresholds",
"sources",
"ai",
@@ -27,6 +27,7 @@ import {
Settings2,
SlidersHorizontal,
Sparkles,
TrendingUp,
User,
type LucideIcon,
} from "lucide-react";
@@ -97,6 +98,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.dashboard.title",
icon: LayoutDashboard,
},
{
slug: "insights",
titleKey: "settings.sections.insights.title",
icon: TrendingUp,
},
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
+460 -30
View File
@@ -25,6 +25,7 @@ import {
expectedSlotCountForDay,
expectedSlotsBetween,
lastNonSkippedTakenAt,
tallyComplianceFromLedger,
type ComplianceMedicationContext,
type ComplianceSchedule,
} from "../compliance";
@@ -447,20 +448,67 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
};
}
it("parity: a legacy daysOfWeek-only schedule yields identical numbers with no context", () => {
it("on-time takes: the ledger scores every logged 08:00 dose as taken (100%)", () => {
// v1.15.18 — the context path now tallies the unified dose-history
// ledger (band membership) instead of the legacy ±12h proximity timeline.
// An 08:00 daily dose taken at 08:00 sits inside the ±60min on-time band →
// taken_on_time. The seven logged days all score taken; the current-day
// 08:00 slot is still inside its takeable window at NOW (12:00, the late
// tail ends at 12:00) so it reads upcoming, not missed → a clean 100%.
// (The off-time / stricter behaviour is pinned in the next test.)
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] },
];
const events = Array.from({ length: 7 }, (_, i) =>
eventAt(new Date(NOW.getTime() - (i + 1) * DAY_MS), true),
);
const legacy = calculateCompliance(events, schedules, 7);
const onTime = (d: Date) => ({
scheduledFor: d,
takenAt: d,
skipped: false,
});
const events = Array.from({ length: 7 }, (_, i) => {
const day = new Date(NOW.getTime() - (i + 1) * DAY_MS);
day.setUTCHours(8, 0, 0, 0);
return onTime(day);
});
const withCtx = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx(),
});
// Legacy daysOfWeek-only schedule has no canonical fields → both
// paths run the legacy walker → identical numbers.
expect(withCtx).toEqual(legacy);
// The window floors at NOW − 7d (Jan-8 12:00), so Jan-8's 08:00 dose falls
// just before it; the six in-window logged doses (Jan-9..Jan-14) all score
// taken and the current-day slot is still upcoming → 100%.
expect(withCtx.taken).toBe(6);
expect(withCtx.missed).toBe(0);
expect(withCtx.rate).toBe(100);
});
it("off-time take: the ledger is stricter than the legacy ±12h proximity matcher", () => {
// v1.15.18 — the keystone behaviour change. A daily 08:00 dose logged at
// 12:30 (4.5h late) USED to count as taken under the ±12h `pairDoses`
// proximity matcher. The unified band model attributes it honestly: 12:30
// is outside the 08:00 slot's on-time band (07:00–09:00) AND its late tail
// (to ~12:00), so it is an AD-HOC take and the slot reads MISSED. The
// percentage now agrees with the history view (the dose cannot read
// "taken" in the % while the ledger calls it ad-hoc).
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] },
];
// takenAt = 08:00 slot + 4.5h = 12:30 (eventAt adds 30min onto a 12:00
// scheduledFor; here we pin the off-time take explicitly).
const events = Array.from({ length: 7 }, (_, i) => {
const day = new Date(NOW.getTime() - (i + 1) * DAY_MS);
day.setUTCHours(8, 0, 0, 0);
return {
scheduledFor: day,
takenAt: new Date(day.getTime() + 4.5 * 60 * 60 * 1000),
skipped: false,
};
});
const withCtx = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx(),
});
// Every off-time take is ad-hoc → its slot is missed → 0% taken.
expect(withCtx.taken).toBe(0);
expect(withCtx.missed).toBeGreaterThanOrEqual(6);
expect(withCtx.rate).toBe(0);
});
it("FREQ=WEEKLY;BYDAY=MO — took every Monday → 100%, denominator counts only Mondays", () => {
@@ -2163,39 +2211,40 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", (
return new Date(`2025-01-${String(day).padStart(2, "0")}T${hhmm}:00Z`);
}
it("snaps two off-time same-day takes to BOTH slots → 2 taken, not 1 taken + 1 missed", () => {
// The crux of BUG 3: a day with a late-morning take (09:13) and an
// evening take (19:00) must read as two taken doses. The morning take
// pairs to the 07:00 slot (2h13m away, well inside the ±6h half-gap) and
// the evening to the 19:00 slot — NOT the morning take landing on 19:00
// and the 07:00 slot reading falsely missed.
it("attributes two same-day takes to BOTH slots — a near-on-time morning take is taken_late, not orphaned", () => {
// v1.15.18 — the band model attributes each take to its OWN slot. A
// morning take at 08:13 (07:00 slot + 1h13m) is inside the 07:00 slot's
// late tail (07:00 + 60min on-time + 180min tail → to 10:00), so it reads
// taken_late and does NOT orphan the 07:00 slot onto the 19:00 row. The
// evening 19:00 take is on-time. The crux of the original BUG-3 fix
// survives the move off the ±6h snap: a same-day morning + evening take
// never collapse into one taken + one falsely-missed.
//
// A 2-day window so BOTH of Jan-14's slots fall inside [NOW-2d, NOW]
// (a 1-day window starts at noon Jan-14, which is after the 07:00 slot).
// Cover EVERY slot the window emits so there is no stray uncovered slot:
// a 3-day window (NOW − 3d = Jan-12 12:00 .. Jan-15 12:00) holds Jan-12
// 19:00, Jan-13 07:00+19:00, Jan-14 07:00+19:00, Jan-15 07:00 = 6 slots.
const onTime = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false });
const events = [
// 07:00 slot, logged at 09:13 (off-time, snapped onto the 07:00 row).
onTime(slot(12, "19:00")),
onTime(slot(13, "07:00")),
onTime(slot(13, "19:00")),
// 07:00 slot, logged at 08:13 (within the late tail → taken_late).
{
scheduledFor: slot(14, "07:00"),
takenAt: new Date(slot(14, "07:00").getTime() + (2 * 60 + 13) * 60 * 1000),
takenAt: new Date(slot(14, "07:00").getTime() + (1 * 60 + 13) * 60 * 1000),
skipped: false,
},
// 19:00 slot, logged on time.
{ scheduledFor: slot(14, "19:00"), takenAt: slot(14, "19:00"), skipped: false },
// Jan-13's slots are covered too so the window has no stray miss.
{ scheduledFor: slot(13, "07:00"), takenAt: slot(13, "07:00"), skipped: false },
{ scheduledFor: slot(13, "19:00"), takenAt: slot(13, "19:00"), skipped: false },
onTime(slot(14, "19:00")),
onTime(slot(15, "07:00")),
];
const result = calculateCompliance(events, twiceDaily, 2, undefined, {
const result = calculateCompliance(events, twiceDaily, 3, undefined, {
now: NOW,
medicationContext: ctxVal,
});
// Both of Jan-14's off-time/on-time takes pair to their own slot → the
// morning slot is NOT falsely missed. No missed slot anywhere → 100%.
// Every slot is covered by its own take → no miss, 100%.
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
// The morning take did not orphan the 07:00 slot: at least Jan-14's two
// doses both register as taken.
expect(result.taken).toBeGreaterThanOrEqual(3);
expect(result.taken).toBe(6);
});
it("computes the rate as taken/(taken+missed) over a 7-day irregular pattern", () => {
@@ -2437,3 +2486,384 @@ describe("doseCadenceFamily", () => {
).toBe("daily");
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.18 — the UNIFIED ledger tally. `tallyComplianceFromLedger` is the
// ONE authority behind the compliance %: it builds the cadence-aware bands
// per schedule, reconstructs the dose-history ledger, and tallies it so the
// % and the history view can never contradict. These tests pin the
// taking-vs-timing split, the exclusions (skip / ad-hoc / upcoming / PRN),
// auto-missed=missed, and the cross-consistency invariant that the SAME
// intake can't be "taken" in the % while the ledger calls it "ad_hoc".
// ────────────────────────────────────────────────────────────────────
import { localHmAsUtc } from "@/lib/timezone";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import type { CanonicalSchedule, RecurrenceContext } from "@/lib/medications/scheduling/recurrence";
describe("tallyComplianceFromLedger — the unified % keystone", () => {
const TZ = "Europe/Berlin";
const dayRef = new Date("2026-06-08T12:00:00Z");
function at(h: number, m: number): Date {
return localHmAsUtc(dayRef, TZ, h, m);
}
function ctxFor(
over: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return {
startsOn: new Date("2026-06-01T00:00:00Z"),
endsOn: null,
oneShot: false,
createdAt: new Date("2026-06-01T00:00:00Z"),
lastIntakeAt: null,
timeZone: TZ,
...over,
};
}
// Twice-daily 07:00 / 19:00, single window covering the whole day.
const twiceDaily: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
},
];
// Late evening "now" so both of the reference day's slots are past cutoff.
const nowEvening = at(23, 59);
// Window covers just the reference day.
const from = at(0, 0);
it("a late take counts as taken (taking adherence), with the timing split exposed", () => {
// 07:00 on-time + 19:00 logged at 20:30 (inside the 180min late tail).
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false },
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(2);
expect(tally.takenOnTime).toBe(1);
expect(tally.takenLate).toBe(1);
expect(tally.missed).toBe(0);
expect(tally.rate).toBe(100);
});
it("a deliberate user-skip is EXCLUDED from the denominator", () => {
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(19, 0), takenAt: null, skipped: true },
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(1);
expect(tally.skipped).toBe(1);
expect(tally.missed).toBe(0);
expect(tally.denominator).toBe(1); // taken + missed, skip excluded
expect(tally.rate).toBe(100);
});
it("an auto-missed forgotten dose IS a miss (counts against the rate)", () => {
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{
scheduledFor: at(19, 0),
takenAt: null,
skipped: false,
autoMissed: true,
},
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(1);
expect(tally.missed).toBe(1);
expect(tally.skipped).toBe(0);
expect(tally.rate).toBe(50);
});
it("an off-schedule (ad-hoc) take is EXCLUDED + leaves its slot missed", () => {
// Marc's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc;
// the 07:00 slot is missed; the 19:00 slot is also missed (untouched).
const events = [
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.adHoc).toBe(1);
// The ad-hoc take is NOT in taken; both slots are missed.
expect(tally.taken).toBe(0);
expect(tally.missed).toBe(2);
// Denominator = taken + missed; ad-hoc never enters it.
expect(tally.denominator).toBe(2);
expect(tally.rate).toBe(0);
});
it("a future slot is upcoming — excluded, never a premature miss", () => {
// Early morning now: both slots still ahead of their miss cutoff.
const earlyNow = at(5, 0);
const tally = tallyComplianceFromLedger(
[],
twiceDaily,
ctxFor(),
from,
earlyNow,
earlyNow,
);
expect(tally.taken).toBe(0);
expect(tally.missed).toBe(0);
expect(tally.denominator).toBe(0);
expect(tally.rate).toBe(100); // empty-window contract
});
it("a PRN schedule contributes NO denominator (every intake excluded)", () => {
const prn: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00"],
scheduleType: "PRN",
},
];
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(14, 0), takenAt: at(14, 0), skipped: false },
];
const tally = tallyComplianceFromLedger(
events,
prn,
ctxFor(),
from,
nowEvening,
nowEvening,
);
// PRN → hasExpectedSlots false → no bands → every take ad-hoc, no slots.
expect(tally.taken).toBe(0);
expect(tally.missed).toBe(0);
expect(tally.adHoc).toBe(2);
expect(tally.denominator).toBe(0);
expect(tally.rate).toBe(100);
});
it("the rate is capped at 100% (extra doses never inflate it)", () => {
// Both slots taken + an ad-hoc extra. Ad-hoc is excluded so the rate is a
// clean 100, never >100.
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
{ scheduledFor: at(13, 0), takenAt: at(13, 0), skipped: false }, // extra
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(2);
expect(tally.adHoc).toBe(1);
expect(tally.rate).toBe(100);
});
it("CROSS-CONSISTENCY: the same intake cannot be taken in the % and ad_hoc in the ledger", () => {
// The keystone invariant. Build the exact bands the tally builds, run the
// SAME reconstructDoseHistory, and assert the tally's per-status counts
// equal the ledger's per-status counts — so a dose graded "taken" by the %
// is a "taken_*" row in the history view, and an "ad_hoc" row never leaks
// into the taken numerator.
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, // on-time
{ scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false }, // late
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, // ad-hoc
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
// Re-derive the ledger independently the same way the tally does.
const medication: BandMinterMedication = {
id: "x",
startsOn: ctxFor().startsOn,
endsOn: null,
oneShot: false,
createdAt: ctxFor().createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "x",
startsOn: ctxFor().startsOn,
endsOn: null,
oneShot: false,
createdAt: ctxFor().createdAt,
},
timeZone: TZ,
lastIntakeAt: null,
};
const canonical: CanonicalSchedule = {
id: "s",
rrule: null,
rollingIntervalDays: null,
timesOfDay: ["07:00", "19:00"],
daysOfWeek: null,
windowStart: "07:00",
windowEnd: "19:00",
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
const groups = buildBandsForSchedules({
medication,
schedules: [canonical],
ctx: recurrenceCtx,
userTz: TZ,
range: { from, to: nowEvening },
now: nowEvening,
intakeInstants: events
.filter((e) => !e.skipped && e.takenAt)
.map((e) => e.takenAt as Date),
});
const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
const intakes: HistoryIntake[] = events.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: false,
}));
const rows = reconstructDoseHistory(bands, intakes, nowEvening);
const fromLedger = {
onTime: rows.filter((r) => r.status === "taken_on_time").length,
late: rows.filter((r) => r.status === "taken_late").length,
missed: rows.filter((r) => r.status === "missed").length,
adHoc: rows.filter((r) => r.status === "ad_hoc").length,
};
expect(tally.takenOnTime).toBe(fromLedger.onTime);
expect(tally.takenLate).toBe(fromLedger.late);
expect(tally.missed).toBe(fromLedger.missed);
expect(tally.adHoc).toBe(fromLedger.adHoc);
// And the load-bearing claim: the ad-hoc take is NOT counted as taken.
expect(tally.taken).toBe(fromLedger.onTime + fromLedger.late);
expect(tally.adHoc).toBe(1);
});
it("rolling weekly (GLP-1) — each logged shot is a taken slot (retrospective family)", () => {
const rolling: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const shot1 = new Date("2026-05-04T09:12:00Z");
const shot2 = new Date("2026-05-11T18:40:00Z");
const shot3 = new Date("2026-05-18T07:55:00Z");
const now = new Date("2026-05-20T12:00:00Z");
const events = [shot1, shot2, shot3].map((d) => ({
scheduledFor: d,
takenAt: d,
skipped: false,
}));
const tally = tallyComplianceFromLedger(
events,
rolling,
ctxFor({
startsOn: new Date("2026-05-01T00:00:00Z"),
createdAt: new Date("2026-05-01T00:00:00Z"),
lastIntakeAt: shot3,
}),
new Date("2026-05-01T00:00:00Z"),
now,
now,
);
// Each irregular shot anchors its own retrospective band → all taken.
expect(tally.taken).toBeGreaterThanOrEqual(3);
expect(tally.rate).toBeGreaterThanOrEqual(90);
});
// v1.15.18 W7 — the persisted per-dose window reaches the % through the
// SAME minter the history view + write path use. An 11:29 take that the
// default ±1h leaves ad-hoc (slot missed) is counted on-time once the
// morning slot carries an explicit 07:00–12:00 window.
it("the persisted doseWindows widens the on-time band feeding the %", () => {
const events = [
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
];
// DEFAULT (no doseWindows): 11:29 is past the 11:00 cutoff → ad-hoc,
// morning slot missed.
const dflt = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(dflt.missed).toBe(1);
expect(dflt.adHoc).toBe(1);
// WITH the persisted 07:00–12:00 window: 11:29 lands on-time, nothing
// missed, nothing orphaned.
const windowed: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }],
},
];
const wt = tallyComplianceFromLedger(
events,
windowed,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(wt.missed).toBe(0);
expect(wt.adHoc).toBe(0);
expect(wt.takenOnTime).toBe(2);
expect(wt.rate).toBe(100);
});
});
+256 -32
View File
@@ -19,6 +19,12 @@
* daily compliance heatmap on `/api/medications/[id]/compliance`.
*/
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
type DoseWindowConfig,
} from "@/lib/medications/scheduling/band-minter";
import {
buildCadenceTimeline,
type CadenceEngineContext,
@@ -26,6 +32,12 @@ import {
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import {
expandRollingRetrospective,
nextOccurrenceAfter,
@@ -198,24 +210,7 @@ export function classifyIntakeTiming(
* - overdue: up to target + 4 days (late-but-counts)
* - missed: > target + 4 days
*/
export const DOSE_WINDOW_DEFAULTS = {
/** Daily / intraday on-time half-width around the target instant (min). */
dailyOnTimeMinutes: 60,
/**
* Daily / intraday overdue tail past the on-time window (min). A dose
* taken inside `[onTime, onTime + overdue]` still counts; beyond it the
* dose is missed.
*/
dailyOverdueMinutes: 240 - 60,
/** Weekly / rolling on-time half-width around the target day (days). */
weeklyOnTimeDays: 1,
/**
* Weekly / rolling overdue tail past the target day (days) — the clinical
* 4-day GLP-1 rule. A shot inside `[target, target + 4d]` still counts;
* beyond it the dose is missed and the user skips to the next slot.
*/
weeklyOverdueDays: 4,
} as const;
export { DOSE_WINDOW_DEFAULTS };
/**
* v1.15.9 — the derived per-dose state the medication card renders.
@@ -361,6 +356,15 @@ export interface ComplianceSchedule {
scheduleType?: ScheduleType | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
/**
* v1.15.18 — per-dose configurable on-time windows. Threaded onto the
* canonical schedule so `tallyComplianceFromLedger` builds the on-time band
* from the explicit `[start, end]` range — keeping the % exactly aligned with
* the dose-history view and the write/edit attribution (all consume the same
* minter). Accepts the raw persisted JSON (a full Prisma row drops straight
* in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`.
*/
doseWindows?: unknown;
}
/**
@@ -489,6 +493,7 @@ function toCanonicalSchedule(
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(s.doseWindows),
};
}
@@ -1094,6 +1099,177 @@ function soonestCadenceFamily(
return "daily";
}
/**
* v1.15.18 — the unified compliance tally over the dose-history ledger.
*
* THE single source of the medication compliance %. It builds the
* cadence-aware `SlotBand[]` per schedule (the shared band minter — every
* cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic,
* PRN), reconstructs the ONE dose-history ledger
* (`reconstructDoseHistory`), and tallies it so the percentage and the
* history view are mathematically incapable of contradicting each other
* (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher
* that `calculateCompliance` used for the engine-routed path.
*
* The tally follows the adherence literature's TAKING-vs-TIMING split:
* - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is
* still a taken dose; "late" is NOT collapsed into "missed";
* - denominator = taken + `missed`;
* - EXCLUDED from the denominator: `skipped` (deliberate user decision),
* `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the
* window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false`
* — PRN has no defensible denominator per the literature);
* - the rate is capped at 100% (extra doses never inflate it).
*
* The on-time / late split is surfaced separately so a caller can show both
* the TAKING rate (the headline) and the TIMING quality.
*
* Pure / synchronous: the bands are minted from pre-fetched schedules +
* intake instants; no DB access.
*/
export interface LedgerComplianceTally {
/** Doses taken (on-time + late). The TAKING-adherence numerator. */
taken: number;
/** Of `taken`, the count inside the on-time band. */
takenOnTime: number;
/** Of `taken`, the count in the late tail (still counts as taken). */
takenLate: number;
/** Expected doses never acted on past their miss cutoff. */
missed: number;
/** Deliberate user skips — excluded from the denominator. */
skipped: number;
/** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */
adHoc: number;
/** taken + missed (the rate denominator). */
denominator: number;
/** round(100 · taken / denominator), capped at 100. 100 on empty. */
rate: number;
}
export function tallyComplianceFromLedger(
events: IntakeEvent[],
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
from: Date,
to: Date,
now: Date,
windowConfig?: DoseWindowConfig,
): LedgerComplianceTally {
const medication: BandMinterMedication = {
id: "compliance-tally",
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
};
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally");
const canonicalSchedules = schedules.map((s, i) => {
const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`);
// A legacy daily schedule carries only `windowStart` (no `timesOfDay`,
// no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy`
// reads that as "every day at windowStart", but the band minter's
// cadence-detection gate needs an explicit time signal — surface
// `windowStart` as the single time-of-day so the daily band is minted.
if (
canonical.timesOfDay.length === 0 &&
canonical.rrule === null &&
canonical.rollingIntervalDays === null &&
canonical.scheduleType !== "PRN" &&
!ctx.oneShot
) {
return { ...canonical, timesOfDay: [canonical.windowStart] };
}
return canonical;
});
// Rolling cadences anchor their retrospective grid AT each logged intake;
// the bands need every non-skipped take in (or before) the window. Reuse
// the same instant-extraction the legacy rolling path uses so the
// numerator and denominator are built from one expansion.
const intakeInstants = intakeInstantsAtOrBefore(
events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })),
to,
);
const groups = buildBandsForSchedules({
medication,
schedules: canonicalSchedules,
ctx: recurrenceCtx,
userTz: ctx.timeZone,
range: { from, to },
now,
windowConfig,
intakeInstants,
});
// The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The
// bands already partition the slot space, so the union of every
// non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct
// DoseHistory` claims each slot by at most one intake, so pooling the
// (already correctly-minted) bands is safe. PRN groups (no expected
// slots) contribute no bands; their intakes surface as ad-hoc and are
// excluded from the denominator, exactly as the literature requires.
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
}));
const rows = reconstructDoseHistory(bands, intakes, now);
let takenOnTime = 0;
let takenLate = 0;
let missed = 0;
let skipped = 0;
let adHoc = 0;
for (const row of rows) {
switch (row.status) {
case "taken_on_time":
takenOnTime++;
break;
case "taken_late":
takenLate++;
break;
case "missed":
missed++;
break;
case "skipped":
skipped++;
break;
case "ad_hoc":
adHoc++;
break;
// `upcoming` slots are future / still-takeable → excluded from every
// counter so a partial head-of-window day never pollutes the rate.
}
}
const taken = takenOnTime + takenLate;
const denominator = taken + missed;
const rate =
denominator > 0
? Math.min(100, Math.round((taken / denominator) * 100))
: 100;
return {
taken,
takenOnTime,
takenLate,
missed,
skipped,
adHoc,
denominator,
rate,
};
}
/**
* Calculate compliance for a medication over a given period.
*
@@ -1168,6 +1344,41 @@ export function calculateCompliance(
Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS),
);
// v1.15.18 — when a medication context is supplied, the count fields
// (taken / skipped / missed / rate) come from the UNIFIED dose-history
// ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the
// keystone unification: the percentage is a tally over the exact same
// ledger the history view renders, so the two can never contradict (a
// dose can't read "taken late" in the % while the ledger calls it
// "ad-hoc"). The numerator is on-time + late takes (a late dose still
// counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded
// from the denominator. The streak still walks the cadence timeline below
// (its day-grain "every dose taken or skipped" rule is unchanged).
//
// Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the
// legacy timeline tally byte-stable — they have no engine context to mint
// bands from.
const ledgerCtx = options?.medicationContext;
let ledgerCounts:
| { taken: number; skipped: number; missed: number; rate: number }
| null = null;
if (ledgerCtx) {
const tally = tallyComplianceFromLedger(
events,
schedules,
ledgerCtx,
effectiveStart,
now,
now,
);
ledgerCounts = {
taken: tally.taken,
skipped: tally.skipped,
missed: tally.missed,
rate: tally.rate,
};
}
// Normalise the schedule shape so legacy callers that pass only
// `{ windowStart, windowEnd }` still produce a usable `daysOfWeek`
// field (treated as daily by the cadence parser).
@@ -1252,23 +1463,36 @@ export function calculateCompliance(
retro,
);
let taken = 0;
let skipped = 0;
let missed = 0;
for (const slot of timeline) {
if (slot.status === "taken") taken++;
else if (slot.status === "skipped") skipped++;
else if (slot.status === "missed") missed++;
// `upcoming` slots (future window) are excluded from every counter
// so a partial day at the head of the window doesn't pollute the rate.
// v1.15.18 — the count fields come from the unified ledger tally when a
// medication context was supplied (the keystone unification), and from the
// legacy timeline tally otherwise (context-less pure-math callers). The
// streak below always walks the timeline — its day-grain rule is orthogonal
// to the per-dose attribution and stays byte-stable for every caller.
let taken: number;
let skipped: number;
let missed: number;
let rate: number;
if (ledgerCounts) {
({ taken, skipped, missed, rate } = ledgerCounts);
} else {
taken = 0;
skipped = 0;
missed = 0;
for (const slot of timeline) {
if (slot.status === "taken") taken++;
else if (slot.status === "skipped") skipped++;
else if (slot.status === "missed") missed++;
// `upcoming` slots (future window) are excluded from every counter
// so a partial day at the head of the window doesn't pollute the rate.
}
// Skipped doses are excluded from the denominator — they represent a
// deliberate user decision rather than a missed dose.
const denom = taken + missed;
rate =
denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
}
const totalExpected = taken + skipped + missed;
// Skipped doses are excluded from the denominator — they represent a
// deliberate user decision rather than a missed dose.
const denom = taken + missed;
const rate =
denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
// Streak: consecutive days, ending today, where every expected dose
// was taken or skipped. Days with no expected dose advance the
@@ -332,6 +332,9 @@ export async function computeUserHealthScoreFastPath(
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
// v1.15.18 — per-dose window so the score's compliance honours the
// same on-time band the detail % + history use.
doseWindows: true,
},
},
},
@@ -0,0 +1,178 @@
/**
* v1.15.18 — WRITE/EDIT-path band attribution.
*
* `attributeTakenToSlot` is the write-side counterpart to the read-side
* ledger: both run the SAME shared band minter so a take is bound to a slot
* identically wherever it is decided. These tests pin the behaviour the audit
* called out (HIGH-5: the ±6h snap fully gone) + the late-take force-attribute
* guard (Marc's "diesem Slot zuordnen?" nudge):
*
* - on-time take → its own slot anchor;
* - clearly off-window → ad-hoc (null), NOT the nearest snapped slot;
* - late-but-in-tail → the slot, status late;
* - PRN / no schedule → always ad-hoc (hasExpectedSlots false);
* - force-attribute → resolves only a REAL slot anchor, never an
* arbitrary instant.
*/
import { describe, expect, it } from "vitest";
import {
attributeTakenToSlot,
resolveForcedSlotInstant,
type AttributeIntakeMedication,
} from "../attribute-intake";
import type { WorkerScheduleRow } from "../worker-helpers";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
function schedule(over: Partial<WorkerScheduleRow> = {}): WorkerScheduleRow {
return {
id: "sched-1",
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: [],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
...over,
};
}
function med(
schedules: WorkerScheduleRow[],
over: Partial<AttributeIntakeMedication> = {},
): AttributeIntakeMedication {
return {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules,
...over,
};
}
/** Berlin wall-clock HH:mm on the day implied by `dayRef`. */
function at(dayRef: Date, h: number, m: number): Date {
return localHmAsUtc(dayRef, TZ, h, m);
}
const DAY = new Date("2026-06-08T12:00:00Z"); // CEST
describe("attributeTakenToSlot — twice-daily 07:00 / 19:00 (Marc's case)", () => {
const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]);
it("snaps an on-time morning take to the 07:00 slot", () => {
const takenAt = at(DAY, 7, 5);
const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt });
expect(out.slotInstant?.getTime()).toBe(at(DAY, 7, 0).getTime());
expect(out.status).toBe("on_time");
});
it("treats the 11:29 take as ad-hoc, NOT a snapped 07:00 dose (HIGH-5)", () => {
// The retired ±6h snap pulled 11:29 onto 07:00. Band membership orphans it.
const takenAt = at(DAY, 11, 29);
const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt });
expect(out.slotInstant).toBeNull();
expect(out.status).toBeNull();
expect(out.hasExpectedSlots).toBe(true);
});
it("treats a 13:02 take as ad-hoc, never the 19:00 slot (HIGH-5)", () => {
const takenAt = at(DAY, 13, 2);
const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt });
expect(out.slotInstant).toBeNull();
});
it("snaps a slightly-late evening take to the 19:00 slot as late", () => {
// 19:00 + 90min is inside the daily late tail (180min default).
const takenAt = at(DAY, 20, 30);
const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt });
expect(out.slotInstant?.getTime()).toBe(at(DAY, 19, 0).getTime());
expect(out.status).toBe("late");
});
});
describe("attributeTakenToSlot — PRN / unscheduled", () => {
it("returns ad-hoc with hasExpectedSlots false for a PRN schedule", () => {
const m = med([schedule({ scheduleType: "PRN", timesOfDay: [] })]);
const out = attributeTakenToSlot({
medication: m,
userTz: TZ,
takenAt: at(DAY, 14, 0),
});
expect(out.slotInstant).toBeNull();
expect(out.hasExpectedSlots).toBe(false);
});
it("returns ad-hoc for a medication with no schedules", () => {
const m = med([]);
const out = attributeTakenToSlot({
medication: m,
userTz: TZ,
takenAt: at(DAY, 7, 0),
});
expect(out.slotInstant).toBeNull();
expect(out.hasExpectedSlots).toBe(false);
});
});
describe("attributeTakenToSlot — DST correctness", () => {
it("snaps a winter (CET) on-time take to its slot", () => {
const winterDay = new Date("2026-01-15T12:00:00Z"); // CET
const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]);
const takenAt = at(winterDay, 19, 10);
const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt });
expect(out.slotInstant?.getTime()).toBe(at(winterDay, 19, 0).getTime());
expect(out.status).toBe("on_time");
});
});
describe("resolveForcedSlotInstant — the late-take nudge guard", () => {
const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]);
it("resolves a real slot anchor the client pins onto", () => {
const slot = at(DAY, 7, 0);
const out = resolveForcedSlotInstant({
medication: m,
userTz: TZ,
slotInstant: slot,
});
expect(out?.getTime()).toBe(slot.getTime());
});
it("tolerates sub-minute client drift on the pinned anchor", () => {
const slot = new Date(at(DAY, 19, 0).getTime() + 30_000);
const out = resolveForcedSlotInstant({
medication: m,
userTz: TZ,
slotInstant: slot,
});
expect(out?.getTime()).toBe(at(DAY, 19, 0).getTime());
});
it("refuses an arbitrary off-slot instant (cannot pin a non-slot)", () => {
const out = resolveForcedSlotInstant({
medication: m,
userTz: TZ,
slotInstant: at(DAY, 11, 29),
});
expect(out).toBeNull();
});
it("refuses any pin for a PRN medication (no slots)", () => {
const prn = med([schedule({ scheduleType: "PRN", timesOfDay: [] })]);
const out = resolveForcedSlotInstant({
medication: prn,
userTz: TZ,
slotInstant: at(DAY, 7, 0),
});
expect(out).toBeNull();
});
});
@@ -0,0 +1,168 @@
/**
* v1.15.18 — window-band intake→slot attribution.
*
* Replaces the wide nearest-neighbour matchers (the ±6h write-time snap in
* `resolve-slot-instant.ts` and the ±12h/half-gap read-time radius in
* `cadence.ts pairDoses`) that forced an irregular real intake onto the
* nearest configured slot — producing absurd rows like "07:00 dose taken
* 15:46". Attribution is now pure band membership:
*
* - intake ∈ [onTimeStart, onTimeEnd] → on_time (this slot)
* - intake ∈ (onTimeEnd, overdueEnd] → late (this slot)
* - otherwise → null (ad-hoc)
*
* `overdueEnd` is the late tail past `onTimeEnd`, capped so it can never
* bleed into the next slot's on-time start (adjacent slots never both claim
* one intake). The on-time band IS the user's configurable dose window; the
* default point window is ±60min on-time + a 180min late tail
* (`DOSE_WINDOW_DEFAULTS`). All math is in instants, DST handled by the
* caller minting the band bounds via `localHmAsUtc`.
*/
import { describe, expect, it } from "vitest";
import {
buildSlotBands,
attributeIntakeToSlot,
type SlotWindowInput,
} from "../attribution";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
const MIN = 60_000;
/** A point-time slot with a symmetric on-time half-width + a late tail. */
function pointSlot(
at: Date,
timeOfDay: string,
onTimeHalfMin = 60,
lateGraceMin = 180,
): SlotWindowInput {
return {
at,
timeOfDay,
onTimeStart: new Date(at.getTime() - onTimeHalfMin * MIN),
onTimeEnd: new Date(at.getTime() + onTimeHalfMin * MIN),
lateGraceMs: lateGraceMin * MIN,
};
}
/** {07:00, 19:00} Europe/Berlin for the local day implied by `dayRef`. */
function ramiprilSlots(dayRef: Date): SlotWindowInput[] {
return [
pointSlot(localHmAsUtc(dayRef, TZ, 7, 0), "07:00"),
pointSlot(localHmAsUtc(dayRef, TZ, 19, 0), "19:00"),
];
}
/** Berlin wall-clock HH:mm on the same local day → instant. */
function at(dayRef: Date, h: number, m: number): Date {
return localHmAsUtc(dayRef, TZ, h, m);
}
describe("attributeIntakeToSlot — summer (CEST)", () => {
const day = new Date("2026-06-08T12:00:00Z");
const bands = buildSlotBands(ramiprilSlots(day));
it("07:00 dose taken 07:00 is on_time for the 07:00 slot", () => {
const r = attributeIntakeToSlot(at(day, 7, 0), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("on_time");
});
it("07:00 dose taken 08:30 is late for the 07:00 slot (inside the tail)", () => {
const r = attributeIntakeToSlot(at(day, 8, 30), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("late");
});
it("a take at 11:29 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => {
// The exact case Marc reported: an 11:29 take must NOT show as the 07:00 slot.
expect(attributeIntakeToSlot(at(day, 11, 29), bands)).toBeNull();
});
it("a midday take at 13:02 is ad-hoc (not snapped onto 19:00)", () => {
expect(attributeIntakeToSlot(at(day, 13, 2), bands)).toBeNull();
});
it("19:00 dose taken 19:00 is on_time for the 19:00 slot", () => {
const r = attributeIntakeToSlot(at(day, 19, 0), bands);
expect(r?.band.timeOfDay).toBe("19:00");
expect(r?.status).toBe("on_time");
});
it("an early evening take at 18:30 is on_time for the 19:00 slot", () => {
const r = attributeIntakeToSlot(at(day, 18, 30), bands);
expect(r?.band.timeOfDay).toBe("19:00");
expect(r?.status).toBe("on_time");
});
it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => {
expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull();
});
});
describe("attributeIntakeToSlot — winter (CET, DST-robust)", () => {
const day = new Date("2026-01-15T12:00:00Z");
const bands = buildSlotBands(ramiprilSlots(day));
it("07:00 dose taken 07:00 is on_time in winter too", () => {
const r = attributeIntakeToSlot(at(day, 7, 0), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("on_time");
});
it("11:29 take is ad-hoc in winter too", () => {
expect(attributeIntakeToSlot(at(day, 11, 29), bands)).toBeNull();
});
it("the 07:00 slot's true instant is 06:00Z in winter (CET = UTC+1)", () => {
expect(bands[0].at.toISOString()).toBe("2026-01-15T06:00:00.000Z");
});
});
describe("buildSlotBands — late tail never bleeds into the next slot", () => {
// Two close slots 08:00 / 12:00 with a generous 180min tail: 08:00's tail
// (08:00+60+180 = 11:00) is capped at 12:00's on-time start (11:00). With
// an even larger tail the cap binds.
const day = new Date("2026-06-08T12:00:00Z");
const slots: SlotWindowInput[] = [
pointSlot(localHmAsUtc(day, TZ, 8, 0), "08:00", 60, 600), // 10h tail
pointSlot(localHmAsUtc(day, TZ, 12, 0), "12:00", 60, 180),
];
const bands = buildSlotBands(slots);
it("caps the 08:00 overdueEnd at the 12:00 on-time start (11:00)", () => {
expect(bands[0].overdueEnd.toISOString()).toBe(
bands[1].onTimeStart.toISOString(),
);
});
it("a take at 11:30 belongs to the 12:00 slot, not the over-long 08:00 tail", () => {
const r = attributeIntakeToSlot(at(day, 11, 30), bands);
expect(r?.band.timeOfDay).toBe("12:00");
expect(r?.status).toBe("on_time");
});
});
describe("attributeIntakeToSlot — configurable window widens on-time", () => {
const day = new Date("2026-06-08T12:00:00Z");
it("an explicit 07:00–12:00 window makes an 11:29 take on_time for that dose", () => {
// Marc's lever: a real morning window of 07:00–12:00 (not a ±60 point)
// means his habitual ~11:00 intake counts as on-time, not ad-hoc.
const slots: SlotWindowInput[] = [
{
at: localHmAsUtc(day, TZ, 7, 0),
timeOfDay: "07:00",
onTimeStart: localHmAsUtc(day, TZ, 7, 0),
onTimeEnd: localHmAsUtc(day, TZ, 12, 0),
lateGraceMs: 180 * MIN,
},
pointSlot(localHmAsUtc(day, TZ, 19, 0), "19:00"),
];
const bands = buildSlotBands(slots);
const r = attributeIntakeToSlot(at(day, 11, 29), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("on_time");
});
});
@@ -0,0 +1,570 @@
/**
* v1.15.18 — shared cadence-aware band minter.
*
* The keystone that builds the correct `SlotBand[]` for EVERY medication
* cadence, so the read-model + the compliance % + the write/edit paths all
* consume ONE source of slot windows. These tests pin each cadence the audit
* called out:
*
* - daily / multi-time → minute-scale on-time + daily late tail;
* - fixed weekdays / N-weeks → day-scale on-time (realised-gap family, NOT
* the field-shape `doseCadenceFamily`);
* - rolling (GLP-1) → retrospective bands anchored AT each intake;
* - cyclic → off-week slots already filtered out;
* - one-shot → one WIDE whole-day on-time band, no auto-miss;
* - PRN / empty / bad rrule → [] + `hasExpectedSlots: false`;
* - DST day-scale → ±N calendar days via `localHmAsUtc`, not ±N·DAY;
* - multi-schedule → bands PER SCHEDULE, no cross-schedule clip.
*/
import { describe, expect, it } from "vitest";
import {
buildBandsForMedication,
buildBandsForSchedules,
type BandMinterMedication,
} from "../band-minter";
import {
type CanonicalSchedule,
type RecurrenceContext,
} from "../recurrence";
import { attributeIntakeToSlot } from "../attribution";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
function med(over: Partial<BandMinterMedication> = {}): BandMinterMedication {
return {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
...over,
};
}
function schedule(over: Partial<CanonicalSchedule> = {}): CanonicalSchedule {
return {
id: "sched-1",
rrule: null,
rollingIntervalDays: null,
timesOfDay: [],
daysOfWeek: null,
windowStart: "08:00",
windowEnd: "08:00",
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
...over,
};
}
function ctxFor(
m: BandMinterMedication,
lastIntakeAt: Date | null = null,
): RecurrenceContext {
return {
medication: {
id: m.id,
startsOn: m.startsOn,
endsOn: m.endsOn,
oneShot: m.oneShot,
createdAt: m.createdAt,
},
timeZone: TZ,
lastIntakeAt,
};
}
/** Berlin wall-clock HH:mm on the local day implied by `dayRef`. */
function at(dayRef: Date, h: number, m: number): Date {
return localHmAsUtc(dayRef, TZ, h, m);
}
// ────────────────────────────────────────────────────────────────────
// 1. Daily / multi-time
// ────────────────────────────────────────────────────────────────────
describe("daily multi-time (07:00 / 19:00)", () => {
const m = med();
const sched = schedule({ timesOfDay: ["07:00", "19:00"], daysOfWeek: null });
const day = new Date("2026-06-08T12:00:00Z");
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
it("mints one band per time-of-day", () => {
expect(out.hasExpectedSlots).toBe(true);
expect(out.bands.map((b) => b.timeOfDay)).toEqual(["07:00", "19:00"]);
});
it("classifies as a daily (minute-scale) family", () => {
expect(out.family).toBe("daily");
});
it("uses a minute-scale on-time band (±60min default)", () => {
const morning = out.bands[0];
expect(morning.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime() - 60 * MIN);
expect(morning.onTimeEnd.getTime()).toBe(at(day, 7, 0).getTime() + 60 * MIN);
});
it("applies a daily late tail (180min default) capped before the next slot", () => {
const morning = out.bands[0];
// 07:00 + 60 on-time + 180 tail = 10:00, well before 19:00 → not capped.
expect(morning.overdueEnd.getTime()).toBe(at(day, 7, 0).getTime() + (60 + 180) * MIN);
});
it("DST-correct anchors: 07:00 Berlin summer is 05:00Z", () => {
expect(out.bands[0].at.toISOString()).toBe("2026-06-08T05:00:00.000Z");
});
});
// ────────────────────────────────────────────────────────────────────
// 2. Fixed weekdays / every-N-weeks (realised-gap family)
// ────────────────────────────────────────────────────────────────────
describe("fixed weekdays Mon/Thu via legacy daysOfWeek", () => {
// A Mon/Thu med stored the legacy way. `doseCadenceFamily` would mislabel
// it daily (±60min). The realised inter-slot gap (3–4 days) must classify
// it weekly → day-scale on-time.
const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") });
const sched = schedule({ daysOfWeek: "1,4", timesOfDay: ["09:00"] });
// Mon 2026-06-08 .. Sun 2026-06-14 inclusive.
const from = new Date("2026-06-08T00:00:00Z");
const to = new Date("2026-06-14T23:59:59Z");
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from, to },
now: new Date("2026-06-20T00:00:00Z"),
});
it("derives the weekly family from the realised gap, not the field shape", () => {
expect(out.family).toBe("weekly");
});
it("emits the Mon and Thu slots", () => {
expect(out.bands).toHaveLength(2);
});
it("uses a day-scale (±1 day) on-time band", () => {
const mon = out.bands[0];
const span = mon.onTimeEnd.getTime() - mon.onTimeStart.getTime();
// ±1 day on-time → roughly 2 days wide (DST may shift by an hour, allow slop).
expect(span).toBeGreaterThanOrEqual(2 * DAY - 2 * HOUR);
expect(span).toBeLessThanOrEqual(2 * DAY + 2 * HOUR);
});
it("applies the 4-day late tail (capped before the next slot)", () => {
const mon = out.bands[0];
const thu = out.bands[1];
// Mon on-time-end + 4d would overrun Thu's on-time start (Thu − 1d), so it
// is capped there.
expect(mon.overdueEnd.getTime()).toBeLessThanOrEqual(thu.onTimeStart.getTime());
});
});
// ────────────────────────────────────────────────────────────────────
// 3. Rolling / "every N days from last intake" (GLP-1)
// ────────────────────────────────────────────────────────────────────
describe("rolling GLP-1 (every 7 days) — retrospective bands", () => {
const m = med({ startsOn: new Date("2026-05-01T00:00:00Z") });
const sched = schedule({
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
windowStart: "08:00",
windowEnd: "08:00",
});
// Three irregular real injections (not exactly 7d apart).
const shot1 = new Date("2026-05-04T09:12:00Z");
const shot2 = new Date("2026-05-12T18:40:00Z"); // 8d later
const shot3 = new Date("2026-05-18T07:55:00Z"); // 6d later
const intakeInstants = [shot1, shot2, shot3];
const now = new Date("2026-05-20T12:00:00Z");
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m, shot3),
userTz: TZ,
range: {
from: new Date("2026-05-01T00:00:00Z"),
to: now,
},
now,
intakeInstants,
});
it("anchors a band AT each logged injection (not one forward slot)", () => {
// Three intakes → at least three retrospective bands.
expect(out.bands.length).toBeGreaterThanOrEqual(3);
});
it("each shot's own injection time falls inside its band (on-time)", () => {
for (const shot of intakeInstants) {
const owning = out.bands.find(
(b) => shot.getTime() >= b.onTimeStart.getTime() && shot.getTime() <= b.onTimeEnd.getTime(),
);
expect(owning, `shot ${shot.toISOString()} should anchor a band`).toBeDefined();
}
});
it("uses a day-scale (weekly) family for the rolling cadence", () => {
expect(out.family).toBe("weekly");
});
});
describe("rolling — a ≤1-day interval stays minute-scale (daily family)", () => {
// A degenerate every-1-day rolling interval is effectively daily; a ±1-day
// on-time window would over-widen, so it must classify daily (minute-scale).
const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") });
const sched = schedule({ rollingIntervalDays: 1, timesOfDay: ["08:00"] });
const shot = new Date("2026-06-08T08:05:00Z");
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m, shot),
userTz: TZ,
range: {
from: new Date("2026-06-07T00:00:00Z"),
to: new Date("2026-06-09T00:00:00Z"),
},
now: new Date("2026-06-09T00:00:00Z"),
intakeInstants: [shot],
});
it("uses the daily family for a 1-day rolling cadence", () => {
expect(out.family).toBe("daily");
});
it("the on-time band is minute-scale (±60min), not day-scale", () => {
const b = out.bands[0];
const span = b.onTimeEnd.getTime() - b.onTimeStart.getTime();
expect(span).toBe(2 * 60 * MIN);
});
});
// ────────────────────────────────────────────────────────────────────
// 4. Cyclic (on/off weeks)
// ────────────────────────────────────────────────────────────────────
describe("cyclic (3 weeks on / 1 week off) — off-week slots dropped", () => {
const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") });
// Daily within an on-week, 3 on / 1 off; week 4 (2026-06-22..) is off.
const sched = schedule({
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
timesOfDay: ["08:00"],
daysOfWeek: null,
});
// Off-week 4 runs Mon 2026-06-22 .. Sat 2026-06-27; Sunday 06-28 is already
// the start of the next (Sunday-rooted) on-week, so the range stops Saturday.
const offWeekFrom = new Date("2026-06-22T00:00:00Z");
const offWeekTo = new Date("2026-06-27T23:59:59Z");
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from: offWeekFrom, to: offWeekTo },
now: new Date("2026-07-10T00:00:00Z"),
});
it("mints no bands inside an off week", () => {
expect(out.bands).toHaveLength(0);
});
it("still reports the schedule HAS expected slots (it is scheduled)", () => {
expect(out.hasExpectedSlots).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────────
// 5. One-shot
// ────────────────────────────────────────────────────────────────────
describe("one-shot — single wide whole-day on-time band", () => {
const m = med({
oneShot: true,
startsOn: new Date("2026-06-08T00:00:00Z"),
});
const sched = schedule({ timesOfDay: ["08:00"] });
const day = new Date("2026-06-08T12:00:00Z");
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
it("mints exactly one band", () => {
expect(out.bands).toHaveLength(1);
});
it("the on-time band spans the whole local day", () => {
const b = out.bands[0];
// Whole-day on-time → start at local midnight, end at next local midnight.
expect(b.onTimeStart.getTime()).toBeLessThanOrEqual(at(day, 0, 0).getTime());
expect(b.onTimeEnd.getTime()).toBeGreaterThanOrEqual(at(day, 23, 59).getTime());
});
it("a take any time that day is on-time (a 23:00 take still counts)", () => {
const b = out.bands[0];
const late = at(day, 23, 0);
expect(late.getTime()).toBeGreaterThanOrEqual(b.onTimeStart.getTime());
expect(late.getTime()).toBeLessThanOrEqual(b.onTimeEnd.getTime());
});
it("classifies as a one-shot family", () => {
expect(out.family).toBe("one_shot");
});
});
// ────────────────────────────────────────────────────────────────────
// 6. PRN / empty / malformed rrule
// ────────────────────────────────────────────────────────────────────
describe("PRN / empty / malformed → no expected slots", () => {
const m = med();
const day = new Date("2026-06-08T12:00:00Z");
const range = { from: at(day, 0, 0), to: at(day, 23, 59) };
it("PRN yields [] and hasExpectedSlots=false", () => {
const out = buildBandsForMedication({
medication: m,
schedule: schedule({ scheduleType: "PRN", timesOfDay: ["08:00"] }),
ctx: ctxFor(m),
userTz: TZ,
range,
now: at(day, 23, 59),
});
expect(out.bands).toEqual([]);
expect(out.hasExpectedSlots).toBe(false);
expect(out.family).toBe("none");
});
it("a malformed rrule yields [] and hasExpectedSlots=false", () => {
const out = buildBandsForMedication({
medication: m,
schedule: schedule({ rrule: "this-is-not-a-valid-rrule" }),
ctx: ctxFor(m),
userTz: TZ,
range,
now: at(day, 23, 59),
});
expect(out.bands).toEqual([]);
expect(out.hasExpectedSlots).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────
// 7. DST for day-scale bands
// ────────────────────────────────────────────────────────────────────
describe("DST day-scale bounds via localHmAsUtc (not ±N·DAY_MS)", () => {
// A weekly slot whose ±1-day window spans the spring-forward transition
// (Europe/Berlin 2026-03-29 02:00→03:00). The day-scale bound must be the
// calendar day before/after at the same wall-clock, not a raw 24h subtraction.
const m = med({ startsOn: new Date("2026-03-01T00:00:00Z") });
const sched = schedule({
rrule: "FREQ=WEEKLY;BYDAY=SU",
timesOfDay: ["09:00"],
});
const from = new Date("2026-03-26T00:00:00Z");
const to = new Date("2026-04-01T23:59:59Z");
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from, to },
now: new Date("2026-04-10T00:00:00Z"),
});
it("emits the Sunday slot in the DST week", () => {
expect(out.bands.length).toBeGreaterThanOrEqual(1);
});
it("the on-time lower bound is 09:00 the previous calendar day (DST-correct)", () => {
const b = out.bands[0];
const anchorParts = b.at; // 2026-03-29 09:00 Berlin
// The lower bound must read 09:00 Berlin on 2026-03-28, i.e. a calendar-day
// shift, even though the raw clock distance is 23h across the spring-forward.
const lower = b.onTimeStart;
const expectedLower = localHmAsUtc(
new Date(anchorParts.getTime() - DAY),
TZ,
9,
0,
);
expect(lower.toISOString()).toBe(expectedLower.toISOString());
// And critically NOT the naive ±24h subtraction.
const naive = new Date(anchorParts.getTime() - DAY);
expect(lower.getTime()).not.toBe(naive.getTime());
});
});
// ────────────────────────────────────────────────────────────────────
// 8. Multi-schedule on one med
// ────────────────────────────────────────────────────────────────────
describe("multi-schedule — bands built PER SCHEDULE (no cross-clip)", () => {
// A daily oral 08:00 + a weekly injection Sunday 08:00 on the same med. If
// pooled into one buildSlotBands call, the weekly slot's 4-day tail would be
// clipped by the next daily 08:00 anchor. Per-schedule banding keeps the
// weekly tail intact.
const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") });
const daily = schedule({ id: "daily", timesOfDay: ["08:00"], daysOfWeek: null });
const weekly = schedule({
id: "weekly",
rrule: "FREQ=WEEKLY;BYDAY=SU",
timesOfDay: ["08:00"],
});
const from = new Date("2026-06-07T00:00:00Z"); // Sunday
const to = new Date("2026-06-13T23:59:59Z");
const out = buildBandsForSchedules({
medication: m,
schedules: [daily, weekly],
ctx: ctxFor(m),
userTz: TZ,
range: { from, to },
now: new Date("2026-06-20T00:00:00Z"),
});
it("returns one group per schedule", () => {
expect(out).toHaveLength(2);
expect(out.map((g) => g.scheduleId).sort()).toEqual(["daily", "weekly"]);
});
it("the weekly band keeps a multi-day late tail (not clipped by a daily anchor)", () => {
const weeklyGroup = out.find((g) => g.scheduleId === "weekly");
expect(weeklyGroup).toBeDefined();
expect(weeklyGroup!.family).toBe("weekly");
const sun = weeklyGroup!.bands[0];
const tailMs = sun.overdueEnd.getTime() - sun.onTimeEnd.getTime();
// The weekly tail must be on the day scale (>1 day), not clipped to the
// next daily 08:00 (~24h − on-time) as a pooled call would do.
expect(tailMs).toBeGreaterThan(DAY);
});
it("the daily group keeps minute-scale bands", () => {
const dailyGroup = out.find((g) => g.scheduleId === "daily");
expect(dailyGroup!.family).toBe("daily");
const oneBand = dailyGroup!.bands[0];
const span = oneBand.onTimeEnd.getTime() - oneBand.onTimeStart.getTime();
expect(span).toBe(2 * 60 * MIN); // ±60min
});
});
// ────────────────────────────────────────────────────────────────────
// 9. Per-dose configurable window (Marc's "07:00–09:00" lever, v1.15.18 W7)
// ────────────────────────────────────────────────────────────────────
describe("explicit per-dose window from schedule.doseWindows", () => {
const m = med();
const day = new Date("2026-06-08T12:00:00Z");
// Morning dose carries an explicit 07:00–12:00 window; the evening dose
// none, so it keeps the symmetric ±60min default.
const sched = schedule({
timesOfDay: ["07:00", "19:00"],
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }],
});
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
const morning = out.bands.find((b) => b.timeOfDay === "07:00")!;
const evening = out.bands.find((b) => b.timeOfDay === "19:00")!;
it("builds the on-time band from the explicit [start,end] range", () => {
expect(morning.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime());
expect(morning.onTimeEnd.getTime()).toBe(at(day, 12, 0).getTime());
});
it("an 11:29 take lands inside the widened on-time window", () => {
expect(attributeIntakeToSlot(at(day, 11, 29), out.bands)).toMatchObject({
band: { timeOfDay: "07:00" },
status: "on_time",
});
});
it("a dose with no entry keeps the symmetric ±60min default", () => {
expect(evening.onTimeStart.getTime()).toBe(at(day, 19, 0).getTime() - 60 * MIN);
expect(evening.onTimeEnd.getTime()).toBe(at(day, 19, 0).getTime() + 60 * MIN);
});
it("under the DEFAULT (no doseWindows) the same 11:29 take is ad-hoc", () => {
const dflt = buildBandsForMedication({
medication: m,
schedule: schedule({ timesOfDay: ["07:00", "19:00"] }),
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
// 11:29 is > 07:00 + on-time(60) + tail(180) = 11:00 cutoff → no band.
expect(attributeIntakeToSlot(at(day, 11, 29), dflt.bands)).toBeNull();
});
it("a malformed doseWindows shape falls back to the default derivation", () => {
const out2 = buildBandsForMedication({
medication: m,
// start > end is dropped by the normaliser; an unrelated timeOfDay is
// ignored. Neither widens the 07:00 band.
schedule: schedule({
timesOfDay: ["07:00", "19:00"],
doseWindows: [
{ timeOfDay: "07:00", start: "12:00", end: "07:00" },
] as never,
}),
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
const morn = out2.bands.find((b) => b.timeOfDay === "07:00")!;
expect(morn.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime() - 60 * MIN);
expect(morn.onTimeEnd.getTime()).toBe(at(day, 7, 0).getTime() + 60 * MIN);
});
});
describe("explicit per-dose window is DST-correct (winter)", () => {
const m = med();
const winterDay = new Date("2026-01-15T12:00:00Z");
const sched = schedule({
timesOfDay: ["07:00"],
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
});
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(winterDay, 0, 0), to: at(winterDay, 23, 59) },
now: at(winterDay, 23, 59),
});
it("mints the [start,end] bounds on the slot's local winter day (CET = +1)", () => {
const band = out.bands[0];
expect(band.onTimeStart.toISOString()).toBe("2026-01-15T06:00:00.000Z");
expect(band.onTimeEnd.toISOString()).toBe("2026-01-15T08:00:00.000Z");
});
});
@@ -0,0 +1,153 @@
/**
* v1.15.18 — traceable dose-history reconstruction (spec B).
*
* Produces a complete, attributable ledger from the expected-slot bands +
* the actual intake rows:
* - every expected slot appears with a status (taken_on_time / taken_late /
* skipped / missed / upcoming) even when never taken;
* - a TAKEN intake is attributed to a slot by its real `takenAt` band
* membership (NOT the stored, possibly-snapped `scheduledFor`), so the
* corrected bands converge forward over legacy mis-snapped rows;
* - skipped / auto-missed / pending rows bind to their slot by anchor
* (they were written against a slot deliberately);
* - a taken intake matching no slot band is emitted as a tagged `ad_hoc`
* row at its real time — never silently snapped onto a scheduled slot.
*/
import { describe, expect, it } from "vitest";
import { buildSlotBands, type SlotWindowInput } from "../attribution";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "../dose-history";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
const MIN = 60_000;
const day = new Date("2026-06-08T12:00:00Z");
function pointSlot(at: Date, timeOfDay: string): SlotWindowInput {
return {
at,
timeOfDay,
onTimeStart: new Date(at.getTime() - 60 * MIN),
onTimeEnd: new Date(at.getTime() + 60 * MIN),
lateGraceMs: 180 * MIN,
};
}
const bands = buildSlotBands([
pointSlot(localHmAsUtc(day, TZ, 7, 0), "07:00"),
pointSlot(localHmAsUtc(day, TZ, 19, 0), "19:00"),
]);
function at(h: number, m: number): Date {
return localHmAsUtc(day, TZ, h, m);
}
function intake(over: Partial<HistoryIntake>): HistoryIntake {
return {
id: "i",
scheduledFor: at(7, 0),
takenAt: null,
skipped: false,
autoMissed: false,
...over,
};
}
// Late "now" so both of today's slots are past their miss cutoff.
const nowEvening = at(23, 59);
describe("reconstructDoseHistory", () => {
it("emits one row per expected slot even with no intakes", () => {
const rows = reconstructDoseHistory(bands, [], nowEvening);
expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]);
expect(rows.every((r) => r.kind === "slot")).toBe(true);
expect(rows.map((r) => r.status)).toEqual(["missed", "missed"]);
});
it("attributes an on-time take to its slot", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ takenAt: at(7, 0), scheduledFor: at(7, 0) })],
nowEvening,
);
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.status).toBe("taken_on_time");
expect(morning?.intake?.takenAt?.toISOString()).toBe(at(7, 0).toISOString());
});
it("attributes a take inside the late tail as taken_late", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ takenAt: at(8, 30), scheduledFor: at(7, 0) })],
nowEvening,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("taken_late");
});
it("emits an 11:29 take as an ad-hoc row and leaves 07:00 missed", () => {
// Marc's case: the stored scheduledFor was snapped to 07:00 by the old
// write path, but the real takenAt (11:29) is outside every band → ad-hoc.
const rows = reconstructDoseHistory(
bands,
[intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })],
nowEvening,
);
const adhoc = rows.find((r) => r.kind === "ad_hoc");
expect(adhoc?.status).toBe("ad_hoc");
expect(adhoc?.at.toISOString()).toBe(at(11, 29).toISOString());
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed");
});
it("binds an explicit skip to its slot by anchor", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ skipped: true, scheduledFor: at(7, 0) })],
nowEvening,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped");
});
it("counts an auto-missed pending row as missed", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ autoMissed: true, scheduledFor: at(7, 0) })],
nowEvening,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed");
});
it("marks a future slot upcoming, not missed", () => {
const earlyMorning = at(5, 0); // before every slot's miss cutoff
const rows = reconstructDoseHistory(bands, [], earlyMorning);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("upcoming");
expect(rows.find((r) => r.timeOfDay === "19:00")?.status).toBe("upcoming");
});
it("a second take near an already-filled slot becomes ad-hoc", () => {
const rows = reconstructDoseHistory(
bands,
[
intake({ id: "a", takenAt: at(7, 0), scheduledFor: at(7, 0) }),
intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }),
],
nowEvening,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe(
"taken_on_time",
);
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
expect(adhoc).toHaveLength(1);
expect(adhoc[0].intake?.id).toBe("b");
});
it("returns rows in chronological order (slots + ad-hoc interleaved)", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ id: "x", takenAt: at(13, 0), scheduledFor: at(13, 0) })],
nowEvening,
);
const times = rows.map((r) => r.at.getTime());
expect(times).toEqual([...times].sort((a, b) => a - b));
});
});
@@ -0,0 +1,78 @@
/**
* v1.15.18 — `normaliseDoseWindows` coerces the persisted `dose_windows` JSON
* into a clean `DoseWindowEntry[]`, dropping anything malformed so the band
* paths never have to defend against an arbitrary JSON shape.
*/
import { describe, expect, it } from "vitest";
import {
buildCanonicalSchedule,
normaliseDoseWindows,
type WorkerScheduleRow,
} from "../worker-helpers";
describe("normaliseDoseWindows", () => {
it("returns null for null / non-array input", () => {
expect(normaliseDoseWindows(null)).toBeNull();
expect(normaliseDoseWindows(undefined)).toBeNull();
expect(normaliseDoseWindows("nope")).toBeNull();
expect(normaliseDoseWindows({ timeOfDay: "07:00" })).toBeNull();
});
it("keeps a well-formed entry", () => {
expect(
normaliseDoseWindows([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]),
).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]);
});
it("drops entries with start > end, bad HH:mm, or missing keys", () => {
expect(
normaliseDoseWindows([
{ timeOfDay: "07:00", start: "12:00", end: "07:00" }, // start > end
{ timeOfDay: "7:00", start: "07:00", end: "09:00" }, // bad HH:mm
{ timeOfDay: "19:00", start: "19:00" }, // missing end
{ timeOfDay: "08:00", start: "25:00", end: "26:00" }, // out of range
]),
).toBeNull();
});
it("keeps the good entries and drops the bad ones in a mixed array", () => {
expect(
normaliseDoseWindows([
{ timeOfDay: "07:00", start: "07:00", end: "09:00" }, // good
{ timeOfDay: "19:00", start: "20:00", end: "19:00" }, // start > end
]),
).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]);
});
it("threads through buildCanonicalSchedule onto the canonical schedule", () => {
const row: WorkerScheduleRow = {
id: "s1",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["07:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
};
expect(buildCanonicalSchedule(row).doseWindows).toEqual([
{ timeOfDay: "07:00", start: "07:00", end: "09:00" },
]);
});
it("a row with no doseWindows yields null on the canonical schedule", () => {
const row: WorkerScheduleRow = {
id: "s1",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["07:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
};
expect(buildCanonicalSchedule(row).doseWindows).toBeNull();
});
});
@@ -0,0 +1,228 @@
/**
* v1.15.18 — band-model intake→slot attribution for the WRITE + EDIT paths.
*
* The write-side counterpart to `tallyComplianceFromLedger` (the read-side
* ledger): both consume the SAME shared band minter so a take is bound to a
* slot identically wherever it is decided. This retires the wide ±6h
* nearest-snap in `resolve-slot-instant.ts` (`snapToleranceMs`) that pulled a
* 13:02 take onto the 19:00 slot and a 15:46 take onto the 07:00 slot.
*
* Given a medication's schedules, the user's timezone, a `takenAt`, and the
* pre-fetched rolling-anchor instants, `attributeTakenToSlot` mints the day's
* `SlotBand[]` (per schedule, never pooled into one minter call — audit
* MEDIUM-9) and runs `attributeIntakeToSlot`:
*
* - the take lands in a slot's band → that slot's canonical anchor instant
* (`band.at`), which the caller writes as `scheduledFor` (snap to slot);
* - the take lands in no band → `null`: an ad-hoc / PRN take the
* caller inserts standalone with `scheduledFor = takenAt`.
*
* PRN / empty / malformed schedules mint no bands (`hasExpectedSlots:false`)
* and therefore always return `null` — every PRN intake is a standalone row.
*
* Pure / synchronous: no DB access. The caller `select`s the schedule columns
* + threads `lastIntakeAt` / `intakeInstants` in (mirrors
* `resolve-slot-instant.ts`).
*/
import {
attributeIntakeToSlot,
type SlotBand,
} from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
type DoseWindowConfig,
} from "@/lib/medications/scheduling/band-minter";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
type WorkerMedicationRow,
type WorkerScheduleRow,
} from "@/lib/medications/scheduling/worker-helpers";
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** The medication projection the attributor reads (matches the write `select`). */
export interface AttributeIntakeMedication extends WorkerMedicationRow {
schedules: WorkerScheduleRow[];
}
export interface AttributeTakenInput {
medication: AttributeIntakeMedication;
userTz: string;
/** The real intake instant whose slot we resolve. */
takenAt: Date;
/**
* Latest non-tombstoned `takenAt` for the medication — anchors the rolling
* retrospective grid's next-due. Pass `null` when unknown / not rolling.
*/
lastIntakeAt?: Date | null;
/**
* Non-skipped intake instants for the medication, ascending. REQUIRED for a
* rolling cadence (the retrospective grid anchors AT each intake); ignored
* otherwise.
*/
intakeInstants?: Date[];
/** Per-dose window override; defaults to `DOSE_WINDOW_DEFAULTS`. */
windowConfig?: DoseWindowConfig;
/** Wall-clock reference; defaults to `takenAt`. Injectable for tests. */
now?: Date;
}
export interface AttributeTakenResult {
/**
* The canonical slot anchor the take attributes to, or `null` for an ad-hoc
* / PRN take (the caller inserts it standalone with `scheduledFor=takenAt`).
*/
slotInstant: Date | null;
/** on_time / late when matched, null when ad-hoc. */
status: "on_time" | "late" | null;
/** False for PRN / empty / malformed medications (no slot machinery). */
hasExpectedSlots: boolean;
}
/**
* Build the day's bands for every schedule of a medication, pooled into one
* `SlotBand[]`. Bands are minted PER SCHEDULE (no tail clipping across
* schedules), then pooled — each slot is claimed by ≤1 intake downstream, so
* the union is safe (mirrors `tallyComplianceFromLedger`).
*/
export function buildMedicationDayBands(input: {
medication: AttributeIntakeMedication;
userTz: string;
/** The instant whose local day's slots are minted (the take's own time). */
around: Date;
lastIntakeAt?: Date | null;
intakeInstants?: Date[];
windowConfig?: DoseWindowConfig;
now?: Date;
}): { bands: SlotBand[]; hasExpectedSlots: boolean } {
const schedules = input.medication.schedules ?? [];
if (schedules.length === 0) {
return { bands: [], hasExpectedSlots: false };
}
const bandMinterMedication: BandMinterMedication = {
id: input.medication.id,
startsOn: input.medication.startsOn,
endsOn: input.medication.endsOn,
oneShot: input.medication.oneShot,
createdAt: input.medication.createdAt,
};
const ctx = buildRecurrenceContext({
medication: input.medication,
userTz: input.userTz,
lastIntakeAt: input.lastIntakeAt ?? null,
});
const canonicalSchedules = schedules.map(buildCanonicalSchedule);
// Mint over the take's local day padded a day each side so a slot near the
// local-midnight boundary (and any DST shift) is still captured — mirrors
// `resolve-slot-instant.ts`. A weekly/rolling band reaches further; its
// minter widens the probe internally, so the padded day window is enough to
// surface the anchor that the take could pair to.
const from = new Date(input.around.getTime() - ONE_DAY_MS);
const to = new Date(input.around.getTime() + ONE_DAY_MS);
const groups = buildBandsForSchedules({
medication: bandMinterMedication,
schedules: canonicalSchedules,
ctx,
userTz: input.userTz,
range: { from, to },
now: input.now ?? input.around,
windowConfig: input.windowConfig,
intakeInstants: input.intakeInstants,
});
const bands: SlotBand[] = [];
let hasExpectedSlots = false;
for (const g of groups) {
if (g.hasExpectedSlots) {
hasExpectedSlots = true;
bands.push(...g.bands);
}
}
return { bands, hasExpectedSlots };
}
/**
* Attribute a TAKEN intake to its scheduled slot via band membership, or
* `null` when it falls in no band (ad-hoc / PRN).
*/
export function attributeTakenToSlot(
input: AttributeTakenInput,
): AttributeTakenResult {
const { bands, hasExpectedSlots } = buildMedicationDayBands({
medication: input.medication,
userTz: input.userTz,
around: input.takenAt,
lastIntakeAt: input.lastIntakeAt,
intakeInstants: input.intakeInstants,
windowConfig: input.windowConfig,
now: input.now ?? input.takenAt,
});
if (!hasExpectedSlots) {
return { slotInstant: null, status: null, hasExpectedSlots: false };
}
const matched = attributeIntakeToSlot(input.takenAt, bands);
if (!matched) {
return { slotInstant: null, status: null, hasExpectedSlots: true };
}
return {
slotInstant: matched.band.at,
status: matched.status,
hasExpectedSlots: true,
};
}
/**
* Validate that `slotInstant` is a real scheduled slot of `medication` on its
* own local day — the server-side guard behind the "diesem Slot zuordnen?"
* late-take nudge. A client may pin an off-window take onto a chosen slot, but
* only onto a slot the schedule actually mints (never an arbitrary instant).
* Returns the canonical band anchor (which the caller writes as
* `scheduledFor`) when the instant matches a band anchor within a minute, else
* `null`.
*/
export function resolveForcedSlotInstant(input: {
medication: AttributeIntakeMedication;
userTz: string;
/** The slot instant the client asks to pin the take onto. */
slotInstant: Date;
lastIntakeAt?: Date | null;
intakeInstants?: Date[];
windowConfig?: DoseWindowConfig;
now?: Date;
}): Date | null {
const { bands, hasExpectedSlots } = buildMedicationDayBands({
medication: input.medication,
userTz: input.userTz,
around: input.slotInstant,
lastIntakeAt: input.lastIntakeAt,
intakeInstants: input.intakeInstants,
windowConfig: input.windowConfig,
now: input.now ?? input.slotInstant,
});
if (!hasExpectedSlots) return null;
const target = input.slotInstant.getTime();
let best: { at: Date; dist: number } | null = null;
for (const band of bands) {
const dist = Math.abs(band.at.getTime() - target);
if (dist <= ANCHOR_MATCH_EPSILON_MS && (best === null || dist < best.dist)) {
best = { at: band.at, dist };
}
}
return best?.at ?? null;
}
/**
* Sub-minute slop for matching a client-supplied force-attribute instant to a
* real band anchor. The client echoes a slot anchor it read from the
* dose-history / today endpoints; sub-minute iOS-vs-server drift must still
* resolve, but an arbitrary off-slot instant must not.
*/
const ANCHOR_MATCH_EPSILON_MS = 60_000;
@@ -0,0 +1,116 @@
/**
* v1.15.18 — window-band intake→slot attribution.
*
* The medical core of the traceable dose-history model. It replaces the two
* wide nearest-neighbour matchers that mis-attributed irregular real intakes:
*
* - WRITE-time: the ±6h `snapToleranceMs` snap in `resolve-slot-instant.ts`
* (½ the inter-slot gap), which pulled a 13:02 take onto the 19:00 slot
* and an 15:46 take onto the 07:00 slot.
* - READ-time: the ±12h/half-gap `slotMatchRadius` in `cadence.ts`.
*
* Attribution is now pure band membership against each slot's configurable
* dose window (the on-time band) plus a late tail:
*
* - intake ∈ [onTimeStart, onTimeEnd] → on_time (this slot)
* - intake ∈ (onTimeEnd, overdueEnd] → late (this slot)
* - otherwise → null (ad-hoc: standalone row,
* the slot stays missed if unfilled)
*
* `overdueEnd` (the late tail past `onTimeEnd`) is capped at the next slot's
* `onTimeStart`, so two adjacent same-day slots can never both claim one
* intake. The on-time band IS the user's per-dose window: a point time maps to
* a symmetric ±half-width, an explicit window to `[start, end]`. All bounds are
* instants — the caller mints them DST-correctly via `localHmAsUtc`, keeping
* this module timezone-agnostic and trivially testable.
*/
/** Per-slot window the caller supplies (bounds already minted as instants). */
export interface SlotWindowInput {
/** Canonical anchor instant for the slot (labelling / next-due / pairing). */
at: Date;
/** "HH:mm" the slot represents (for the history label + dedup key). */
timeOfDay: string;
/** On-time band lower bound (inclusive). */
onTimeStart: Date;
/** On-time band upper bound (inclusive). */
onTimeEnd: Date;
/** Late tail past `onTimeEnd` that still counts as taken-late, in ms. */
lateGraceMs: number;
}
/** A slot window with its (capped) late-tail cutoff resolved. */
export interface SlotBand extends SlotWindowInput {
/** Cutoff past which a take no longer pairs to this slot (capped). */
overdueEnd: Date;
}
export type AttributionStatus = "on_time" | "late";
export interface AttributionResult {
band: SlotBand;
status: AttributionStatus;
}
/**
* Resolve each slot's `overdueEnd` = `onTimeEnd + lateGraceMs`, capped at the
* next slot's `onTimeStart` so adjacent slots' capture bands stay disjoint.
* The input is sorted by anchor instant; the returned bands preserve that order.
*/
export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] {
const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime());
return sorted.map((slot, i) => {
let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs;
const next = sorted[i + 1];
if (next) {
// Never let one slot's late tail bleed into the next slot's on-time
// window — the cap keeps the two capture bands disjoint.
overdueEnd = Math.min(overdueEnd, next.onTimeStart.getTime());
}
// A degenerate/negative tail collapses to the on-time end.
overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime());
return { ...slot, overdueEnd: new Date(overdueEnd) };
});
}
/**
* Attribute an intake instant to the slot whose window contains it, or `null`
* when it falls in no slot's band (an ad-hoc / off-schedule intake).
*
* On overlap-free bands at most one slot matches. As a defensive tie-break
* (custom windows a caller hand-builds could overlap), an on-time match wins
* over a late match, then the nearer anchor wins.
*/
export function attributeIntakeToSlot(
intakeAt: Date,
bands: SlotBand[],
): AttributionResult | null {
const t = intakeAt.getTime();
let best: { result: AttributionResult; rank: number; dist: number } | null =
null;
for (const band of bands) {
const onStart = band.onTimeStart.getTime();
const onEnd = band.onTimeEnd.getTime();
const overdueEnd = band.overdueEnd.getTime();
let status: AttributionStatus | null = null;
if (t >= onStart && t <= onEnd) status = "on_time";
else if (t > onEnd && t <= overdueEnd) status = "late";
if (status === null) continue;
// on_time (rank 0) beats late (rank 1); within a rank the nearer anchor
// wins. Keeps a deterministic pick if a caller's custom bands overlap.
const rank = status === "on_time" ? 0 : 1;
const dist = Math.abs(t - band.at.getTime());
if (
best === null ||
rank < best.rank ||
(rank === best.rank && dist < best.dist)
) {
best = { result: { band, status }, rank, dist };
}
}
return best?.result ?? null;
}
@@ -0,0 +1,557 @@
/**
* v1.15.18 — shared cadence-aware band minter.
*
* THE keystone of the traceable dose-history model. It builds the correct
* `SlotBand[]` for EVERY medication cadence so the three downstream surfaces
* — the read-model (`reconstructDoseHistory`), the compliance %, and the
* write/edit attribution — all consume ONE source of slot windows and can
* never contradict each other (the v1.15.18 audit's CRITICAL-2 finding).
*
* Per the audit, each cadence needs its own band shape:
*
* 1. daily / multi-time — `occurrencesBetween` slots; minute-scale
* on-time (±60min default) + a daily late tail.
* 2. fixed weekdays / N-weeks — derive the family from the REALISED
* inter-slot gap, NOT `doseCadenceFamily`'s
* field-shape heuristic (which mislabels legacy
* `daysOfWeek` meds as daily); day-scale on-time
* (±1 day) + a 4-day late tail.
* 3. rolling (GLP-1) — bands from `expandRollingRetrospective`,
* anchored AT each logged intake (NOT the
* forward-only `occurrencesBetween`, which would
* make every past shot read ad-hoc). Day-scale.
* 4. cyclic — `occurrencesBetween` already drops off-week
* slots; mint bands only for the survivors.
* 5. one-shot — a single WIDE whole-day on-time band; never
* auto-flips to missed.
* 6. PRN / empty / bad rrule — `[]` (the caller renders intakes as plain
* entries) + `hasExpectedSlots: false`.
* 7. DST for day-scale bands — ±N-day bounds minted via `localHmAsUtc` on
* calendar days, never `±N·DAY_MS`.
* 8. multi-schedule on one med — bands built PER SCHEDULE so a weekly tail is
* never clipped by a daily oral's anchor.
*
* Pure / synchronous: no DB access. The DB-reading parts (the rolling
* `intakeInstants`, `lastIntakeAt`) are pre-fetched and threaded in, like
* `resolve-slot-instant.ts`.
*/
import { RRule } from "rrule";
import { DOSE_WINDOW_DEFAULTS } from "@/lib/analytics/compliance";
import {
buildSlotBands,
type SlotBand,
type SlotWindowInput,
} from "@/lib/medications/scheduling/attribution";
import {
expandRollingRetrospective,
occurrencesBetween,
type CanonicalSchedule,
type DoseWindowEntry,
type Occurrence,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import { localHmAsUtc } from "@/lib/timezone";
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/**
* Realised-gap threshold separating the day-scale (weekly) family from the
* minute-scale (daily) family. A cadence whose closest two slots sit at least
* 36h apart is treated as day-scale: that catches every-other-day, weekly,
* bi-weekly and monthly cadences while keeping daily (24h) and intraday
* multi-dose schedules on the minute scale. Derived from the REALISED gap, not
* `doseCadenceFamily`'s field-shape heuristic (audit MEDIUM-6).
*/
const DAY_SCALE_GAP_THRESHOLD_MS = 36 * HOUR_MS;
/** Which band shape the minter chose for a schedule. */
export type BandFamily = "daily" | "weekly" | "one_shot" | "none";
/** The medication projection the minter reads (pre-fetched, no DB access). */
export interface BandMinterMedication {
id: string;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
createdAt: Date;
}
/**
* Per-dose window override. When absent the minter derives the on-time width +
* late tail from `DOSE_WINDOW_DEFAULTS` keyed on the realised family. A caller
* can widen the on-time band per Marc's configurable-window lever.
*/
export interface DoseWindowConfig {
/** Daily on-time half-width around the slot instant (minutes). */
dailyOnTimeMinutes?: number;
/** Daily late tail past the on-time window (minutes). */
dailyOverdueMinutes?: number;
/** Weekly / rolling on-time half-width around the slot day (days). */
weeklyOnTimeDays?: number;
/** Weekly / rolling late tail past the on-time day (days). */
weeklyOverdueDays?: number;
}
export interface BuildBandsInput {
medication: BandMinterMedication;
schedule: CanonicalSchedule;
ctx: RecurrenceContext;
userTz: string;
/** The [from, to] window to mint expected slots over (inclusive). */
range: { from: Date; to: Date };
/** Wall-clock reference, threaded to the rolling retrospective expansion. */
now: Date;
/** Per-dose window override; defaults to `DOSE_WINDOW_DEFAULTS`. */
windowConfig?: DoseWindowConfig;
/**
* Non-skipped intake instants for THIS medication. Required for a rolling
* cadence (the retrospective grid anchors AT each intake); ignored by every
* other cadence. Pre-fetched by the caller — the minter has no DB access.
*/
intakeInstants?: Date[];
}
export interface BandMinterResult {
bands: SlotBand[];
/** False for PRN / empty / malformed schedules (no slot machinery). */
hasExpectedSlots: boolean;
/** The band shape chosen — surfaced for diagnostics + the UI label. */
family: BandFamily;
}
/** One schedule's band group, for a multi-schedule medication. */
export interface ScheduleBandGroup extends BandMinterResult {
scheduleId: string;
}
/**
* Build the `SlotBand[]` for one schedule of one medication over `range`.
*
* The single per-schedule entry point. A multi-schedule medication routes
* each schedule through here independently (see `buildBandsForSchedules`) so a
* sparse cadence's late tail is never clipped by a denser sibling's anchor.
*/
export function buildBandsForMedication(
input: BuildBandsInput,
): BandMinterResult {
const { medication, schedule, ctx, userTz, range, now } = input;
// PRN / empty / malformed → no slot machinery. The caller renders the
// intakes as plain entries. `occurrencesBetween` already returns [] for PRN;
// we also short-circuit a schedule that describes no cadence at all (no
// rrule, no rolling, no legacy weekday/time, not one-shot) so a malformed or
// empty schedule never silently mints a stray daily band.
if (schedule.scheduleType === "PRN" || !describesACadence(schedule, medication)) {
return { bands: [], hasExpectedSlots: false, family: "none" };
}
// One-shot — a single WIDE whole-day on-time band that never auto-misses.
if (medication.oneShot) {
const bands = buildOneShotBands(schedule, ctx, userTz, range);
// A one-shot whose anchor lies outside the window mints no band here, but
// it IS a scheduled dose — keep `hasExpectedSlots` true.
return { bands, hasExpectedSlots: true, family: "one_shot" };
}
// Rolling (GLP-1) — retrospective bands anchored AT each logged intake.
if (schedule.rollingIntervalDays !== null) {
const occ = expandRollingRetrospective(
schedule,
ctx,
range.from,
range.to,
input.intakeInstants ?? [],
now,
);
// The canonical rolling case is a once-weekly+ injection (day-scale). A
// degenerate ≤1-day rolling interval is effectively daily, so a ±1-day
// on-time window would over-widen and let one take claim adjacent slots —
// keep it minute-scale. The realised every-N-days cadence is the field
// here (intakes drive the anchors), so the interval is the right signal.
const family: "daily" | "weekly" =
schedule.rollingIntervalDays >= 2 ? "weekly" : "daily";
const bands = mintBands(occ, family, input.windowConfig, userTz, schedule);
return { bands, hasExpectedSlots: true, family };
}
// Daily / weekly / cyclic / rrule — `occurrencesBetween` already filters
// cyclic off-week slots. Classify the family by the REALISED inter-slot gap
// probed over a padded window (so a sparse cadence yields ≥2 slots), NOT the
// field-shape `doseCadenceFamily` heuristic.
const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx);
const family = realisedFamily(schedule, ctx, range);
const bands = mintBands(occurrences, family, input.windowConfig, userTz, schedule);
return { bands, hasExpectedSlots: true, family };
}
/**
* Build per-schedule band groups for a multi-schedule medication. Each
* schedule is banded independently — the keystone invariant that keeps a
* weekly injection's 4-day tail from being clipped by a daily oral's anchor.
*/
export function buildBandsForSchedules(input: {
medication: BandMinterMedication;
schedules: CanonicalSchedule[];
ctx: RecurrenceContext;
userTz: string;
range: { from: Date; to: Date };
now: Date;
windowConfig?: DoseWindowConfig;
/** Per-medication intake instants (the rolling anchors). */
intakeInstants?: Date[];
}): ScheduleBandGroup[] {
return input.schedules.map((schedule) => {
const result = buildBandsForMedication({
medication: input.medication,
schedule,
ctx: input.ctx,
userTz: input.userTz,
range: input.range,
now: input.now,
windowConfig: input.windowConfig,
intakeInstants: input.intakeInstants,
});
return { scheduleId: schedule.id, ...result };
});
}
// ────────────────────────────────────────────────────────────────────
// Band construction
// ────────────────────────────────────────────────────────────────────
/**
* Turn engine occurrences into `SlotWindowInput`s of the given family and run
* them through `buildSlotBands` (which resolves + caps the late tails so
* adjacent slots never both claim one intake).
*/
function mintBands(
occurrences: Occurrence[],
family: "daily" | "weekly",
windowConfig: DoseWindowConfig | undefined,
userTz: string,
schedule: CanonicalSchedule,
): SlotBand[] {
const w = resolveWindows(windowConfig);
// The persisted per-dose windows (Marc's "07:00–09:00" lever). When a slot's
// `timeOfDay` matches an entry, the on-time band is the explicit `[start,
// end]` range; otherwise the default symmetric ±width applies. The late tail
// stays cadence-derived for every slot.
const explicit = indexDoseWindows(schedule.doseWindows ?? null);
const inputs: SlotWindowInput[] = occurrences.map((occ) =>
family === "weekly"
? weeklyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null)
: dailyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null),
);
return buildSlotBands(inputs);
}
/**
* Index the schedule's `doseWindows` by `timeOfDay` for O(1) per-slot lookup.
* A duplicate `timeOfDay` keeps the first entry (the write path Zod-validates
* uniqueness, so this is purely defensive).
*/
function indexDoseWindows(
windows: DoseWindowEntry[] | null,
): Map<string, DoseWindowEntry> {
const map = new Map<string, DoseWindowEntry>();
if (!windows) return map;
for (const w of windows) {
// Defence-in-depth: only index well-formed `start <= end` HH:mm entries.
// `buildCanonicalSchedule` already normalises the persisted JSON, but a
// directly-constructed `CanonicalSchedule` (tests, compliance helpers)
// must never widen a band from a malformed range.
if (!isUsableDoseWindow(w)) continue;
if (!map.has(w.timeOfDay)) map.set(w.timeOfDay, w);
}
return map;
}
/** True when a dose window is well-formed HH:mm with `start <= end`. */
function isUsableDoseWindow(w: DoseWindowEntry): boolean {
const start = localHourMinute(w.start);
const end = localHourMinute(w.end);
const startMin = start.hour * 60 + start.minute;
const endMin = end.hour * 60 + end.minute;
return (
HHMM_BAND_RE.test(w.timeOfDay) &&
HHMM_BAND_RE.test(w.start) &&
HHMM_BAND_RE.test(w.end) &&
startMin <= endMin
);
}
const HHMM_BAND_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/**
* Minute-scale band around a slot instant (daily / intraday cadences). When an
* explicit per-dose window is configured for this `timeOfDay`, the on-time band
* is the literal `[start, end]` range minted on the slot's local day (DST-correct
* via `localHmAsUtc`); otherwise the symmetric ±`dailyOnTimeMinutes` default.
*/
function dailyWindow(
occ: Occurrence,
w: ResolvedWindows,
userTz: string,
explicit: DoseWindowEntry | null,
): SlotWindowInput {
const onTime = explicit
? explicitOnTimeBounds(occ.at, explicit, userTz)
: {
onTimeStart: new Date(
occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS,
),
onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS),
};
return {
at: occ.at,
timeOfDay: occ.timeOfDay,
onTimeStart: onTime.onTimeStart,
onTimeEnd: onTime.onTimeEnd,
lateGraceMs: w.dailyOverdueMinutes * MINUTE_MS,
};
}
/**
* Mint an explicit `[start, end]` on-time range on the slot's local calendar
* day. `localHmAsUtc` keys off the local day of its first argument (`occ.at`),
* so the bounds land on the same day as the slot, DST-correct.
*/
function explicitOnTimeBounds(
occAt: Date,
window: DoseWindowEntry,
userTz: string,
): { onTimeStart: Date; onTimeEnd: Date } {
const start = localHourMinute(window.start);
const end = localHourMinute(window.end);
return {
onTimeStart: localHmAsUtc(occAt, userTz, start.hour, start.minute),
onTimeEnd: localHmAsUtc(occAt, userTz, end.hour, end.minute),
};
}
/**
* Day-scale band around a slot instant (weekly / rolling / sparse cadences).
* The ±N-day bounds are minted via `localHmAsUtc` on the calendar days N days
* before / after the slot's local day — DST-correct, unlike `±N·DAY_MS`, which
* drifts an hour across a transition (audit MEDIUM-8).
*/
function weeklyWindow(
occ: Occurrence,
w: ResolvedWindows,
userTz: string,
explicit: DoseWindowEntry | null,
): SlotWindowInput {
const { hour, minute } = localHourMinute(occ.timeOfDay);
// An explicit per-dose window pins the on-time band to the literal `[start,
// end]` range on the slot's own local day (a within-day window the user set);
// otherwise the default day-scale ±`weeklyOnTimeDays` envelope. Either way the
// late tail below stays day-scale + DST-correct.
const onTimeStart = explicit
? localHmAsUtc(
occ.at,
userTz,
localHourMinute(explicit.start).hour,
localHourMinute(explicit.start).minute,
)
: localHmAsUtc(
new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS),
userTz,
hour,
minute,
);
const onTimeEnd = explicit
? localHmAsUtc(
occ.at,
userTz,
localHourMinute(explicit.end).hour,
localHourMinute(explicit.end).minute,
)
: localHmAsUtc(
new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS),
userTz,
hour,
minute,
);
// The late tail is also day-scale: the instant N overdue-days past the
// on-time end, minted on the calendar day for DST-correctness, then expressed
// as a millisecond grace `buildSlotBands` consumes.
const overdueAnchor = localHmAsUtc(
new Date(onTimeEnd.getTime() + w.weeklyOverdueDays * DAY_MS),
userTz,
hour,
minute,
);
const lateGraceMs = Math.max(0, overdueAnchor.getTime() - onTimeEnd.getTime());
return {
at: occ.at,
timeOfDay: occ.timeOfDay,
onTimeStart,
onTimeEnd,
lateGraceMs,
};
}
/**
* One-shot — a single whole-day on-time band per `timesOfDay` anchor. The
* band spans the local day start..end so a take any time that day is on-time,
* and the late tail is zero (a one-shot never auto-misses; an unfilled slot
* simply reads `upcoming` until the day closes and then stays a never-acted
* slot rather than a hard miss).
*/
function buildOneShotBands(
schedule: CanonicalSchedule,
ctx: RecurrenceContext,
userTz: string,
range: { from: Date; to: Date },
): SlotBand[] {
const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx);
const inputs: SlotWindowInput[] = occurrences.map((occ) => {
const dayStart = localHmAsUtc(occ.at, userTz, 0, 0);
// End at the next local midnight (start of the following day) so a 23:59
// take is still inside the band.
const nextDayStart = localHmAsUtc(
new Date(dayStart.getTime() + 25 * HOUR_MS),
userTz,
0,
0,
);
return {
at: occ.at,
timeOfDay: occ.timeOfDay,
onTimeStart: dayStart,
onTimeEnd: nextDayStart,
lateGraceMs: 0,
};
});
return buildSlotBands(inputs);
}
// ────────────────────────────────────────────────────────────────────
// Family classification (realised inter-slot gap)
// ────────────────────────────────────────────────────────────────────
/**
* Classify the schedule's family from the REALISED inter-slot gap rather than
* the field-shape `doseCadenceFamily` heuristic (which mislabels a legacy
* Mon/Thu `daysOfWeek` med as daily ±60min). Probe a window padded to ≥16
* weeks so a sparse cadence yields ≥2 slots and a real gap is observable; the
* minimum consecutive gap decides the scale.
*/
function realisedFamily(
schedule: CanonicalSchedule,
ctx: RecurrenceContext,
range: { from: Date; to: Date },
): "daily" | "weekly" {
const probeDays = Math.max(
Math.ceil((range.to.getTime() - range.from.getTime()) / DAY_MS),
16 * 7,
);
const probeFrom = new Date(range.to.getTime() - probeDays * DAY_MS);
const slots = occurrencesBetween(schedule, probeFrom, range.to, ctx);
if (slots.length < 2) {
// Too few slots to measure a gap. Fall back to the field-shape signal:
// an explicit rolling/weekly/monthly shape is day-scale, else daily.
return fieldShapeFamily(schedule);
}
const times = slots
.map((s) => s.at.getTime())
.sort((a, b) => a - b);
let minGap = Infinity;
for (let i = 1; i < times.length; i++) {
const gap = times[i] - times[i - 1];
if (gap > 0 && gap < minGap) minGap = gap;
}
if (!Number.isFinite(minGap)) return fieldShapeFamily(schedule);
return minGap >= DAY_SCALE_GAP_THRESHOLD_MS ? "weekly" : "daily";
}
/** Field-shape fallback when too few slots exist to measure a realised gap. */
function fieldShapeFamily(schedule: CanonicalSchedule): "daily" | "weekly" {
if (
schedule.rollingIntervalDays !== null &&
schedule.rollingIntervalDays >= 2
) {
return "weekly";
}
if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(schedule.rrule ?? "")) {
return "weekly";
}
return "daily";
}
// ────────────────────────────────────────────────────────────────────
// Cadence detection + small helpers
// ────────────────────────────────────────────────────────────────────
/**
* True when the schedule describes ANY cadence the engine can expand — a
* one-shot, a rolling interval, an rrule, or a legacy `daysOfWeek`/`timesOfDay`
* shape. A schedule with none of these (a stray/empty row) is treated like PRN:
* no expected slots. Keeps a malformed row from minting a phantom daily band.
*/
function describesACadence(
schedule: CanonicalSchedule,
medication: BandMinterMedication,
): boolean {
if (medication.oneShot) return true;
if (schedule.rollingIntervalDays !== null) return true;
if (schedule.rrule !== null && schedule.rrule.trim() !== "") {
// A malformed rrule describes no usable cadence — `expandRrule` swallows
// the parse error and returns no slots, so without this gate the schedule
// would read `hasExpectedSlots: true` with an empty band list (audit
// CRITICAL-3: a bad rrule must fall to the no-slot-machinery path).
return isParseableRrule(schedule.rrule);
}
if (schedule.daysOfWeek !== null && schedule.daysOfWeek.trim() !== "") {
return true;
}
if (schedule.timesOfDay.length > 0) return true;
return false;
}
/** True when the bare RRULE body parses (mirrors `expandRrule`'s try/catch). */
function isParseableRrule(rrule: string): boolean {
try {
RRule.fromString(`RRULE:${rrule}`);
return true;
} catch {
return false;
}
}
interface ResolvedWindows {
dailyOnTimeMinutes: number;
dailyOverdueMinutes: number;
weeklyOnTimeDays: number;
weeklyOverdueDays: number;
}
/** Merge the caller's per-dose overrides over `DOSE_WINDOW_DEFAULTS`. */
function resolveWindows(config: DoseWindowConfig | undefined): ResolvedWindows {
return {
dailyOnTimeMinutes:
config?.dailyOnTimeMinutes ?? DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes,
dailyOverdueMinutes:
config?.dailyOverdueMinutes ?? DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
weeklyOnTimeDays:
config?.weeklyOnTimeDays ?? DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays,
weeklyOverdueDays:
config?.weeklyOverdueDays ?? DOSE_WINDOW_DEFAULTS.weeklyOverdueDays,
};
}
/**
* Parse an "HH:mm" slot label into hour/minute, defaulting to the slot's own
* local time when the label is malformed. The engine always sets a parseable
* `timeOfDay`, but the day-scale bound minting must never crash the read path.
*/
function localHourMinute(timeOfDay: string): { hour: number; minute: number } {
const [hStr, mStr] = timeOfDay.split(":");
const h = Number(hStr);
const m = Number(mStr);
if (!Number.isFinite(h) || !Number.isFinite(m)) return { hour: 0, minute: 0 };
return { hour: h, minute: m };
}
+109
View File
@@ -28,6 +28,15 @@
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import {
type CanonicalSchedule,
type RecurrenceContext,
@@ -35,6 +44,7 @@ import {
expandRollingRetrospective,
occurrencesBetween,
} from "@/lib/medications/scheduling/recurrence";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
import { wallClockInTz } from "@/lib/tz/wall-clock";
/**
@@ -87,6 +97,14 @@ export interface ScheduleLike {
scheduleType?: ScheduleType | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
/**
* v1.15.18 — per-dose configurable on-time windows. Carried onto the
* canonical schedule so the cadence-timeline bands (the card last/next dose)
* honour the same explicit window the % and the history view use. Accepts the
* raw persisted JSON (a full Prisma row's `Json?` column drops straight in);
* `toCanonical` normalises it via `normaliseDoseWindows`.
*/
doseWindows?: unknown;
/** Stable id for the engine occurrence; defaults to a synthetic value. */
id?: string;
}
@@ -126,6 +144,7 @@ function toCanonical(
scheduleType: schedule.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: schedule.cyclicOnWeeks ?? null,
cyclicOffWeeks: schedule.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(schedule.doseWindows),
};
}
@@ -790,6 +809,19 @@ export function missedDoses(
timeZone?: string,
engineCtx?: CadenceEngineContext,
): number {
// v1.15.18 — when an engine context is threaded the missed count comes from
// the unified band ledger (the same one the % and the history view read), so
// every "missed" surface agrees. Pure-math callers (no context) keep the
// legacy timeline-status count.
const ledger = missedFromLedger(
schedules,
events,
asOf,
windowDays,
timeZone,
engineCtx,
);
if (ledger !== null) return ledger;
const timeline = buildCadenceTimeline(
schedules,
events,
@@ -801,3 +833,80 @@ export function missedDoses(
);
return timeline.filter((d) => d.status === "missed").length;
}
/**
* v1.15.18 — the count of `missed` rows in the unified dose-history ledger
* over the trailing window. Returns null when no engine context is supplied
* (the caller then uses the legacy timeline tally). Shares the band minter +
* `reconstructDoseHistory` with the compliance % so the numbers agree.
*/
function missedFromLedger(
schedules: ScheduleLike[],
events: IntakeEventLike[],
asOf: Date,
windowDays: number,
timeZone: string | undefined,
engineCtx: CadenceEngineContext | undefined,
): number | null {
if (!engineCtx) return null;
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const userTz = engineCtx.timeZone || timeZone || "UTC";
const medication: BandMinterMedication = {
id: "missed-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "missed-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
},
timeZone: userTz,
lastIntakeAt: engineCtx.lastIntakeAt,
};
const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => {
const base = toCanonical(s, i);
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
!engineCtx.oneShot
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
});
const intakeInstants = events
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
medication,
schedules: canonicalSchedules,
ctx: recurrenceCtx,
userTz,
range: { from, to: asOf },
now: asOf,
intakeInstants,
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
}));
const rows = reconstructDoseHistory(bands, intakes, asOf);
return rows.filter((r) => r.status === "missed").length;
}
+142 -2
View File
@@ -19,6 +19,11 @@
* legacy system-local behaviour the W19e tests pin.
*/
import type { SlotBand } from "./attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "./band-minter";
import {
buildCadenceTimeline,
startOfLocalDay,
@@ -27,6 +32,15 @@ import {
type PairedDose,
type ScheduleLike,
} from "./cadence";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "./dose-history";
import {
type CanonicalSchedule,
type RecurrenceContext,
} from "./recurrence";
import { normaliseDoseWindows } from "./worker-helpers";
export interface ComplianceChips {
/** 0-100, taken / (taken + missed). Skipped doses are excluded from
@@ -142,6 +156,117 @@ export function streaksFromTimeline(
return { current, longest };
}
/**
* v1.15.18 — band-ledger tally counts for the detail-page chips.
*
* When the route threads an engine context (RRULE / rolling / cyclic /
* one-shot / PRN / legacy) the adherence rate + missed count come from the
* SAME unified dose-history ledger the compliance % and the history view
* read — so the chips can never contradict either. The streak still walks
* the cadence timeline (its day-grain "every dose taken or skipped" rule is
* orthogonal to per-dose attribution). Returns null when there is no engine
* context (a pure-math caller) — the caller then falls back to the legacy
* timeline tally.
*/
function ledgerChipCounts(
schedules: ScheduleLike[],
events: IntakeEventLike[],
asOf: Date,
windowDays: number,
timeZone: string | undefined,
engineCtx: CadenceEngineContext | undefined,
): { taken: number; missed: number } | null {
if (!engineCtx) return null;
const DAY_MS = 24 * 60 * 60 * 1000;
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const userTz = engineCtx.timeZone || timeZone || "UTC";
const medication: BandMinterMedication = {
id: "chips-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "chips-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
},
timeZone: userTz,
lastIntakeAt: engineCtx.lastIntakeAt,
};
const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => {
const base: CanonicalSchedule = {
id: s.id ?? `chips-${i}`,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
timesOfDay: s.timesOfDay ?? [],
daysOfWeek: s.daysOfWeek ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(s.doseWindows),
};
// Surface windowStart as the single time-of-day for a legacy daily row
// so the band minter's cadence gate mints its daily band (see the
// analytics/compliance tally for the same normalisation).
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
!engineCtx.oneShot
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
});
const intakeInstants = events
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
medication,
schedules: canonicalSchedules,
ctx: recurrenceCtx,
userTz,
range: { from, to: asOf },
now: asOf,
intakeInstants,
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
}));
const rows = reconstructDoseHistory(bands, intakes, asOf);
let taken = 0;
let missed = 0;
for (const row of rows) {
if (row.status === "taken_on_time" || row.status === "taken_late") taken++;
else if (row.status === "missed") missed++;
}
return { taken, missed };
}
export function complianceChips(
schedules: ScheduleLike[],
events: IntakeEventLike[],
@@ -160,8 +285,23 @@ export function complianceChips(
timeZone,
engineCtx,
);
const taken = timeline.filter((d) => d.status === "taken").length;
const missed = timeline.filter((d) => d.status === "missed").length;
// v1.15.18 — adherence + missed come from the unified band ledger when an
// engine context is present (so the chips agree with the % and the history
// view); the legacy timeline tally is the fallback for pure-math callers.
const ledger = ledgerChipCounts(
schedules,
events,
asOf,
windowDays,
timeZone,
engineCtx,
);
const taken = ledger
? ledger.taken
: timeline.filter((d) => d.status === "taken").length;
const missed = ledger
? ledger.missed
: timeline.filter((d) => d.status === "missed").length;
const denom = taken + missed;
const adherenceRate = denom === 0 ? null : Math.round((taken / denom) * 100);
const { current, longest } = streaksFromTimeline(
@@ -0,0 +1,162 @@
/**
* v1.15.18 — traceable dose-history reconstruction (spec B).
*
* Builds a complete, attributable ledger for a medication's day(s): every
* expected slot with a status, plus every off-schedule intake tagged ad-hoc.
* It is the read-model behind the medication history view + the card's
* last/next dose, and it converges forward over legacy mis-snapped rows
* because it attributes a TAKEN intake by its real `takenAt`, not the stored
* `scheduledFor` the old write path may have snapped to a slot.
*
* Attribution rules:
* - skipped / auto-missed / pending rows (no `takenAt`) bind to the slot
* whose anchor equals their `scheduledFor` (±epsilon): these were written
* against a slot deliberately, so they annotate that slot;
* - a TAKEN intake is attributed by `attributeIntakeToSlot(takenAt, bands)`;
* inside a band → that slot (on-time / late), outside every band → ad-hoc;
* - each slot is claimed by at most one intake (first/best wins); extra
* intakes near a filled slot fall through to ad-hoc.
*
* Pure / synchronous / instant-based — the caller mints the bands DST-correctly
* via `localHmAsUtc` and supplies the per-dose window.
*/
import {
attributeIntakeToSlot,
type SlotBand,
} from "@/lib/medications/scheduling/attribution";
/** The minimal intake shape the ledger reads. */
export interface HistoryIntake {
id?: string;
/** Stored slot anchor (may be a legacy mis-snapped instant for takes). */
scheduledFor: Date;
/** Real intake time, or null for skipped / pending / auto-missed rows. */
takenAt: Date | null;
skipped: boolean;
/** Cron-marked forgotten dose (counts as missed, not skipped). */
autoMissed?: boolean;
}
export type DoseHistoryStatus =
| "taken_on_time"
| "taken_late"
| "skipped"
| "missed"
| "upcoming"
| "ad_hoc";
export interface DoseHistoryRow {
/** A scheduled slot, or a standalone off-schedule intake. */
kind: "slot" | "ad_hoc";
/** The slot anchor instant, or the ad-hoc take's own time. */
at: Date;
/** The slot's "HH:mm" label, or null for an ad-hoc row. */
timeOfDay: string | null;
status: DoseHistoryStatus;
/** The intake attributed to this row, if any. */
intake: HistoryIntake | null;
}
/** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */
const ANCHOR_EPSILON_MS = 60_000;
/**
* Reconstruct the dose-history ledger for the slots described by `bands` and
* the supplied intakes. Returns rows in chronological order.
*/
export function reconstructDoseHistory(
bands: SlotBand[],
intakes: HistoryIntake[],
now: Date,
): DoseHistoryRow[] {
// Per-band claim: the intake attributed to it + the resolved status.
const claim = new Map<
SlotBand,
{ intake: HistoryIntake; status: DoseHistoryStatus }
>();
const adHoc: HistoryIntake[] = [];
// Partition: anchored rows (skip / auto-missed / pending — no takenAt) bind
// by scheduledFor; taken rows attribute by real takenAt band membership.
// Process anchored first so a deliberate skip/miss owns its slot before a
// stray take could.
const anchored = intakes.filter((i) => i.takenAt === null);
const taken = intakes.filter((i) => i.takenAt !== null);
for (const i of anchored) {
const band = nearestAnchorBand(i.scheduledFor, bands);
const status: DoseHistoryStatus = i.skipped ? "skipped" : "missed";
if (band && !claim.has(band)) {
claim.set(band, { intake: i, status });
} else {
// A skip/miss with no matching slot (legacy off-grid) — surface it so
// nothing silently vanishes; tag it ad-hoc.
adHoc.push(i);
}
}
for (const i of taken) {
const matched = attributeIntakeToSlot(i.takenAt as Date, bands);
if (matched && !claim.has(matched.band)) {
claim.set(matched.band, {
intake: i,
status: matched.status === "on_time" ? "taken_on_time" : "taken_late",
});
} else {
adHoc.push(i);
}
}
const rows: DoseHistoryRow[] = bands.map((band) => {
const c = claim.get(band);
if (c) {
return {
kind: "slot",
at: band.at,
timeOfDay: band.timeOfDay,
status: c.status,
intake: c.intake,
};
}
// Unfilled slot: missed only once the miss cutoff (the late tail's end)
// has passed; until then the dose is still takeable, so it reads upcoming
// rather than prematurely missed.
const status: DoseHistoryStatus =
now.getTime() > band.overdueEnd.getTime() ? "missed" : "upcoming";
return {
kind: "slot",
at: band.at,
timeOfDay: band.timeOfDay,
status,
intake: null,
};
});
for (const i of adHoc) {
rows.push({
kind: "ad_hoc",
at: i.takenAt ?? i.scheduledFor,
timeOfDay: null,
status: "ad_hoc",
intake: i,
});
}
rows.sort((a, b) => a.at.getTime() - b.at.getTime());
return rows;
}
/** The band whose anchor is within epsilon of `instant`, nearest wins. */
function nearestAnchorBand(instant: Date, bands: SlotBand[]): SlotBand | null {
const t = instant.getTime();
let best: SlotBand | null = null;
let bestDist = Infinity;
for (const band of bands) {
const dist = Math.abs(band.at.getTime() - t);
if (dist <= ANCHOR_EPSILON_MS && dist < bestDist) {
bestDist = dist;
best = band;
}
}
return best;
}
@@ -0,0 +1,37 @@
/**
* Default on-time / overdue window widths for the compliance engine.
*
* Kept in its own leaf module — free of any server-only import — so the
* client-side dose-window editor can read the same defaults without
* pulling the full analytics graph (and its `node:async_hooks` deps)
* into the browser bundle. `@/lib/analytics/compliance` re-exports this
* constant so existing server-side imports keep their path.
*
* DAILY / INTRADAY dose (minute-scale windows):
* - on-time: target ± 1h
* - overdue: up to target + 4h (late-but-counts)
* - missed: > target + 4h
*
* WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule):
* - on-time: target day ± 1 day
* - overdue: up to target + 4 days (late-but-counts)
* - missed: > target + 4 days
*/
export const DOSE_WINDOW_DEFAULTS = {
/** Daily / intraday on-time half-width around the target instant (min). */
dailyOnTimeMinutes: 60,
/**
* Daily / intraday overdue tail past the on-time window (min). A dose
* taken inside `[onTime, onTime + overdue]` still counts; beyond it the
* dose is missed.
*/
dailyOverdueMinutes: 240 - 60,
/** Weekly / rolling on-time half-width around the target day (days). */
weeklyOnTimeDays: 1,
/**
* Weekly / rolling overdue tail past the target day (days) — the clinical
* 4-day GLP-1 rule. A shot inside `[target, target + 4d]` still counts;
* beyond it the dose is missed and the user skips to the next slot.
*/
weeklyOverdueDays: 4,
} as const;
@@ -81,6 +81,25 @@ export interface CanonicalSchedule {
cyclicOnWeeks: number | null;
/** v1.7.0 — cyclic "off" weeks. Only read when `scheduleType === "CYCLIC"`. */
cyclicOffWeeks: number | null;
/**
* v1.15.18 — per-dose configurable on-time intake windows. The recurrence
* engine itself never reads these (slot timing is unchanged); they ride on
* the canonical schedule purely so the band minter can build a slot's
* on-time band from an explicit `[start, end]` range for the matching
* `timeOfDay`. NULL / empty = every slot uses the default ±1h derivation.
*/
doseWindows?: DoseWindowEntry[] | null;
}
/**
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
* the window applies to (matching a `timesOfDay` entry); `start`/`end` are the
* HH:mm on-time bounds in the user's wall clock (`start <= end`, same local day).
*/
export interface DoseWindowEntry {
timeOfDay: string;
start: string;
end: string;
}
export interface RecurrenceContext {
@@ -19,6 +19,11 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant";
import {
attributeTakenToSlot,
resolveForcedSlotInstant,
type AttributeIntakeMedication,
} from "@/lib/medications/scheduling/attribute-intake";
import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
@@ -294,6 +299,9 @@ const SCHEDULE_SELECT = {
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
// v1.15.18 — the persisted per-dose window the band attribution honours so
// the write/edit slot binding uses the SAME on-time window as the % + history.
doseWindows: true,
} as const;
const MEDICATION_SELECT = {
@@ -387,3 +395,185 @@ export async function resolveSlotInstantForWrite(
now: input.now,
});
}
/**
* v1.15.18 — load a medication's schedules + rolling anchors and adapt them to
* the shared band attributor's projection. Mirrors `resolveSlotInstantForWrite`
* but returns the full medication so the caller can run band attribution AND
* the force-attribute guard from one load.
*/
async function loadAttributeMedication(input: {
userId: string;
medicationId: string;
client: PrismaLike;
}): Promise<{
medication: AttributeIntakeMedication;
lastIntakeAt: Date | null;
} | null> {
const medication = await input.client.medication.findFirst({
where: { id: input.medicationId, userId: input.userId },
select: MEDICATION_SELECT,
});
if (!medication || medication.schedules.length === 0) return null;
// Rolling cadence anchors off the last logged intake — fetch it only when a
// schedule actually needs it (mirrors the projector's gate).
let lastIntakeAt: Date | null = null;
if (medication.schedules.some((s) => s.rollingIntervalDays !== null)) {
const lastIntake = await input.client.medicationIntakeEvent.findFirst({
where: {
userId: input.userId,
medicationId: input.medicationId,
deletedAt: null,
takenAt: { not: null },
},
orderBy: { takenAt: "desc" },
select: { takenAt: true },
});
lastIntakeAt = lastIntake?.takenAt ?? null;
}
return {
medication: {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
schedules: medication.schedules as WorkerScheduleRow[],
},
lastIntakeAt,
};
}
export interface ResolveSlotByBandInput {
userId: string;
medicationId: string;
userTz: string;
/** The real intake instant the slot is resolved from. */
takenAt: Date;
/** Reference "now"; defaults to the current time. */
now?: Date;
/** Inject a Prisma client/tx in tests; defaults to the app client. */
client?: PrismaLike;
}
export interface ResolveSlotByBandResult {
/** Canonical slot anchor the take attributes to, or null (ad-hoc / PRN). */
slotInstant: Date | null;
/** on_time / late when matched, null when ad-hoc. */
status: "on_time" | "late" | null;
/** False for PRN / empty / malformed medications (no slot machinery). */
hasExpectedSlots: boolean;
}
/**
* v1.15.18 — band-model slot resolution for the intake WRITE/EDIT paths,
* replacing the wide ±6h `snapToleranceMs` nearest-snap. The take is bound to
* a slot by band membership against the SAME minter the read ledger + the
* compliance % consume, so the three surfaces can never disagree. `null`
* means ad-hoc / PRN → the caller inserts a standalone row with
* `scheduledFor = takenAt`.
*/
export async function resolveSlotForWriteByBand(
input: ResolveSlotByBandInput,
): Promise<ResolveSlotByBandResult> {
const client = input.client ?? defaultPrisma;
const loaded = await loadAttributeMedication({
userId: input.userId,
medicationId: input.medicationId,
client,
});
if (!loaded) {
return { slotInstant: null, status: null, hasExpectedSlots: false };
}
return attributeTakenToSlot({
medication: loaded.medication,
userTz: input.userTz,
takenAt: input.takenAt,
lastIntakeAt: loaded.lastIntakeAt,
intakeInstants: await rollingIntakeInstantsIfNeeded(
loaded.medication,
input.userId,
input.medicationId,
input.takenAt,
client,
),
now: input.now,
});
}
export interface ResolveForcedSlotInput {
userId: string;
medicationId: string;
userTz: string;
/** The slot instant the client asks to pin an off-window take onto. */
slotInstant: Date;
now?: Date;
client?: PrismaLike;
}
/**
* v1.15.18 — validate the client's "diesem Slot zuordnen?" pin: the supplied
* instant must be a REAL scheduled slot of this medication on its day (within
* a minute of a band anchor). Returns the canonical anchor on a match, or
* `null` when the instant is not a slot — the route then rejects the pin.
*/
export async function resolveForcedSlotForWrite(
input: ResolveForcedSlotInput,
): Promise<Date | null> {
const client = input.client ?? defaultPrisma;
const loaded = await loadAttributeMedication({
userId: input.userId,
medicationId: input.medicationId,
client,
});
if (!loaded) return null;
return resolveForcedSlotInstant({
medication: loaded.medication,
userTz: input.userTz,
slotInstant: input.slotInstant,
lastIntakeAt: loaded.lastIntakeAt,
intakeInstants: await rollingIntakeInstantsIfNeeded(
loaded.medication,
input.userId,
input.medicationId,
input.slotInstant,
client,
),
now: input.now,
});
}
/**
* Fetch the non-skipped `takenAt` instants (≤ `around`) a rolling cadence
* anchors its retrospective grid on. Returns `undefined` for non-rolling
* medications so the band minter skips the (unused) work.
*/
async function rollingIntakeInstantsIfNeeded(
medication: AttributeIntakeMedication,
userId: string,
medicationId: string,
around: Date,
client: PrismaLike,
): Promise<Date[] | undefined> {
if (!medication.schedules.some((s) => s.rollingIntervalDays !== null)) {
return undefined;
}
const rows = await client.medicationIntakeEvent.findMany({
where: {
userId,
medicationId,
deletedAt: null,
skipped: false,
takenAt: { not: null, lte: around },
},
orderBy: { takenAt: "asc" },
select: { takenAt: true },
});
return rows
.map((r) => r.takenAt)
.filter((d): d is Date => d !== null);
}
@@ -18,6 +18,7 @@
*/
import {
type CanonicalSchedule,
type DoseWindowEntry,
type RecurrenceContext,
type ScheduleType,
occurrencesBetween,
@@ -48,6 +49,13 @@ export interface WorkerScheduleRow {
scheduleType?: ScheduleType | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
/**
* v1.15.18 — per-dose on-time windows. The Prisma column is `Json?`, so a
* selected row surfaces it as `Prisma.JsonValue` (or `null`). The adapter
* normalises it to a `DoseWindowEntry[]` (dropping malformed entries) so the
* band minter never has to defend against an arbitrary JSON shape.
*/
doseWindows?: unknown;
}
/** Minimal `Medication` projection used by the worker. */
@@ -78,9 +86,47 @@ export function buildCanonicalSchedule(
scheduleType: schedule.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: schedule.cyclicOnWeeks ?? null,
cyclicOffWeeks: schedule.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(schedule.doseWindows),
};
}
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/**
* v1.15.18 — coerce the persisted `dose_windows` JSON into a clean
* `DoseWindowEntry[]`. Drops anything that isn't an `{ timeOfDay, start, end }`
* triple of well-formed HH:mm strings with `start <= end` — the column is
* Zod-validated on write, but a hand-edited or legacy row must never crash the
* read/write band paths. Returns `null` (the default-derivation signal) when
* nothing usable survives.
*/
export function normaliseDoseWindows(raw: unknown): DoseWindowEntry[] | null {
if (!Array.isArray(raw)) return null;
const out: DoseWindowEntry[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) continue;
const { timeOfDay, start, end } = item as Record<string, unknown>;
if (
typeof timeOfDay !== "string" ||
typeof start !== "string" ||
typeof end !== "string" ||
!HHMM_RE.test(timeOfDay) ||
!HHMM_RE.test(start) ||
!HHMM_RE.test(end) ||
hhmmToMinutes(start) > hhmmToMinutes(end)
) {
continue;
}
out.push({ timeOfDay, start, end });
}
return out.length > 0 ? out : null;
}
function hhmmToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":");
return Number(h) * 60 + Number(m);
}
/** Build the canonical engine context from worker-loop state. */
export function buildRecurrenceContext(input: {
medication: WorkerMedicationRow;
+23
View File
@@ -265,6 +265,29 @@ export const queryKeys = {
["medications", medicationId, "cadence"] as const,
medicationGlp1Details: (medicationId: string) =>
["medications", medicationId, "glp1-details"] as const,
/**
* v1.15.18 — per-medication pen/vial inventory list, consumed by the
* detail page's Bestand (supply) tab. Rides under the `["medications"]`
* prefix so an intake mutation that decrements a pen invalidates it.
*/
medicationInventory: (medicationId: string) =>
["medications", medicationId, "inventory"] as const,
/**
* v1.15.18 — per-medication dose-history ledger
* (`GET /api/medications/[id]/dose-history?from=&to=`), consumed by the
* detail page's Verlauf tab. Keyed by the `(from, to)` window so paging the
* range caches each span independently; rides under the `["medications"]`
* prefix so an intake mutation (take / skip / edit / delete) reaches it
* through `medicationDependentKeys`. The Verlauf tab also mutates this slot
* optimistically on Genommen / Übersprungen so the row status + the
* Übersicht headline % flip in the same paint, then reconciles on the
* authoritative refetch.
*/
medicationDoseHistory: (
medicationId: string,
from: string,
to: string,
) => ["medications", medicationId, "dose-history", from, to] as const,
medicationIntakeDrugLevelChart: (medicationId: string) =>
["medications", medicationId, "intake", "drug-level-chart"] as const,
/**
@@ -14,6 +14,25 @@
* coverage probe falls back to live + fires a boot backfill when a
* user has intake events but zero rollup rows.
*
* v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count,
* NOT the band-attributed compliance %. It aggregates, across ALL of a
* user's medications, "of the intake rows whose `scheduled_for` lands on
* this day, how many carry a non-null `taken_at`" — a coarse daily
* cross-medication coverage strip for the dashboard mini-tiles. The
* unified band-membership attribution (the `tallyComplianceFromLedger`
* keystone) is per-medication and per-cadence: it depends on the slot
* bands of each schedule and the user timezone, which cannot be expressed
* in a single cross-medication SQL `SUM(CASE …)`. Re-attributing every
* intake against its cadence's bands before the tally would mean running
* the band minter per `(medication, day)` inside the rollup, which defeats
* the rollup's whole purpose (bounded SQL, no per-row Node walk). The
* detail page + the medication card already read the band-attributed % via
* `calculateCompliance` / `complianceChips`; this tier deliberately remains
* the cheaper coverage signal and is documented as such so the two are not
* confused. The `scheduled` / `taken` column names reflect this: a day's
* `taken` is "intake rows logged taken", not "doses inside their on-time
* band".
*
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4.
*/
import { prisma } from "@/lib/db";
@@ -0,0 +1,125 @@
/**
* v1.15.18 — per-dose configurable on-time window (`doseWindows`) validation.
*
* The schedule schema accepts an optional `[{ timeOfDay, start, end }]` array.
* Each entry must be well-formed HH:mm with `start <= end`, name a real dose
* time of the schedule, and no `timeOfDay` may repeat. Absent leaves the
* default ±1h behaviour unchanged.
*/
import { describe, it, expect } from "vitest";
import { doseWindowEntrySchema, scheduleSchema } from "../medication";
function baseSchedule(over: Record<string, unknown> = {}) {
return {
windowStart: "07:00",
windowEnd: "09:00",
timesOfDay: ["07:00", "19:00"],
rrule: "FREQ=DAILY",
...over,
};
}
describe("doseWindowEntrySchema", () => {
it("accepts a well-formed entry", () => {
expect(
doseWindowEntrySchema.safeParse({
timeOfDay: "07:00",
start: "07:00",
end: "09:00",
}).success,
).toBe(true);
});
it("rejects start > end", () => {
expect(
doseWindowEntrySchema.safeParse({
timeOfDay: "07:00",
start: "12:00",
end: "07:00",
}).success,
).toBe(false);
});
it("rejects a malformed HH:mm", () => {
expect(
doseWindowEntrySchema.safeParse({
timeOfDay: "7:00",
start: "07:00",
end: "09:00",
}).success,
).toBe(false);
expect(
doseWindowEntrySchema.safeParse({
timeOfDay: "07:00",
start: "07:00",
end: "25:00",
}).success,
).toBe(false);
});
it("allows a zero-width window (start === end)", () => {
expect(
doseWindowEntrySchema.safeParse({
timeOfDay: "07:00",
start: "08:00",
end: "08:00",
}).success,
).toBe(true);
});
});
describe("scheduleSchema doseWindows", () => {
it("is optional — a schedule without it validates", () => {
expect(scheduleSchema.safeParse(baseSchedule()).success).toBe(true);
});
it("accepts a window for a real dose time", () => {
const r = scheduleSchema.safeParse(
baseSchedule({
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
}),
);
expect(r.success).toBe(true);
});
it("rejects a window whose timeOfDay is not a scheduled dose time", () => {
const r = scheduleSchema.safeParse(
baseSchedule({
doseWindows: [{ timeOfDay: "13:00", start: "13:00", end: "14:00" }],
}),
);
expect(r.success).toBe(false);
});
it("rejects a duplicate timeOfDay", () => {
const r = scheduleSchema.safeParse(
baseSchedule({
doseWindows: [
{ timeOfDay: "07:00", start: "07:00", end: "08:00" },
{ timeOfDay: "07:00", start: "08:00", end: "09:00" },
],
}),
);
expect(r.success).toBe(false);
});
it("rejects a per-entry start > end", () => {
const r = scheduleSchema.safeParse(
baseSchedule({
doseWindows: [{ timeOfDay: "07:00", start: "12:00", end: "07:00" }],
}),
);
expect(r.success).toBe(false);
});
it("matches a legacy single-time schedule against windowStart", () => {
const r = scheduleSchema.safeParse({
windowStart: "08:00",
windowEnd: "10:00",
// no timesOfDay → effective dose time is windowStart
doseWindows: [{ timeOfDay: "08:00", start: "08:00", end: "11:00" }],
});
expect(r.success).toBe(true);
});
});
+131 -12
View File
@@ -126,6 +126,43 @@ export type MedicationDeliveryForm =
const RRULE_PROPS =
/^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/;
/**
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
* the window applies to; `start`/`end` are the HH:mm on-time bounds in the
* user's wall clock. `start <= end` within the day (an overnight window is not
* a configurable on-time band — the late tail owns the cross-midnight tail).
*/
export const doseWindowEntrySchema = z
.object({
timeOfDay: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe("The dose time this window applies to (matches a `timesOfDay` entry)."),
start: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe("On-time band lower bound (HH:mm, user local)."),
end: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe("On-time band upper bound (HH:mm, user local). Must be >= `start`."),
})
.refine((w) => hhmmToMinutes(w.start) <= hhmmToMinutes(w.end), {
message: "Window start must be on or before end (same day)",
path: ["end"],
})
.meta({
id: "DoseWindowEntry",
description:
"One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.",
});
/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */
function hhmmToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":");
return Number(h) * 60 + Number(m);
}
export const scheduleSchema = z
.object({
windowStart: z
@@ -251,6 +288,21 @@ export const scheduleSchema = z
.describe(
"Cyclic \"off\" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.",
),
/**
* v1.15.18 — per-dose configurable on-time intake window (Marc's
* "07:00–09:00" lever). One entry per dose time the user wants an explicit
* range for; a `timeOfDay` with no entry keeps the symmetric ±1h default.
* Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the
* legacy `windowStart`), and `start <= end` within the day. Absent → every
* slot uses the default derivation (unchanged behaviour).
*/
doseWindows: z
.array(doseWindowEntrySchema)
.max(8)
.optional()
.describe(
"Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived.",
),
})
.refine(
(s) => !(s.rrule && s.rollingIntervalDays),
@@ -290,6 +342,38 @@ export const scheduleSchema = z
path: ["timesOfDay"],
},
)
.refine(
(s) => {
// Every per-dose window must name a real dose time. The effective dose
// times are `timesOfDay` when set, else the single legacy `windowStart`
// (mirrors the engine's `effectiveTimesOfDay`).
if (!s.doseWindows || s.doseWindows.length === 0) return true;
const times = new Set(
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart],
);
return s.doseWindows.every((w) => times.has(w.timeOfDay));
},
{
message: "Each doseWindows.timeOfDay must match one of the schedule's timesOfDay",
path: ["doseWindows"],
},
)
.refine(
(s) => {
// A dose time may carry at most one explicit window.
if (!s.doseWindows || s.doseWindows.length === 0) return true;
const seen = new Set<string>();
for (const w of s.doseWindows) {
if (seen.has(w.timeOfDay)) return false;
seen.add(w.timeOfDay);
}
return true;
},
{
message: "doseWindows must not repeat a timeOfDay",
path: ["doseWindows"],
},
)
.meta({
id: "MedicationScheduleInput",
description:
@@ -550,6 +634,21 @@ export const intakeSchema = z
.describe(
"Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site.",
),
/**
* v1.15.18 — late-take "attribute anyway" pin. An off-window take that
* band attribution would otherwise orphan to an ad-hoc row can be pinned
* onto a chosen scheduled slot via the UI's "diesem Slot zuordnen?" nudge.
* The instant MUST be a real slot of this medication on its day (the
* server validates it against the band anchors); an arbitrary instant is
* rejected with 422. Absent → default band attribution.
*/
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional()
.describe(
"Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution.",
),
})
.meta({
id: "MedicationIntakeRequest",
@@ -594,18 +693,38 @@ export const listIntakeEventsSchema = z.object({
.default("all"),
});
export const updateIntakeEventSchema = z.object({
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
skipped: z.boolean().optional(),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
});
export const updateIntakeEventSchema = z
.object({
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
skipped: z.boolean().optional(),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
/**
* v1.15.18 — late-take "attribute anyway" pin on the edit path. When the
* edited `takenAt` lands outside every window the UI can offer to pin the
* take onto a chosen slot; the server validates the instant is a real
* scheduled slot (422 otherwise). Absent → the edit re-runs band
* attribution on the new `takenAt`.
*/
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional()
.describe(
"Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Absent re-runs the default window-band attribution on the edited `takenAt`.",
),
})
.meta({
id: "UpdateMedicationIntakeEventRequest",
description:
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly.",
});
/**
* v1.5.5 — bulk-delete request body. The detail-page intake-history
@@ -163,7 +163,12 @@ describe("GET /api/analytics — Health Score", () => {
},
});
for (let i = 0; i < 30; i++) {
// Anchor the take to a fixed wall-clock instant that lands inside the
// 08:00–10:00 Berlin window (07:30 UTC ≈ 09:30 Berlin) so the slot
// matcher reads "on-time" deterministically regardless of host TZ —
// an unaligned `now - i*DAY` shifts its Berlin hour with the runner TZ.
const scheduledFor = new Date(now - i * DAY);
scheduledFor.setUTCHours(7, 30, 0, 0);
await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
@@ -283,7 +288,12 @@ describe("GET /api/analytics — Health Score", () => {
},
});
for (let i = 0; i < 30; i++) {
// Anchor the take to a fixed wall-clock instant that lands inside the
// 08:00–10:00 Berlin window (07:30 UTC ≈ 09:30 Berlin) so the slot
// matcher reads "on-time" deterministically regardless of host TZ —
// an unaligned `now - i*DAY` shifts its Berlin hour with the runner TZ.
const scheduledFor = new Date(now - i * DAY);
scheduledFor.setUTCHours(7, 30, 0, 0);
await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
@@ -100,7 +100,11 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
// One night with the four iOS-16+ stages tagged. The analytics
// route should surface a single Measurement-summary datapoint
// (~480 minutes total) plus a per-stage breakdown.
const baseDate = new Date("2026-05-09T01:00:00.000Z");
// Anchor the night comfortably inside the route's trailing 30-day
// window (relative to `now`) so the test never drifts onto the
// window edge as the calendar advances or the host TZ shifts.
const baseDate = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
baseDate.setUTCHours(1, 0, 0, 0);
const stages: Array<{
stage: "IN_BED" | "CORE" | "DEEP" | "REM";
min: number;
@@ -212,15 +212,20 @@ describe("weekly injectable compliance (Mounjaro 0% regression)", () => {
rrule: "FREQ=WEEKLY;BYDAY=MO",
windowStart: "07:30",
});
// Log only 12 of the trailing 13 Mondays (skip the most recent).
// Take the most-recent Monday on time, then skip ONE older Monday
// (`lastMonday − 7d`, which sits 9 days before the pinned now — past
// the weekly 4-day overdue tail, so it counts as definitively missed
// rather than still-takeable) and take the 11 Mondays before it. The
// gap is a real, settled miss, not a head-of-window overdue slot.
await logWeeklyIntakes(id, lastMonday, 1);
await logWeeklyIntakes(
id,
new Date(lastMonday.getTime() - 7 * DAY_MS),
12,
new Date(lastMonday.getTime() - 14 * DAY_MS),
11,
);
const { compliance90 } = await readCompliance(id);
// 12 of 13 expected → ~92%, definitively non-zero and below 100.
// 12 taken of 13 expected → ~92%, definitively non-zero and below 100.
expect(compliance90.rate).toBeGreaterThan(80);
expect(compliance90.rate).toBeLessThan(100);
expect(compliance90.missed).toBeGreaterThanOrEqual(1);