mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge pull request #304 from MBombeck/release/v1.15.18
Release v1.15.18
This commit is contained in:
90 files changed
+9659
-1517
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user