diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3480868..1cec7c2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 926ccfe89..fe9e190fc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index c09d62ccd..8ab6d3dbb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index a8d9cf9a4..58d054157 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index d11008a7e..a07cce72b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index ab8aaf052..75f0bb202 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 1df6a9855..8d6623f95 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index bd5133174..c3266cdd8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 54801ba95..7ccdfc4a0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0137_v11518_dose_windows/migration.sql b/prisma/migrations/0137_v11518_dose_windows/migration.sql new file mode 100644 index 000000000..17c551b20 --- /dev/null +++ b/prisma/migrations/0137_v11518_dose_windows/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c97ed5da..4ced5f92e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index ae9e21ed9..4541af588 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -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 | 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 () => { diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 4a1cc2e85..7f75efd1a 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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 = {}; - 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(); + 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({ diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts new file mode 100644 index 000000000..967a62714 --- /dev/null +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts new file mode 100644 index 000000000..9045525ea --- /dev/null +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -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, + }); + }, +); diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 971cebb19..8337a3b27 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 7468eaeee..5a8a76f19 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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, }); } diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index fc87a09b3..984f5fb73 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -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" }; diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index b06e008f3..1c3fef93e 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 938508447..87bdf8c83 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -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 }, }, }); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index dc4d03b66..1373fe569 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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) { diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 8c33a61fb..2817baf87 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 }), }; }), }, diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 0bd2d72fc..b9f35fd7e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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 }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 4a470f920..8e4f77835 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 }), }; }), }, diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 544370864..cf35a6376 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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 ). 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(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": , }; - // 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(); - 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 && ( -
- -
- )} - - {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. */ - { - 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. */ } title={t("insights.editMode.emptyTitle")} description={t("insights.editMode.emptyDescription")} action={ - } /> diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index a24e3df0a..948509a8d 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -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 - * ``); 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 ( -
- -
- ); - } - - const isGlp1 = medication?.treatmentClass === "GLP1"; + router.replace(`/medications/${id}?tab=verlauf`); + }, [id, router]); return ( -
- {/* Back to the medication detail page — history is a drill-down - OF the medication, not a sibling of the list. */} - - - {medication && ( -
-
-

- {medication.name} -

-

{medication.dose}

-

- {t("medications.detail.history.subtitle")} -

-
- {/* De-emphasised import — ghost / muted, not a prominent CTA. */} - -
- )} - - {medication && ( - - )} - - {/* 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 && ( -
- - -
- )} - - {importOpen && ( - setImportOpen(false)} - /> - )} +
+
); } diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index c5935ff89..330c672bd 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -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 + * ``, 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( - () => (medication ? snapshotToWizardPayload(medication) : null), - [medication], - ); - if (authLoading || isLoading) { return (

- {t("medications.loadFailed")} + {t("medications.detail.shell.loadFailed")}

); } - 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 ( -
- - - {/* 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. */} - - - {/* Intake history table — the primary surface. */} - - - {/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications - (genuine history context). */} - {isGlp1 && } - - {/* 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 && ( -
- - -
- )} -
- ); + return ; } diff --git a/src/app/medications/__tests__/detail-page-history-surface.test.ts b/src/app/medications/__tests__/detail-page-history-surface.test.ts index e2a127e34..ce95075fd 100644 --- a/src/app/medications/__tests__/detail-page-history-surface.test.ts +++ b/src/app/medications/__tests__/detail-page-history-surface.test.ts @@ -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 + * `` (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"); }); }); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 23c4c756a..4a754d0b1 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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(null); - // v1.7.1 — the medications-card History + Advanced actions. History - // navigates to the full intake-history view; Advanced opens one shared - // `` mounted below, keyed to the selected - // medication (reused, not duplicated per card). - const [advancedMed, setAdvancedMed] = useState(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. */} @@ -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 && ( - { - 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} - /> - )}
); } - -/** - * 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, - })), - }; -} - diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 38e5fadae..ad7eb5f22 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -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: () => , api: ApiSection, diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 41a70be79..9aedff7a4 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -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("", () => { ).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)[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(); + expect(html).toContain('data-slot="sleep-stage-stacked-bar"'); + }); + }); + /** * v1.4.25 W3f — per-night surface + window toggle. */ diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 942b33a13..145b5e5d6 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -141,6 +141,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { 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(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 (