From 6fef2081d0674c53a29144ca466285a4ca16c561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 23:17:20 +0200 Subject: [PATCH] fix(medications): the audit findings on the new supply and dose surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The escalated card row keeps its time anchor: the red badge carries the matched window or the overdue-slot value beside it, and the inline row's range stays visible on mobile, where it was the only clock on the card. - Take-all-due collects what the cards offer: a dose escalated past its catch-up band joins the set with exactly the slot the card's own button would record; as-needed medications never join. - The table's stock warning follows the user's runway threshold instead of a leftover four-dose constant; threshold off means no amber, empty stays red. - The card itself shows the remaining runway in the warning tone once it crosses the threshold — only then, the card stays calm above. - The low-stock push deep-links straight into the supply tab and its APNs payload keeps url, runwayDays and thresholdDays; supply tab and threshold setting link to each other; the reminder-thresholds and notification-prefs endpoints enter the OpenAPI contract. --- docs/api/openapi.yaml | 275 ++++++++++++++++++ messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- src/app/medications/page.tsx | 29 +- .../__tests__/medication-table.test.tsx | 62 ++++ .../__tests__/take-all-due.test.tsx | 102 +++++++ .../card-parts/medication-card-body.tsx | 55 +++- .../card-parts/medication-status-pill.tsx | 12 +- .../medications/glp1-medication-card.tsx | 43 ++- .../medications/medication-card.tsx | 45 ++- .../medications/medication-table.tsx | 44 ++- .../sections/inventory-section.tsx | 37 +++ src/components/medications/take-all-due.ts | 43 ++- src/components/settings/low-stock-card.tsx | 17 +- .../__tests__/medication-low-stock.test.ts | 3 +- src/lib/jobs/medication-low-stock.ts | 6 +- src/lib/notifications/senders/apns.ts | 6 + src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/profile.ts | 123 ++++++++ src/lib/openapi/routes/settings.ts | 70 +++++ 24 files changed, 970 insertions(+), 40 deletions(-) create mode 100644 src/lib/openapi/routes/settings.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c788bbfb6..1b3ff886d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3564,6 +3564,49 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/notification-prefs: + get: + tags: + - Notifications + summary: Read per-user notification preferences + description: Returns the fully-resolved preferences with the documented defaults filled in. A null persisted row + resolves to the defaults (server-managed reminders, low-stock threshold 7 days, mood reminder 22:00, Coach + nudges on). + responses: + "200": + description: Resolved preferences. + content: + application/json: + schema: + $ref: "#/components/schemas/GetNotificationPrefsResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Notifications + summary: Update per-user notification preferences + description: 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves + the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic + update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side + MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert + — null switches it off. Rate-limit 60/min per user.' + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationPrefsPatchRequest" + responses: + "200": + description: Resolved next-state preferences echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchNotificationPrefsResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -4805,6 +4848,24 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/settings/reminder-thresholds: + get: + tags: + - Notifications + summary: Read the medication reminder thresholds + description: Returns the operator-level late/missed minute thresholds that tier an open dose's status, plus the calling + user's low-stock runway threshold (days; null = alert off). One endpoint so every threshold consumer reads one + shape. Auth via cookie or Bearer. + responses: + "200": + description: Resolved thresholds. + content: + application/json: + schema: + $ref: "#/components/schemas/GetReminderThresholdsResponse" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -5753,6 +5814,57 @@ components: - type: "null" description: Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or empty string) clears it. `userId` is never accepted — it is narrowed from the session/token. + NotificationPrefsPatchRequest: + type: object + properties: + medication: + type: object + properties: + clientManaged: + type: boolean + deliveryDefault: + type: string + enum: + - server + - client + lowStockRunwayDays: + anyOf: + - type: integer + minimum: 1 + maximum: 60 + - type: "null" + mood: + type: object + properties: + reminderHour: + type: integer + minimum: 0 + maximum: 23 + cycle: + type: object + properties: + clientManaged: + type: boolean + coach: + type: object + properties: + nudgesEnabled: + type: boolean + nudgeMedication: + type: boolean + nudgeVitals: + type: boolean + nudgeRoutine: + type: boolean + nudgeFrequency: + type: string + enum: + - weekly + - biweekly + description: Partial per-user notification preferences. Every category and field is optional; the server deep-merges the + supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. + `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — + `null` switches the alert off. InsightsLayoutBody: type: object properties: @@ -11804,6 +11916,124 @@ components: additionalProperties: false description: Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value. + GetNotificationPrefsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationPrefs" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NotificationPrefs: + type: object + properties: + medication: + type: object + properties: + clientManaged: + type: boolean + description: True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is + suppressed. + deliveryDefault: + type: string + enum: + - server + - client + description: 'Roaming user-level delivery default. "client" implies clientManaged: true.' + lowStockRunwayDays: + anyOf: + - type: integer + minimum: 1 + maximum: 60 + - type: "null" + description: Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7. + required: + - clientManaged + - deliveryDefault + - lowStockRunwayDays + additionalProperties: false + mood: + type: object + properties: + reminderHour: + type: integer + minimum: 0 + maximum: 23 + description: Local-time hour for the daily mood reminder. Default 22. + required: + - reminderHour + additionalProperties: false + cycle: + type: object + properties: + clientManaged: + type: boolean + description: True when the iOS app owns local cycle reminders; the server-side cycle APNs cron is suppressed. + required: + - clientManaged + additionalProperties: false + coach: + type: object + properties: + nudgesEnabled: + type: boolean + description: Master switch for the proactive Coach nudge cron. + nudgeMedication: + type: boolean + description: Medication-group nudge triggers (compliance). + nudgeVitals: + type: boolean + description: Vitals-group nudge triggers (bp / score / weight / sleep). + nudgeRoutine: + type: boolean + description: Routine-group nudge triggers (measurement gap / self-context). + nudgeFrequency: + type: string + enum: + - weekly + - biweekly + description: "Nudge frequency cap: one per 7 or per 14 days." + required: + - nudgesEnabled + - nudgeMedication + - nudgeVitals + - nudgeRoutine + - nudgeFrequency + additionalProperties: false + required: + - medication + - mood + - cycle + - coach + additionalProperties: false + description: Fully-resolved per-user notification preferences. Every key is present — a null / drifted persisted row + resolves to the documented defaults. + PatchNotificationPrefsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationPrefs" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InsightsLayout: type: object properties: @@ -14050,6 +14280,51 @@ components: - data - error additionalProperties: false + GetReminderThresholdsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ReminderThresholdsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ReminderThresholdsResponse: + type: object + properties: + lateMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Minutes after the dose anchor at which an open dose tiers 'late' (operator-level singleton; default 120). + missedMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Minutes after the dose anchor at which an open dose tiers 'missed' (operator-level singleton; default 240). + lowStockRunwayDays: + anyOf: + - type: integer + minimum: 1 + maximum: 60 + - type: "null" + description: Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7. + Written via PATCH /api/auth/me/notification-prefs. + required: + - lateMinutes + - missedMinutes + - lowStockRunwayDays + additionalProperties: false + description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's + low-stock runway threshold (v1.16.11)." MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index 32bb23694..078258bfa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1669,6 +1669,8 @@ "emptyHelper": "Erfasse eine Packung oder einen Behälter, um den verbleibenden Vorrat und die Reichweite zu verfolgen.", "emptyCta": "Packung erfassen", "addButton": "Erfassen", + "lowStockLinkOn": "Benachrichtigung bei niedrigem Bestand: unter {days} Tagen", + "lowStockLinkOff": "Benachrichtigung bei niedrigem Bestand: aus", "addTitle": "Packung oder Behälter erfassen", "addDescription": "Lege einen neuen Vorrat mit seiner Einheitenanzahl an; das Ablaufdatum ist optional.", "addQuantityLabel": "Menge (Einheiten)", @@ -1819,6 +1821,7 @@ "tableStockDoseOne": "1 Dosis", "tableStockDoses": "{count} Dosen", "tableStockRunway": "≈ {days} Tage übrig", + "cardLowStockRunway": "Vorrat: ≈ {days} Tage", "takeActionFor": "Einnehmen – {name}", "skipActionFor": "Überspringen – {name}", "reorderTitle": "Reihenfolge anpassen", @@ -5398,7 +5401,8 @@ "savedToast": "Vorratsschwelle gespeichert.", "enabledToast": "Vorratswarnungen aktiviert.", "disabledToast": "Vorratswarnungen deaktiviert.", - "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", + "medicationsLink": "Zu deinen Medikamenten" }, "eventMedicationReminder": "Medikamenten-Erinnerung", "eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.", diff --git a/messages/en.json b/messages/en.json index 0b0b6650e..dff86db90 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1669,6 +1669,8 @@ "emptyHelper": "Register a pack or container to track remaining supply and runway.", "emptyCta": "Register a pack", "addButton": "Register", + "lowStockLinkOn": "Low-stock notification: below {days} days", + "lowStockLinkOff": "Low-stock notification: off", "addTitle": "Register a pack or container", "addDescription": "Record a new supply with its unit count; the expiry date is optional.", "addQuantityLabel": "Quantity (units)", @@ -1819,6 +1821,7 @@ "tableStockDoseOne": "1 dose", "tableStockDoses": "{count} doses", "tableStockRunway": "≈ {days} days left", + "cardLowStockRunway": "Supply: ≈ {days} days", "takeActionFor": "Take – {name}", "skipActionFor": "Skip – {name}", "reorderTitle": "Adjust order", @@ -5398,7 +5401,8 @@ "savedToast": "Low-stock threshold saved.", "enabledToast": "Low-stock alerts enabled.", "disabledToast": "Low-stock alerts disabled.", - "saveError": "Could not save. Please try again." + "saveError": "Could not save. Please try again.", + "medicationsLink": "Go to your medications" }, "eventMedicationReminder": "Medication reminder", "eventMedicationReminderDesc": "Sent at each scheduled dose time.", diff --git a/messages/es.json b/messages/es.json index 2c7d20c6c..67341a39b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1669,6 +1669,8 @@ "emptyHelper": "Registra un envase o recipiente para hacer seguimiento de las existencias restantes y la autonomía.", "emptyCta": "Registrar un envase", "addButton": "Registrar", + "lowStockLinkOn": "Aviso de reserva baja: por debajo de {days} días", + "lowStockLinkOff": "Aviso de reserva baja: desactivado", "addTitle": "Registrar un envase o recipiente", "addDescription": "Registra una nueva existencia con su número de unidades; la fecha de caducidad es opcional.", "addQuantityLabel": "Cantidad (unidades)", @@ -1819,6 +1821,7 @@ "tableStockDoseOne": "1 dosis", "tableStockDoses": "{count} dosis", "tableStockRunway": "≈ {days} días restantes", + "cardLowStockRunway": "Reserva: ≈ {days} días", "takeActionFor": "Tomar – {name}", "skipActionFor": "Omitir – {name}", "reorderTitle": "Ajustar orden", @@ -5398,7 +5401,8 @@ "savedToast": "Umbral de existencias guardado.", "enabledToast": "Avisos de existencias bajas activados.", "disabledToast": "Avisos de existencias bajas desactivados.", - "saveError": "No se pudo guardar. Inténtalo de nuevo." + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "medicationsLink": "Ir a tus medicamentos" }, "eventMedicationReminder": "Recordatorio de medicación", "eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.", diff --git a/messages/fr.json b/messages/fr.json index ca8901e92..f92ef14a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1669,6 +1669,8 @@ "emptyHelper": "Enregistrez une boîte ou un contenant pour suivre le stock restant et l'autonomie.", "emptyCta": "Enregistrer une boîte", "addButton": "Enregistrer", + "lowStockLinkOn": "Alerte de stock bas : en dessous de {days} jours", + "lowStockLinkOff": "Alerte de stock bas : désactivée", "addTitle": "Enregistrer une boîte ou un contenant", "addDescription": "Enregistrez un nouveau stock avec son nombre d'unités ; la date de péremption est facultative.", "addQuantityLabel": "Quantité (unités)", @@ -1819,6 +1821,7 @@ "tableStockDoseOne": "1 dose", "tableStockDoses": "{count} doses", "tableStockRunway": "≈ {days} jours restants", + "cardLowStockRunway": "Réserve : ≈ {days} jours", "takeActionFor": "Prendre – {name}", "skipActionFor": "Passer – {name}", "reorderTitle": "Modifier l'ordre", @@ -5398,7 +5401,8 @@ "savedToast": "Seuil de stock enregistré.", "enabledToast": "Alertes de stock faible activées.", "disabledToast": "Alertes de stock faible désactivées.", - "saveError": "Échec de l’enregistrement. Réessayez." + "saveError": "Échec de l’enregistrement. Réessayez.", + "medicationsLink": "Voir tes médicaments" }, "eventMedicationReminder": "Rappel de médicament", "eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.", diff --git a/messages/it.json b/messages/it.json index d2eb8ab21..edd4bbb1f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1669,6 +1669,8 @@ "emptyHelper": "Registra una confezione o un contenitore per monitorare la scorta rimanente e l'autonomia.", "emptyCta": "Registra una confezione", "addButton": "Registra", + "lowStockLinkOn": "Avviso scorta bassa: sotto i {days} giorni", + "lowStockLinkOff": "Avviso scorta bassa: disattivato", "addTitle": "Registra una confezione o un contenitore", "addDescription": "Registra una nuova scorta con il numero di unità; la data di scadenza è facoltativa.", "addQuantityLabel": "Quantità (unità)", @@ -1819,6 +1821,7 @@ "tableStockDoseOne": "1 dose", "tableStockDoses": "{count} dosi", "tableStockRunway": "≈ {days} giorni rimanenti", + "cardLowStockRunway": "Scorta: ≈ {days} giorni", "takeActionFor": "Assumi – {name}", "skipActionFor": "Salta – {name}", "reorderTitle": "Modifica ordine", @@ -5398,7 +5401,8 @@ "savedToast": "Soglia delle scorte salvata.", "enabledToast": "Avvisi di scorte basse attivati.", "disabledToast": "Avvisi di scorte basse disattivati.", - "saveError": "Salvataggio non riuscito. Riprova." + "saveError": "Salvataggio non riuscito. Riprova.", + "medicationsLink": "Vai ai tuoi farmaci" }, "eventMedicationReminder": "Promemoria farmaco", "eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.", diff --git a/messages/pl.json b/messages/pl.json index 167b7c4ca..7511fb7ce 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1669,6 +1669,8 @@ "emptyHelper": "Zarejestruj opakowanie lub pojemnik, aby śledzić pozostały zapas i jego wystarczalność.", "emptyCta": "Zarejestruj opakowanie", "addButton": "Zarejestruj", + "lowStockLinkOn": "Powiadomienie o niskim zapasie: poniżej {days} dni", + "lowStockLinkOff": "Powiadomienie o niskim zapasie: wyłączone", "addTitle": "Zarejestruj opakowanie lub pojemnik", "addDescription": "Zapisz nowy zapas z liczbą jednostek; data ważności jest opcjonalna.", "addQuantityLabel": "Ilość (jednostki)", @@ -1819,6 +1821,7 @@ "tableStockDoseOne": "1 dawka", "tableStockDoses": "Dawki: {count}", "tableStockRunway": "≈ {days} dni zapasu", + "cardLowStockRunway": "Zapas: ≈ {days} dni", "takeActionFor": "Przyjmij – {name}", "skipActionFor": "Pomiń – {name}", "reorderTitle": "Zmień kolejność", @@ -5398,7 +5401,8 @@ "savedToast": "Próg zapasu zapisany.", "enabledToast": "Alerty niskiego zapasu włączone.", "disabledToast": "Alerty niskiego zapasu wyłączone.", - "saveError": "Zapis nieudany. Spróbuj ponownie." + "saveError": "Zapis nieudany. Spróbuj ponownie.", + "medicationsLink": "Przejdź do swoich leków" }, "eventMedicationReminder": "Przypomnienie o leku", "eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.", diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index ecf8c9f9b..7e18fd891 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -36,6 +36,7 @@ import { Wrench, } from "lucide-react"; import { apiGet } from "@/lib/api/api-fetch"; +import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary"; import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -204,9 +205,11 @@ export default function MedicationsPage() { queryKey: queryKeys.settingsReminderThresholds(), queryFn: async () => { try { - return await apiGet<{ lateMinutes: number; missedMinutes: number }>( - "/api/settings/reminder-thresholds", - ); + return await apiGet<{ + lateMinutes: number; + missedMinutes: number; + lowStockRunwayDays: number | null; + }>("/api/settings/reminder-thresholds"); } catch { return null; } @@ -215,6 +218,14 @@ export default function MedicationsPage() { staleTime: 5 * 60 * 1000, }); + // v1.16.11 — the SAME batched compliance rows the cards / table read + // (shared key + cache, no extra round trip). The due-set derivation + // needs each medication's compliance dose status so an escalated + // (overdue / missed past its band tail) medication — which still + // renders an active take button on its red card — joins the + // take-all-due set. + const { data: complianceRows } = useMedicationComplianceSummaryAll(); + function openCreate() { setDialogOpen(true); } @@ -289,11 +300,19 @@ export default function MedicationsPage() { // v1.16.11 (#316) — the currently-due set, derived from the list payload // the page already holds via the SAME pipeline a card's pill runs (band - // model + server display-due gate + taken-early downgrade). Page order is - // preserved so the confirm dialog lists medications as rendered. + // model + server display-due gate + taken-early downgrade), plus the + // batched compliance dose status so escalated (overdue / missed) + // medications past their band tail stay takeable from the batch. Page + // order is preserved so the confirm dialog lists medications as rendered. const dueMeds = deriveDueMedications(activeMeds, { tz: user?.timezone || "Europe/Berlin", thresholds: thresholds ?? undefined, + doseStatusById: new Map( + (complianceRows ?? []).map((row) => [ + row.medicationId, + row.complianceDisplay?.currentDose.status ?? "upcoming", + ]), + ), }); return ( diff --git a/src/components/medications/__tests__/medication-table.test.tsx b/src/components/medications/__tests__/medication-table.test.tsx index 44b42ec46..39e207b61 100644 --- a/src/components/medications/__tests__/medication-table.test.tsx +++ b/src/components/medications/__tests__/medication-table.test.tsx @@ -65,6 +65,20 @@ function seedCompliance( ]); } +// Seed the SAME reminder-thresholds key the cards read — the stock +// cell's amber tier rides the per-user low-stock runway threshold +// (v1.16.11); `null` = the alert (and the tier) is off. +function seedThresholds( + client: QueryClient, + lowStockRunwayDays: number | null, +) { + client.setQueryData(["settings", "reminder-thresholds"], { + lateMinutes: 120, + missedMinutes: 240, + lowStockRunwayDays, + }); +} + // A schedule whose window has already passed for today (01:00–02:00) so // the row shows the next-intake value rather than the take-now pill, // regardless of host clock. @@ -217,6 +231,54 @@ describe(" — structure + shared payloads", () => { expect(html).toContain("text-warning"); }); + it("warns on runway DAYS below the user's threshold, not a legacy dose count", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + seedCompliance(client, "m2", 90, 88); + // Per-user threshold: 3 runway days (the same value the low-stock + // notification fires on). + seedThresholds(client, 3); + const html = render( + , + client, + ); + + expect((html.match(/text-warning/g) ?? []).length).toBe(1); + expect(html.indexOf("text-warning")).toBeGreaterThan( + html.indexOf("Mounjaro"), + ); + }); + + it("threshold OFF (null) disables the amber tier; an exhausted stock stays destructive", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + seedCompliance(client, "m2", 90, 88); + seedThresholds(client, null); + const html = render( + , + client, + ); + + expect(html).not.toContain("text-warning"); + // Stock 0 is destructive red regardless of the threshold switch. + expect(html).toContain("text-destructive"); + }); + it("pins inactive rows after the active block, muted and without actions", () => { const client = makeClient(); seedCompliance(client, "m1", 90, 88); diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index 38d0a6a60..827b87084 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -280,6 +280,108 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () => }); }); +describe("deriveDueMedications — compliance-escalated medications (band tail closed)", () => { + // 14:00 UTC: the 08:00 dose band's catch-up tail closed at 13:00 + // (missedMinutes 240), so the window-derived status is null — but the + // card still renders the red escalation value with an ACTIVE take + // button when the batched compliance dose status says overdue/missed. + const now = todayAtUtc(14, 0); + + const morning = () => + makeMed({ + id: "morning", + schedules: [ + { + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + dose: null, + }, + ], + // The server still serves the open overdue slot as next-due. + nextDueAt: todayAtUtc(8, 0).toISOString(), + nextDueOverdue: true, + }); + + it("includes a missed dose past its catch-up tail, recording the card's own displayed slot", () => { + const due = deriveDueMedications([morning()], { + now, + tz: "UTC", + doseStatusById: new Map([["morning", "missed"]]), + }); + expect(due).toHaveLength(1); + expect(due[0].id).toBe("morning"); + // Band closed → no matched window for the dialog line. + expect(due[0].window).toBeNull(); + // EXACTLY what the card's take button records: the same + // `resolveDisplayedSlotInstant` call resolves (status null) to the + // server's next-due instant. + expect(due[0].scheduledFor?.toISOString()).toBe( + todayAtUtc(8, 0).toISOString(), + ); + }); + + it("matches the count of visible red/due cards: dedupes window-due + escalated, excludes calm doses", () => { + const inWindow = makeMed({ + id: "in-window", + schedules: [ + { + windowStart: "14:00", + windowEnd: "14:00", + daysOfWeek: null, + timesOfDay: ["14:00"], + dose: null, + }, + ], + }); + const evening = makeMed({ + id: "evening", + schedules: [ + { + windowStart: "20:00", + windowEnd: "20:00", + daysOfWeek: null, + timesOfDay: ["20:00"], + dose: null, + }, + ], + }); + const due = deriveDueMedications([inWindow, morning(), evening], { + now, + tz: "UTC", + doseStatusById: new Map([ + // The open-window med is ALSO compliance-escalated — it must + // appear exactly once (the two branches are exclusive). + ["in-window", "overdue"], + ["morning", "missed"], + ["evening", "upcoming"], + ]), + }); + expect(due.map((d) => d.id)).toEqual(["in-window", "morning"]); + }); + + it("never includes an as-needed medication, even with a (bogus) escalated compliance row", () => { + const prn = makeMed({ + id: "prn", + asNeeded: true, + schedules: [], + nextDueAt: null, + }); + const due = deriveDueMedications([prn], { + now, + tz: "UTC", + doseStatusById: new Map([["prn", "missed"]]), + }); + expect(due).toEqual([]); + }); + + it("does not escalate without a compliance verdict (window-only derivation stays the floor)", () => { + const due = deriveDueMedications([morning()], { now, tz: "UTC" }); + expect(due).toEqual([]); + }); +}); + describe("runTakeAllDue — per-medication loop with failure isolation", () => { const slotA = new Date(Date.now() - 60 * 60 * 1000); const slotB = new Date(Date.now() - 30 * 60 * 1000); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 3a05fb0d8..4092349cb 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -5,6 +5,7 @@ import { AlertTriangle } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; +import { formatTimeWindowRange } from "@/lib/time-window-format"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; import { @@ -120,6 +121,16 @@ export interface MedicationCardBodyProps { */ asNeeded?: boolean; + /** + * v1.16.11 — projected supply runway in whole days, set ONLY while it + * sits below the user's low-stock threshold (the variants gate it). + * Non-null renders one muted warning-toned "Vorrat: ≈ N Tage" line + * under the next/last slot; null keeps the card free of stock noise. + * Never set for as-needed medications (no consuming schedule, no + * runway). + */ + lowStockRunwayDays?: number | null; + /** "take" | "skip" while the matching request is in flight, else null. */ intakeLoading: string | null; /** Record the displayed dose (the card binds the slot instant). */ @@ -147,11 +158,12 @@ export function MedicationCardBody({ onRetryCompliance, currentCycle, asNeeded = false, + lowStockRunwayDays = null, intakeLoading, onRecordIntake, children, }: MedicationCardBodyProps) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); // Overdue escalation line: a dose past its on-time window. `missed` (at / // past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable @@ -176,11 +188,31 @@ export function MedicationCardBody({ // tier: the row falls through to the plain schedule value and the // "last intake" line directly below already names the recent dose — // the guard's job is that no take prompt renders, not a louder line. + // + // The escalation value keeps the due time: the red badge alone evicted + // the window / time the amber tier used to carry, leaving the user + // with urgency but no anchor. The matched window rides along when one + // is still open; otherwise the card's own next-intake value (the + // overdue slot time) fills the gap. const nextValueOverride = windowStatus?.takenEarlyDaysAgo != null ? null : overdueLabel ? ( - -