mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(medications): as-needed medications log without a schedule
A medication can declare itself as-needed: no schedules, never due, never reminded, excluded from every compliance rate — but intakes log ad-hoc, consume inventory exactly like scheduled doses, and the history stays. The wizard offers it as a cadence choice and round- trips it on edit; card, table and detail show a calm marker where the next dose normally sits, the take-all dialog never collects one, and the supply runway stays unknowable by construction. (#316)
This commit is contained in:
1 parent
550f314a54
commit
3943dcfb72
43 files changed
+1273
-70
No files matched your search
+40
-5
@@ -5160,20 +5160,26 @@ components:
|
||||
one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is
|
||||
logged (non-skipped).
|
||||
type: boolean
|
||||
asNeeded:
|
||||
description: "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules
|
||||
(supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance
|
||||
rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays
|
||||
active indefinitely. Mutually exclusive with `oneShot`."
|
||||
type: boolean
|
||||
schedules:
|
||||
minItems: 1
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleInput"
|
||||
required:
|
||||
- name
|
||||
- dose
|
||||
- schedules
|
||||
description: 'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule
|
||||
`rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule
|
||||
must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring
|
||||
schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and
|
||||
`timesOfDay` is dual-written from `windowStart` when the caller omits it.'
|
||||
`timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a
|
||||
PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication
|
||||
still requires at least one schedule entry.'
|
||||
MedicationScheduleInput:
|
||||
type: object
|
||||
properties:
|
||||
@@ -5444,6 +5450,12 @@ components:
|
||||
one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is
|
||||
logged (non-skipped).
|
||||
type: boolean
|
||||
asNeeded:
|
||||
description: "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules
|
||||
(supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance
|
||||
rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays
|
||||
active indefinitely. Mutually exclusive with `oneShot`."
|
||||
type: boolean
|
||||
schedules:
|
||||
type: array
|
||||
items:
|
||||
@@ -5456,10 +5468,12 @@ components:
|
||||
minimum: 0
|
||||
maximum: 1440
|
||||
- type: "null"
|
||||
description: Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules`
|
||||
description: "Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules`
|
||||
REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping
|
||||
`active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants
|
||||
on the `schedules` array match the create path.
|
||||
on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end
|
||||
schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty
|
||||
`schedules` array without `asNeeded: true` is likewise a 422."
|
||||
MedicationIntakeRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -9002,6 +9016,12 @@ components:
|
||||
type: boolean
|
||||
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
|
||||
`rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.
|
||||
asNeeded:
|
||||
type: boolean
|
||||
description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on
|
||||
any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and
|
||||
excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes
|
||||
per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -9068,6 +9088,7 @@ components:
|
||||
- startsOn
|
||||
- endsOn
|
||||
- oneShot
|
||||
- asNeeded
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- schedules
|
||||
@@ -9214,6 +9235,12 @@ components:
|
||||
type: boolean
|
||||
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
|
||||
`rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.
|
||||
asNeeded:
|
||||
type: boolean
|
||||
description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on
|
||||
any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and
|
||||
excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes
|
||||
per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -9248,6 +9275,7 @@ components:
|
||||
- startsOn
|
||||
- endsOn
|
||||
- oneShot
|
||||
- asNeeded
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- schedules
|
||||
@@ -14391,6 +14419,12 @@ components:
|
||||
type: boolean
|
||||
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
|
||||
`rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.
|
||||
asNeeded:
|
||||
type: boolean
|
||||
description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on
|
||||
any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and
|
||||
excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes
|
||||
per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -14423,6 +14457,7 @@ components:
|
||||
- startsOn
|
||||
- endsOn
|
||||
- oneShot
|
||||
- asNeeded
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- schedules
|
||||
|
||||
+10
-1
@@ -820,6 +820,7 @@
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"tomorrow": "Morgen",
|
||||
"asNeededMarker": "Bei Bedarf",
|
||||
"withoutNotification": "Ohne Benachrichtigung",
|
||||
"pausedSince": "Pausiert seit",
|
||||
"takeNow": "Jetzt einnehmen",
|
||||
@@ -1207,6 +1208,10 @@
|
||||
"oneShot": {
|
||||
"label": "Einmalig",
|
||||
"description": "Einzelgabe, zum Beispiel eine Impfung oder ein Antibiotikum."
|
||||
},
|
||||
"asNeeded": {
|
||||
"label": "Bei Bedarf",
|
||||
"description": "Kein fester Plan — du erfasst eine Einnahme, wenn du sie brauchst (z. B. Schmerzmittel)."
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
@@ -1220,6 +1225,7 @@
|
||||
"yearly": "Einmal im Jahr",
|
||||
"rolling": "Alle {n} Tage seit der letzten Einnahme",
|
||||
"oneShot": "Eine Einmaldosis",
|
||||
"asNeeded": "Bei Bedarf",
|
||||
"everyNWeeks": "Alle {n} Wochen",
|
||||
"everyNMonths": "Alle {n} Monate"
|
||||
},
|
||||
@@ -1405,7 +1411,8 @@
|
||||
},
|
||||
"cadence": {
|
||||
"oneShotOn": "Einmalige Dosis am {date}.",
|
||||
"oneShotPending": "Einmalige Dosis ausstehend."
|
||||
"oneShotPending": "Einmalige Dosis ausstehend.",
|
||||
"asNeeded": "Bei Bedarf — kein fester Plan."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Einnahmeverlauf",
|
||||
@@ -1634,6 +1641,7 @@
|
||||
"statusTitle": "Status",
|
||||
"nextDoseLabel": "Nächste Dosis",
|
||||
"nextDoseNone": "Keine anstehende Dosis.",
|
||||
"nextDoseAsNeeded": "Bei Bedarf — keine geplante Dosis.",
|
||||
"pausedHint": "Pausiert — keine Erinnerungen.",
|
||||
"reminderLabel": "Erinnerung",
|
||||
"reminderOn": "Aktiv",
|
||||
@@ -1641,6 +1649,7 @@
|
||||
"supplyLabel": "Bestand",
|
||||
"supplyRunway": "Reicht noch ca. {days} Tage",
|
||||
"jumpToZeitplan": "Zum Zeitplan",
|
||||
"jumpToVerlauf": "Zum Verlauf",
|
||||
"jumpToBestand": "Zum Bestand",
|
||||
"complianceTitle": "Therapietreue",
|
||||
"complianceEmpty": "Noch keine Daten zur Therapietreue."
|
||||
|
||||
+10
-1
@@ -820,6 +820,7 @@
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"tomorrow": "Tomorrow",
|
||||
"asNeededMarker": "As needed",
|
||||
"withoutNotification": "Without notification",
|
||||
"pausedSince": "Paused since",
|
||||
"takeNow": "Take now",
|
||||
@@ -1207,6 +1208,10 @@
|
||||
"oneShot": {
|
||||
"label": "Single dose",
|
||||
"description": "One administration, e.g. a vaccine or an antibiotic course."
|
||||
},
|
||||
"asNeeded": {
|
||||
"label": "As needed",
|
||||
"description": "No fixed plan — log an intake whenever you need one (e.g. pain relief)."
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
@@ -1220,6 +1225,7 @@
|
||||
"yearly": "Once a year",
|
||||
"rolling": "Every {n} days from your last intake",
|
||||
"oneShot": "A single dose",
|
||||
"asNeeded": "As needed",
|
||||
"everyNWeeks": "Every {n} weeks",
|
||||
"everyNMonths": "Every {n} months"
|
||||
},
|
||||
@@ -1405,7 +1411,8 @@
|
||||
},
|
||||
"cadence": {
|
||||
"oneShotOn": "One-time dose on {date}.",
|
||||
"oneShotPending": "One-time dose pending."
|
||||
"oneShotPending": "One-time dose pending.",
|
||||
"asNeeded": "As needed — no fixed plan."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
@@ -1634,6 +1641,7 @@
|
||||
"statusTitle": "Status",
|
||||
"nextDoseLabel": "Next dose",
|
||||
"nextDoseNone": "No upcoming dose.",
|
||||
"nextDoseAsNeeded": "As needed — no planned dose.",
|
||||
"pausedHint": "Paused — no reminders.",
|
||||
"reminderLabel": "Reminder",
|
||||
"reminderOn": "On",
|
||||
@@ -1641,6 +1649,7 @@
|
||||
"supplyLabel": "Supply",
|
||||
"supplyRunway": "Lasts about {days} more days",
|
||||
"jumpToZeitplan": "Open schedule",
|
||||
"jumpToVerlauf": "Open history",
|
||||
"jumpToBestand": "Open supply",
|
||||
"complianceTitle": "Adherence",
|
||||
"complianceEmpty": "No adherence data yet."
|
||||
|
||||
+10
-1
@@ -820,6 +820,7 @@
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"tomorrow": "Mañana",
|
||||
"asNeededMarker": "Según necesidad",
|
||||
"withoutNotification": "Sin notificación",
|
||||
"pausedSince": "En pausa desde",
|
||||
"takeNow": "Tomar ahora",
|
||||
@@ -1207,6 +1208,10 @@
|
||||
"oneShot": {
|
||||
"label": "Dosis única",
|
||||
"description": "Una sola administración, p. ej. una vacuna o un ciclo de antibiótico."
|
||||
},
|
||||
"asNeeded": {
|
||||
"label": "Según necesidad",
|
||||
"description": "Sin plan fijo: registra una toma cuando la necesites (p. ej. analgésicos)."
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
@@ -1220,6 +1225,7 @@
|
||||
"yearly": "Una vez al año",
|
||||
"rolling": "Cada {n} días desde tu última toma",
|
||||
"oneShot": "Una dosis única",
|
||||
"asNeeded": "Según necesidad",
|
||||
"everyNWeeks": "Cada {n} semanas",
|
||||
"everyNMonths": "Cada {n} meses"
|
||||
},
|
||||
@@ -1405,7 +1411,8 @@
|
||||
},
|
||||
"cadence": {
|
||||
"oneShotOn": "Dosis única el {date}.",
|
||||
"oneShotPending": "Dosis única pendiente."
|
||||
"oneShotPending": "Dosis única pendiente.",
|
||||
"asNeeded": "Según necesidad — sin plan fijo."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Historial de tomas",
|
||||
@@ -1634,6 +1641,7 @@
|
||||
"statusTitle": "Estado",
|
||||
"nextDoseLabel": "Próxima dosis",
|
||||
"nextDoseNone": "No hay dosis pendiente.",
|
||||
"nextDoseAsNeeded": "Según necesidad — sin dosis programada.",
|
||||
"pausedHint": "En pausa — sin recordatorios.",
|
||||
"reminderLabel": "Recordatorio",
|
||||
"reminderOn": "Activado",
|
||||
@@ -1641,6 +1649,7 @@
|
||||
"supplyLabel": "Existencias",
|
||||
"supplyRunway": "Alcanza para unos {days} días más",
|
||||
"jumpToZeitplan": "Ver horario",
|
||||
"jumpToVerlauf": "Ver historial",
|
||||
"jumpToBestand": "Ver existencias",
|
||||
"complianceTitle": "Adherencia",
|
||||
"complianceEmpty": "Aún no hay datos de adherencia."
|
||||
|
||||
+10
-1
@@ -820,6 +820,7 @@
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"tomorrow": "Demain",
|
||||
"asNeededMarker": "Au besoin",
|
||||
"withoutNotification": "Sans notification",
|
||||
"pausedSince": "En pause depuis",
|
||||
"takeNow": "Prendre maintenant",
|
||||
@@ -1207,6 +1208,10 @@
|
||||
"oneShot": {
|
||||
"label": "Dose unique",
|
||||
"description": "Une seule administration, par exemple un vaccin ou une cure d’antibiotique."
|
||||
},
|
||||
"asNeeded": {
|
||||
"label": "Au besoin",
|
||||
"description": "Pas de plan fixe — enregistrez une prise quand vous en avez besoin (p. ex. antidouleur)."
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
@@ -1220,6 +1225,7 @@
|
||||
"yearly": "Une fois par an",
|
||||
"rolling": "Tous les {n} jours depuis votre dernière prise",
|
||||
"oneShot": "Une dose unique",
|
||||
"asNeeded": "Au besoin",
|
||||
"everyNWeeks": "Toutes les {n} semaines",
|
||||
"everyNMonths": "Tous les {n} mois"
|
||||
},
|
||||
@@ -1405,7 +1411,8 @@
|
||||
},
|
||||
"cadence": {
|
||||
"oneShotOn": "Dose unique le {date}.",
|
||||
"oneShotPending": "Dose unique en attente."
|
||||
"oneShotPending": "Dose unique en attente.",
|
||||
"asNeeded": "Au besoin — pas de plan fixe."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Historique des prises",
|
||||
@@ -1634,6 +1641,7 @@
|
||||
"statusTitle": "Statut",
|
||||
"nextDoseLabel": "Prochaine dose",
|
||||
"nextDoseNone": "Aucune dose à venir.",
|
||||
"nextDoseAsNeeded": "Au besoin — aucune dose planifiée.",
|
||||
"pausedHint": "En pause — aucun rappel.",
|
||||
"reminderLabel": "Rappel",
|
||||
"reminderOn": "Activé",
|
||||
@@ -1641,6 +1649,7 @@
|
||||
"supplyLabel": "Stock",
|
||||
"supplyRunway": "Couvre encore environ {days} jours",
|
||||
"jumpToZeitplan": "Voir l'horaire",
|
||||
"jumpToVerlauf": "Voir l'historique",
|
||||
"jumpToBestand": "Voir le stock",
|
||||
"complianceTitle": "Observance",
|
||||
"complianceEmpty": "Aucune donnée d'observance pour l'instant."
|
||||
|
||||
+10
-1
@@ -820,6 +820,7 @@
|
||||
"today": "Oggi",
|
||||
"yesterday": "Ieri",
|
||||
"tomorrow": "Domani",
|
||||
"asNeededMarker": "Al bisogno",
|
||||
"withoutNotification": "Senza notifica",
|
||||
"pausedSince": "In pausa dal",
|
||||
"takeNow": "Assumi ora",
|
||||
@@ -1207,6 +1208,10 @@
|
||||
"oneShot": {
|
||||
"label": "Dose singola",
|
||||
"description": "Una singola somministrazione, ad es. un vaccino o un ciclo di antibiotico."
|
||||
},
|
||||
"asNeeded": {
|
||||
"label": "Al bisogno",
|
||||
"description": "Nessun piano fisso: registra un'assunzione quando ti serve (ad es. antidolorifici)."
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
@@ -1220,6 +1225,7 @@
|
||||
"yearly": "Una volta all’anno",
|
||||
"rolling": "Ogni {n} giorni dall’ultima assunzione",
|
||||
"oneShot": "Una dose singola",
|
||||
"asNeeded": "Al bisogno",
|
||||
"everyNWeeks": "Ogni {n} settimane",
|
||||
"everyNMonths": "Ogni {n} mesi"
|
||||
},
|
||||
@@ -1405,7 +1411,8 @@
|
||||
},
|
||||
"cadence": {
|
||||
"oneShotOn": "Dose singola il {date}.",
|
||||
"oneShotPending": "Dose singola in sospeso."
|
||||
"oneShotPending": "Dose singola in sospeso.",
|
||||
"asNeeded": "Al bisogno — nessun piano fisso."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Storico delle assunzioni",
|
||||
@@ -1634,6 +1641,7 @@
|
||||
"statusTitle": "Stato",
|
||||
"nextDoseLabel": "Prossima dose",
|
||||
"nextDoseNone": "Nessuna dose in programma.",
|
||||
"nextDoseAsNeeded": "Al bisogno — nessuna dose programmata.",
|
||||
"pausedHint": "In pausa — nessun promemoria.",
|
||||
"reminderLabel": "Promemoria",
|
||||
"reminderOn": "Attivo",
|
||||
@@ -1641,6 +1649,7 @@
|
||||
"supplyLabel": "Scorta",
|
||||
"supplyRunway": "Copre ancora circa {days} giorni",
|
||||
"jumpToZeitplan": "Apri programma",
|
||||
"jumpToVerlauf": "Apri cronologia",
|
||||
"jumpToBestand": "Apri scorta",
|
||||
"complianceTitle": "Aderenza",
|
||||
"complianceEmpty": "Ancora nessun dato sull'aderenza."
|
||||
|
||||
+10
-1
@@ -820,6 +820,7 @@
|
||||
"today": "Dzisiaj",
|
||||
"yesterday": "Wczoraj",
|
||||
"tomorrow": "Jutro",
|
||||
"asNeededMarker": "W razie potrzeby",
|
||||
"withoutNotification": "Bez powiadomienia",
|
||||
"pausedSince": "Wstrzymane od",
|
||||
"takeNow": "Przyjmij teraz",
|
||||
@@ -1207,6 +1208,10 @@
|
||||
"oneShot": {
|
||||
"label": "Jednorazowo",
|
||||
"description": "Jednorazowe podanie, np. szczepionka lub kuracja antybiotykowa."
|
||||
},
|
||||
"asNeeded": {
|
||||
"label": "W razie potrzeby",
|
||||
"description": "Bez stałego planu — zapisz przyjęcie, gdy go potrzebujesz (np. lek przeciwbólowy)."
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
@@ -1220,6 +1225,7 @@
|
||||
"yearly": "Raz w roku",
|
||||
"rolling": "Co {n} dni od ostatniego przyjęcia",
|
||||
"oneShot": "Pojedyncza dawka",
|
||||
"asNeeded": "W razie potrzeby",
|
||||
"everyNWeeks": "Co {n} tygodni",
|
||||
"everyNMonths": "Co {n} miesięcy"
|
||||
},
|
||||
@@ -1405,7 +1411,8 @@
|
||||
},
|
||||
"cadence": {
|
||||
"oneShotOn": "Dawka jednorazowa w dniu {date}.",
|
||||
"oneShotPending": "Dawka jednorazowa oczekuje."
|
||||
"oneShotPending": "Dawka jednorazowa oczekuje.",
|
||||
"asNeeded": "W razie potrzeby — bez stałego planu."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Historia przyjęć",
|
||||
@@ -1634,6 +1641,7 @@
|
||||
"statusTitle": "Status",
|
||||
"nextDoseLabel": "Następna dawka",
|
||||
"nextDoseNone": "Brak nadchodzącej dawki.",
|
||||
"nextDoseAsNeeded": "W razie potrzeby — brak zaplanowanej dawki.",
|
||||
"pausedHint": "Wstrzymano — brak przypomnień.",
|
||||
"reminderLabel": "Przypomnienie",
|
||||
"reminderOn": "Włączone",
|
||||
@@ -1641,6 +1649,7 @@
|
||||
"supplyLabel": "Zapas",
|
||||
"supplyRunway": "Wystarczy na około {days} dni",
|
||||
"jumpToZeitplan": "Otwórz harmonogram",
|
||||
"jumpToVerlauf": "Otwórz historię",
|
||||
"jumpToBestand": "Otwórz zapas",
|
||||
"complianceTitle": "Przestrzeganie",
|
||||
"complianceEmpty": "Brak danych o przestrzeganiu."
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- v1.16.11 (#316) — as-needed (PRN) medications.
|
||||
--
|
||||
-- `as_needed = true` marks a medication taken only when the user needs
|
||||
-- it: no fixed schedule (zero medication_schedules rows — enforced at
|
||||
-- the route layer), never due, never reminded, excluded from
|
||||
-- compliance, active indefinitely. Intakes still log, inventory still
|
||||
-- consumes. Additive: every existing row backfills `false`.
|
||||
|
||||
ALTER TABLE "medications" ADD COLUMN "as_needed" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -1305,6 +1305,16 @@ model Medication {
|
||||
/// the migration backfills `oneShot = false` for every existing
|
||||
/// row.
|
||||
oneShot Boolean @default(false) @map("one_shot")
|
||||
/// v1.16.11 (#316) — as-needed (PRN) medication. Taken only when the
|
||||
/// user needs it (pain relief, rescue inhaler): no fixed schedule,
|
||||
/// never due, never reminded, never counted in compliance, stays
|
||||
/// active indefinitely — but intakes log, inventory consumes, and the
|
||||
/// history renders. An as-needed medication carries ZERO
|
||||
/// MedicationSchedule rows (the create/update routes 422 on a
|
||||
/// schedules array alongside `asNeeded: true`); every "never due /
|
||||
/// never reminded" surface follows structurally from the empty
|
||||
/// schedule list. Mutually exclusive with `oneShot`.
|
||||
asNeeded Boolean @default(false) @map("as_needed")
|
||||
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
|
||||
/// so the injection-site picker can surface for any INJECTION dose.
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
|
||||
@@ -277,7 +277,9 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -305,7 +305,9 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// inside this slot while riding the outer fan-out.
|
||||
limit(async () => {
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a
|
||||
// compliance rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -593,3 +593,117 @@ describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
|
||||
expect(prisma.medicationSchedule.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
|
||||
/** Existing-row shape the as-needed invariants read. */
|
||||
function mockExisting(overrides: {
|
||||
asNeeded?: boolean;
|
||||
scheduleCount?: number;
|
||||
}) {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
asNeeded: overrides.asNeeded ?? false,
|
||||
_count: { schedules: overrides.scheduleCount ?? 1 },
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("422s when asNeeded:true carries a non-empty schedules array (Zod)", async () => {
|
||||
mockExisting({ scheduleCount: 1 });
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
asNeeded: true,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when asNeeded:true would keep the existing schedules (no replace list)", async () => {
|
||||
mockExisting({ scheduleCount: 2 });
|
||||
const res = await PUT(putReq({ asNeeded: true }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("schedules: []");
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips to as-needed with schedules:[] — old rows deleted, none recreated", async () => {
|
||||
mockExisting({ scheduleCount: 1 });
|
||||
// Re-stub deps the outer beforeEach's resetAllMocks cleared.
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
} as never);
|
||||
const res = await PUT(
|
||||
putReq({ asNeeded: true, schedules: [] }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Wholesale replace ran: delete-all, recreate none.
|
||||
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
const updateData = vi.mocked(prisma.medication.update).mock
|
||||
.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(updateData.data.asNeeded).toBe(true);
|
||||
expect(updateData.data.schedules).toEqual({ create: [] });
|
||||
});
|
||||
|
||||
it("PUT on an already-as-needed medication never re-acquires schedules silently", async () => {
|
||||
mockExisting({ asNeeded: true, scheduleCount: 0 });
|
||||
// Body without asNeeded but WITH schedules: the effective flag stays
|
||||
// true (existing row), so the entries are refused.
|
||||
const res = await PUT(
|
||||
putReq({ schedules: [{ windowStart: "08:00", windowEnd: "09:00" }] }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a flip back to scheduled without at least one schedule", async () => {
|
||||
mockExisting({ asNeeded: true, scheduleCount: 0 });
|
||||
const res = await PUT(putReq({ asNeeded: false }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("at least one schedule");
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips back to scheduled when the body carries the new schedule list", async () => {
|
||||
mockExisting({ asNeeded: true, scheduleCount: 0 });
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
asNeeded: false,
|
||||
schedules: [{ id: "s1" }],
|
||||
} as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
asNeeded: false,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateData = vi.mocked(prisma.medication.update).mock
|
||||
.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(updateData.data.asNeeded).toBe(false);
|
||||
});
|
||||
|
||||
it("422s an empty schedules array WITHOUT asNeeded:true (Zod)", async () => {
|
||||
mockExisting({ scheduleCount: 1 });
|
||||
const res = await PUT(putReq({ schedules: [] }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -561,3 +561,123 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.16.11 (#316) — as-needed (PRN, schedule-less) intakes
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medication", () => {
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("records an ad-hoc row (scheduledFor = takenAt) and consumes inventory", async () => {
|
||||
// One hour in the past — relative instant, no slot semantics.
|
||||
const takenAt = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const createdEvent = {
|
||||
id: "evt-prn-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: takenAt,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
};
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
|
||||
// Slot-snap resolver loads the med: an as-needed medication carries
|
||||
// ZERO schedules, so band attribution yields no slot → standalone
|
||||
// ad-hoc insert (the documented PRN path).
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
schedules: [],
|
||||
} as never);
|
||||
// Dedup probe — no recent duplicate.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null as never,
|
||||
);
|
||||
// One-shot reconcile probe — not a one-shot.
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({ takenAt: takenAt.toISOString(), skipped: false }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
// Standalone insert anchored on the intake instant — the ad-hoc
|
||||
// contract (`scheduledFor = takenAt`), no slot attribution.
|
||||
const txArg = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
||||
expect(Array.isArray(txArg)).toBe(true);
|
||||
|
||||
// Inventory consumption fires exactly as for a scheduled medication —
|
||||
// the seam is medication-level (eventId + intakeAt), no schedule input.
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
eventId: "evt-prn-1",
|
||||
intakeAt: takenAt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a skipped ad-hoc write never consumes", async () => {
|
||||
const skipEvent = {
|
||||
id: "evt-prn-2",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: new Date(),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
};
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([skipEvent] as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ skipped: true }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -199,7 +199,15 @@ export const PUT = apiHandler(
|
||||
if (guard) return guard;
|
||||
const existing = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { active: true, createdAt: true },
|
||||
// v1.16.11 — `asNeeded` + the schedule count feed the as-needed
|
||||
// invariants below: a flip to as-needed must end schedule-less,
|
||||
// a flip back to scheduled must end with at least one schedule.
|
||||
select: {
|
||||
active: true,
|
||||
createdAt: true,
|
||||
asNeeded: true,
|
||||
_count: { select: { schedules: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Medication not found", 404);
|
||||
@@ -236,9 +244,43 @@ export const PUT = apiHandler(
|
||||
startsOn,
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = parsed.data;
|
||||
|
||||
// ── v1.16.11 (#316) — as-needed invariants ──────────────────────
|
||||
//
|
||||
// An as-needed medication ends schedule-less, always. The Zod layer
|
||||
// already rejects a populated `schedules` array alongside
|
||||
// `asNeeded: true`; the route covers the cross-row cases the schema
|
||||
// cannot see:
|
||||
// - flipping an existing scheduled medication to as-needed
|
||||
// requires `schedules: []` in the same request (keeping the old
|
||||
// rows alongside the flag is a 422, per the feature contract);
|
||||
// - flipping as-needed OFF requires the medication to end with at
|
||||
// least one schedule (the wizard always sends the full array).
|
||||
const effectiveAsNeeded = asNeeded ?? existing.asNeeded;
|
||||
if (effectiveAsNeeded) {
|
||||
const endsScheduleless = schedules
|
||||
? schedules.length === 0
|
||||
: existing._count.schedules === 0;
|
||||
if (!endsScheduleless) {
|
||||
return apiError(
|
||||
"An as-needed medication cannot carry schedules (send schedules: [] to clear them)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
asNeeded === false &&
|
||||
existing.asNeeded &&
|
||||
(!schedules || schedules.length === 0)
|
||||
) {
|
||||
return apiError(
|
||||
"A scheduled medication requires at least one schedule",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// ── v1.5.5 — primary-schedule grace bridge ──────────────────────
|
||||
//
|
||||
// The detail-page settings section saves the reminder-window in
|
||||
@@ -540,6 +582,9 @@ export const PUT = apiHandler(
|
||||
...(startsOn !== undefined && { startsOn }),
|
||||
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
|
||||
@@ -417,3 +417,85 @@ describe("GET /api/medications — todayEventCount counts only actioned rows (v1
|
||||
expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
|
||||
function lastCreateData(): Record<string, unknown> & {
|
||||
schedules: { create: Record<string, unknown>[] };
|
||||
} {
|
||||
const calls = vi.mocked(prisma.medication.create).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return (calls[calls.length - 1][0] as { data: never }).data;
|
||||
}
|
||||
|
||||
it("422s when asNeeded:true carries a schedules array", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
asNeeded: true,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) =>
|
||||
i.message.includes("as-needed medication cannot carry schedules"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(prisma.medication.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when asNeeded:true is combined with oneShot:true", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
asNeeded: true,
|
||||
oneShot: true,
|
||||
startsOn: "2026-06-01",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ message: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) =>
|
||||
i.message.includes("mutually exclusive"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("creates an as-needed medication with ZERO schedules", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "PAIN_RELIEF",
|
||||
unitsPerDose: 2,
|
||||
asNeeded: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const data = lastCreateData();
|
||||
expect(data.asNeeded).toBe(true);
|
||||
expect(data.oneShot).toBeUndefined();
|
||||
expect(data.schedules.create).toEqual([]);
|
||||
// Dose fields stay first-class — they feed inventory consumption.
|
||||
expect(data.unitsPerDose).toBe(2);
|
||||
});
|
||||
|
||||
it("still 422s a scheduled create without any schedule (legacy contract)", async () => {
|
||||
const res = await POST(postReq({ name: "Ramipril", dose: "5 mg" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; message: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.includes("schedules")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,10 @@ export const GET = apiHandler(async () => {
|
||||
// Same ordering as the medications list so the page's card order and
|
||||
// this payload walk the same sequence.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
// v1.16.11 — as-needed (PRN) medications carry no compliance entry:
|
||||
// the cards/table render a last-taken presentation for them instead
|
||||
// of rates, and an empty expected set must not read as 0% or 100%.
|
||||
where: { userId: user.id, asNeeded: false },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
|
||||
@@ -309,6 +309,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
startsOn,
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
} = parsed.data;
|
||||
|
||||
// ── v1.5 route invariants for the new scheduling primitives ───────
|
||||
@@ -335,15 +336,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// always sees a populated `timesOfDay` while legacy iOS clients
|
||||
// (v0.6.x) keep encoding the `windowStart` / `windowEnd` /
|
||||
// `daysOfWeek` shape. Both shapes coexist through v1.5.x.
|
||||
// 4. **As-needed (v1.16.11, #316)** — when `asNeeded === true` the
|
||||
// medication carries ZERO schedules (the Zod refine already
|
||||
// rejects a populated array, mutual exclusion with `oneShot`
|
||||
// included). Every "never due / never reminded / never scored"
|
||||
// surface follows structurally from the empty schedule list.
|
||||
const scheduleInputs = schedules ?? [];
|
||||
if (oneShot === true) {
|
||||
if (schedules.length > 1) {
|
||||
if (scheduleInputs.length > 1) {
|
||||
return apiError(
|
||||
"A one-shot medication can have at most one schedule",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const s = schedules[0];
|
||||
if (s.rrule !== undefined || s.rollingIntervalDays !== undefined) {
|
||||
const s = scheduleInputs[0];
|
||||
if (
|
||||
s &&
|
||||
(s.rrule !== undefined || s.rollingIntervalDays !== undefined)
|
||||
) {
|
||||
return apiError(
|
||||
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
|
||||
422,
|
||||
@@ -387,8 +397,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...(startsOn !== undefined && { startsOn }),
|
||||
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
|
||||
// carries an empty `scheduleInputs`, so the nested create below
|
||||
// persists zero schedule rows.
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
schedules: {
|
||||
create: schedules.map((s) => {
|
||||
create: scheduleInputs.map((s) => {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
// v1.7.0 — PRN schedules carry no cadence, so never default
|
||||
// them to FREQ=DAILY (they would otherwise project + remind).
|
||||
|
||||
@@ -93,6 +93,8 @@ interface Medication {
|
||||
endsOn?: string | null;
|
||||
/** v1.5 — single-administration medication. */
|
||||
oneShot?: boolean;
|
||||
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
|
||||
asNeeded?: boolean;
|
||||
/** v1.9.0 — optional WHO ATC classification code for the FHIR export. */
|
||||
atcCode?: string | null;
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
|
||||
@@ -438,7 +440,10 @@ export default function MedicationsPage() {
|
||||
<div className="space-y-3.5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{activeMeds.map((med) =>
|
||||
med.treatmentClass === "GLP1" ? (
|
||||
// v1.16.11 — an as-needed medication always renders the
|
||||
// generic card: the GLP-1 variant is built around the
|
||||
// rolling injection cadence an as-needed med doesn't have.
|
||||
med.treatmentClass === "GLP1" && !med.asNeeded ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
@@ -466,7 +471,7 @@ export default function MedicationsPage() {
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{inactiveMeds.map((med) =>
|
||||
med.treatmentClass === "GLP1" ? (
|
||||
med.treatmentClass === "GLP1" && !med.asNeeded ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
|
||||
@@ -295,3 +295,75 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
|
||||
expect(html).not.toContain("can still be taken");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) card presentation: a calm
|
||||
* "As needed" marker where next-due normally sits, no due pill, no
|
||||
* overdue escalation, and NO compliance block (neither bars nor an
|
||||
* eternal skeleton — the batched compliance read excludes PRN rows).
|
||||
* The last-intake line stays: the card is last-taken oriented.
|
||||
*/
|
||||
describe("<MedicationCard> — as-needed marker (v1.16.11, #316)", () => {
|
||||
function makeAsNeededMed(overrides: Record<string, unknown> = {}) {
|
||||
const lastTaken = new Date();
|
||||
lastTaken.setHours(lastTaken.getHours() - 3);
|
||||
return {
|
||||
id: "med-prn-1",
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "PAIN_RELIEF",
|
||||
treatmentClass: undefined as string | undefined,
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: lastTaken.toISOString(),
|
||||
todayEventCount: 1,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders the calm marker, the last-intake line, and no compliance block", () => {
|
||||
const med = makeAsNeededMed();
|
||||
const client = makeClient();
|
||||
// Deliberately NOT seeding the compliance cache: the batched read
|
||||
// excludes as-needed medications, so the card must not fall back to
|
||||
// the loading skeleton.
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={med}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="medication-as-needed-marker"');
|
||||
expect(html).toContain("As needed");
|
||||
expect(html).toContain("Last intake:");
|
||||
// No due/overdue presentation, ever.
|
||||
expect(html).not.toContain("Overdue");
|
||||
expect(html).not.toContain("Take now");
|
||||
// No compliance bars and no skeleton placeholder for them.
|
||||
expect(html).not.toContain("Adherence (");
|
||||
});
|
||||
|
||||
it("keeps the quick-log action row (intakes still record)", () => {
|
||||
const med = makeAsNeededMed();
|
||||
const client = makeClient();
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={med}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
// The shared intake actions render (take/skip) — an as-needed dose
|
||||
// is loggable from the card exactly like a scheduled one.
|
||||
expect(html).toContain("Taken");
|
||||
});
|
||||
});
|
||||
@@ -261,6 +261,43 @@ describe("<MedicationTable> — structure + shared payloads", () => {
|
||||
expect(html).toContain('aria-label="Take – Mounjaro"');
|
||||
expect(html).toContain("4 doses");
|
||||
});
|
||||
|
||||
it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => {
|
||||
const client = makeClient();
|
||||
// Deliberately NOT seeding compliance for the PRN row — the batched
|
||||
// payload excludes as-needed medications, and the column must show
|
||||
// the en-dash instead of an eternal skeleton.
|
||||
const lastTaken = new Date();
|
||||
lastTaken.setHours(lastTaken.getHours() - 3);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
med({
|
||||
id: "prn1",
|
||||
name: "Ibuprofen",
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
nextDueAt: null,
|
||||
lastTakenAt: lastTaken.toISOString(),
|
||||
stockDosesRemaining: 10,
|
||||
}),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="medication-table-as-needed-marker"');
|
||||
expect(html).toContain("As needed");
|
||||
// No due/overdue presentation in the status or next-dose cells.
|
||||
expect(html).not.toContain("Overdue");
|
||||
// Compliance column shows the en-dash, not bars or a skeleton.
|
||||
expect(html).not.toContain("Adherence (");
|
||||
// The quick actions stay — an as-needed dose is loggable inline.
|
||||
expect(html).toContain('aria-label="Take – Ibuprofen"');
|
||||
// Stock column still renders (inventory tracking works for PRN).
|
||||
expect(html).toContain("10 doses");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationTable> — manual order + aria-sort", () => {
|
||||
|
||||
@@ -195,6 +195,26 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () =>
|
||||
expect(due).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes an as-needed medication (no schedules, never due) — v1.16.11 #316", () => {
|
||||
// An as-needed (PRN) medication persists ZERO schedules and the
|
||||
// server computes no next-due, so it can NEVER enter the due set —
|
||||
// even with a recent ad-hoc intake on record.
|
||||
const due = deriveDueMedications(
|
||||
[
|
||||
makeMed({
|
||||
id: "prn",
|
||||
schedules: [],
|
||||
nextDueAt: null,
|
||||
lastTakenAt: new Date(now.getTime() - 60 * 60 * 1000).toISOString(),
|
||||
todayEventCount: 1,
|
||||
}),
|
||||
makeMed({ id: "scheduled" }),
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(due.map((m) => m.id)).toEqual(["scheduled"]);
|
||||
});
|
||||
|
||||
it("excludes a dose already covered by an actioned intake in its band", () => {
|
||||
// Taken 30 minutes ago — inside the open 12:00 band; the in-window
|
||||
// suppression (same as the card pill) keeps it out of the due set.
|
||||
|
||||
@@ -111,6 +111,15 @@ export interface MedicationCardBodyProps {
|
||||
/** The open-cycle descriptor, or null when the display block is absent. */
|
||||
currentCycle: CurrentCycle | null;
|
||||
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication. True suppresses the
|
||||
* compliance block entirely (no bars, no skeleton, no error fallback —
|
||||
* the batched compliance read excludes as-needed medications, so a
|
||||
* skeleton would spin forever) and the open-cycle slot. The next/last
|
||||
* lines carry the "Bei Bedarf" + last-taken presentation instead.
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
intakeLoading: string | null;
|
||||
/** Record the displayed dose (the card binds the slot instant). */
|
||||
@@ -137,6 +146,7 @@ export function MedicationCardBody({
|
||||
complianceError = false,
|
||||
onRetryCompliance,
|
||||
currentCycle,
|
||||
asNeeded = false,
|
||||
intakeLoading,
|
||||
onRecordIntake,
|
||||
children,
|
||||
@@ -214,6 +224,7 @@ export function MedicationCardBody({
|
||||
error fallback (no bars, one notice line + retry) instead of
|
||||
sitting on the skeleton forever. */}
|
||||
{active &&
|
||||
!asNeeded &&
|
||||
(compliance ? (
|
||||
<MedicationComplianceBars
|
||||
rate7={compliance.rate7}
|
||||
@@ -232,7 +243,7 @@ export function MedicationCardBody({
|
||||
one xs text line on every active card: the descriptor rides the
|
||||
compliance query, and a line appearing after the fetch resolved
|
||||
used to grow the card and shift the grid row (CLS). */}
|
||||
{active && (
|
||||
{active && !asNeeded && (
|
||||
<div className="min-h-4">
|
||||
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
|
||||
</div>
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface MedicationDetailSnapshot {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
oneShot?: boolean;
|
||||
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
|
||||
asNeeded?: boolean;
|
||||
atcCode?: string | null;
|
||||
rxNormCode?: string | null;
|
||||
/**
|
||||
@@ -189,6 +191,7 @@ function snapshotToWizardPayload(
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
oneShot: med.oneShot ?? false,
|
||||
asNeeded: med.asNeeded ?? false,
|
||||
schedules: med.schedules.map((s) => ({
|
||||
id: s.id,
|
||||
windowStart: s.windowStart,
|
||||
@@ -226,6 +229,7 @@ export function MedicationDetailTabs({
|
||||
|
||||
const id = medication.id;
|
||||
const oneShot = medication.oneShot === true;
|
||||
const asNeeded = medication.asNeeded === true;
|
||||
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
|
||||
const isInjectable = medication.deliveryForm === "INJECTION";
|
||||
|
||||
@@ -238,9 +242,18 @@ export function MedicationDetailTabs({
|
||||
// whatever `?tab=` carries — Injektion is absent for non-injectable
|
||||
// medications, so a stale `?tab=injektion` deep-link falls back to the
|
||||
// landing tab instead of rendering an empty surface.
|
||||
// v1.16.11 — an as-needed medication has no schedule and never
|
||||
// reminds, so the Zeitplan tab (times editor + reminder card +
|
||||
// plan-history) does not apply and is dropped from the registry; a
|
||||
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
|
||||
const availableTabs = useMemo<TabSlug[]>(
|
||||
() => TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
|
||||
[isInjectable],
|
||||
() =>
|
||||
TAB_SLUGS.filter(
|
||||
(slug) =>
|
||||
(slug !== "injektion" || isInjectable) &&
|
||||
(slug !== "zeitplan" || !asNeeded),
|
||||
),
|
||||
[isInjectable, asNeeded],
|
||||
);
|
||||
|
||||
const requestedRaw = searchParams?.get("tab");
|
||||
@@ -296,7 +309,9 @@ export function MedicationDetailTabs({
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: activeTab === "uebersicht",
|
||||
// v1.16.11 — no compliance read for an as-needed medication (the
|
||||
// section is hidden; there is no rate to fetch).
|
||||
enabled: activeTab === "uebersicht" && !asNeeded,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -368,6 +383,7 @@ export function MedicationDetailTabs({
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
asNeeded={asNeeded}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
|
||||
@@ -407,25 +423,35 @@ export function MedicationDetailTabs({
|
||||
value={
|
||||
!medication.active
|
||||
? t("medications.detail.uebersicht.pausedHint")
|
||||
: medication.nextDueAt
|
||||
? fmt.dateTime(medication.nextDueAt)
|
||||
: t("medications.detail.uebersicht.nextDoseNone")
|
||||
: asNeeded
|
||||
? t("medications.detail.uebersicht.nextDoseAsNeeded")
|
||||
: medication.nextDueAt
|
||||
? fmt.dateTime(medication.nextDueAt)
|
||||
: t("medications.detail.uebersicht.nextDoseNone")
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
|
||||
onJump={() => onTabChange("zeitplan")}
|
||||
jumpLabel={
|
||||
asNeeded
|
||||
? t("medications.detail.uebersicht.jumpToVerlauf")
|
||||
: t("medications.detail.uebersicht.jumpToZeitplan")
|
||||
}
|
||||
onJump={() => onTabChange(asNeeded ? "verlauf" : "zeitplan")}
|
||||
dataSlot="uebersicht-next-dose"
|
||||
/>
|
||||
<StatusRow
|
||||
label={t("medications.detail.uebersicht.reminderLabel")}
|
||||
value={
|
||||
medication.notificationsEnabled
|
||||
? t("medications.detail.uebersicht.reminderOn")
|
||||
: t("medications.detail.uebersicht.reminderOff")
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
|
||||
onJump={() => onTabChange("zeitplan")}
|
||||
dataSlot="uebersicht-reminder"
|
||||
/>
|
||||
{/* v1.16.11 — as-needed never reminds; the row would only
|
||||
mislead (the cron skips schedule-less medications). */}
|
||||
{!asNeeded && (
|
||||
<StatusRow
|
||||
label={t("medications.detail.uebersicht.reminderLabel")}
|
||||
value={
|
||||
medication.notificationsEnabled
|
||||
? t("medications.detail.uebersicht.reminderOn")
|
||||
: t("medications.detail.uebersicht.reminderOff")
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
|
||||
onJump={() => onTabChange("zeitplan")}
|
||||
dataSlot="uebersicht-reminder"
|
||||
/>
|
||||
)}
|
||||
{inventoryItems.length > 0 && (
|
||||
<StatusRow
|
||||
label={t("medications.detail.uebersicht.supplyLabel")}
|
||||
@@ -471,6 +497,7 @@ export function MedicationDetailTabs({
|
||||
</ul>
|
||||
</MedicationDetailSection>
|
||||
|
||||
{!asNeeded && (
|
||||
<MedicationDetailSection
|
||||
titleId="medication-uebersicht-compliance-heading"
|
||||
title={t("medications.detail.uebersicht.complianceTitle")}
|
||||
@@ -490,6 +517,7 @@ export function MedicationDetailTabs({
|
||||
</p>
|
||||
)}
|
||||
</MedicationDetailSection>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
|
||||
|
||||
@@ -85,6 +85,13 @@ interface Medication {
|
||||
* still takeable" instead of the regular upcoming-intake phrasing.
|
||||
*/
|
||||
nextDueOverdue?: boolean;
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication: no schedules, never
|
||||
* due, never reminded, no compliance. The card renders a calm
|
||||
* "Bei Bedarf" marker where next-due normally sits and replaces the
|
||||
* compliance bars with the last-taken oriented presentation.
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -300,6 +307,15 @@ export function MedicationCard({
|
||||
/>
|
||||
);
|
||||
|
||||
// v1.16.11 — as-needed: a calm marker where next-due normally sits.
|
||||
// No due pill, no overdue escalation, ever (structurally there is no
|
||||
// schedule, so the window status is already null).
|
||||
const asNeededLine = medication.asNeeded ? (
|
||||
<span className="text-muted-foreground" data-slot="medication-as-needed-marker">
|
||||
{t("medications.asNeededMarker")}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
// The upcoming-intake line value — a day label + window range + optional
|
||||
// dose accent. The card owns this VALUE content (a daily med reads as a
|
||||
// clock-time window); the structure / labels live in the shared body.
|
||||
@@ -396,13 +412,14 @@ export function MedicationCard({
|
||||
}
|
||||
: null
|
||||
}
|
||||
doseStatus={doseStatus}
|
||||
nextLine={nextLine}
|
||||
doseStatus={medication.asNeeded ? "upcoming" : doseStatus}
|
||||
nextLine={asNeededLine ?? nextLine}
|
||||
lastLine={
|
||||
medication.lastTakenAt
|
||||
? formatLastTakenAt(medication.lastTakenAt)
|
||||
: null
|
||||
}
|
||||
asNeeded={medication.asNeeded === true}
|
||||
compliance={
|
||||
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface MedicationDetailSummaryProps {
|
||||
/** Wizard payload used to derive the plain-language cadence line. */
|
||||
payload: MedicationPayload;
|
||||
oneShot: boolean;
|
||||
/** v1.16.11 — as-needed (PRN): the cadence line reads "Bei Bedarf". */
|
||||
asNeeded?: boolean;
|
||||
startsOn?: string | null;
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ export function MedicationDetailSummary({
|
||||
endsOn,
|
||||
payload,
|
||||
oneShot,
|
||||
asNeeded = false,
|
||||
startsOn,
|
||||
}: MedicationDetailSummaryProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -83,7 +86,9 @@ export function MedicationDetailSummary({
|
||||
date: formatters.dateTime(startsOn),
|
||||
})
|
||||
: t("medications.detail.cadence.oneShotPending")
|
||||
: summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
|
||||
: asNeeded
|
||||
? t("medications.detail.cadence.asNeeded")
|
||||
: summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-slot="medication-detail-summary">
|
||||
|
||||
@@ -87,6 +87,11 @@ export interface TableMedication {
|
||||
todayEventCount?: number;
|
||||
nextDueAt?: string | null;
|
||||
nextDueOverdue?: boolean;
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN): the next-dose cell shows a calm
|
||||
* "Bei Bedarf" marker, the compliance column shows "–".
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
schedules: TableSchedule[];
|
||||
@@ -463,7 +468,18 @@ function MedicationTableRowItem({
|
||||
let nextCell: React.ReactNode = (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
);
|
||||
if (medication.active) {
|
||||
if (medication.asNeeded) {
|
||||
// v1.16.11 — a calm marker where next-due normally sits; no due
|
||||
// pill, no overdue escalation, ever.
|
||||
nextCell = (
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
data-slot="medication-table-as-needed-marker"
|
||||
>
|
||||
{t("medications.asNeededMarker")}
|
||||
</span>
|
||||
);
|
||||
} else if (medication.active) {
|
||||
if (medication.nextDueOverdue && nextAt) {
|
||||
nextCell = (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
@@ -609,7 +625,7 @@ function MedicationTableRowItem({
|
||||
</TableCell>
|
||||
<TableCell>{statusCell}</TableCell>
|
||||
<TableCell className="text-sm">{nextCell}</TableCell>
|
||||
<TableCell>{medication.active ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
|
||||
<TableCell>{medication.active && !medication.asNeeded ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active ? (
|
||||
|
||||
@@ -1036,3 +1036,93 @@ describe("v1.15.18 per-dose windows — encode + hydrate round-trip", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.16.11 (#316) — as-needed (PRN) mode
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("as-needed mode (v1.16.11, #316)", () => {
|
||||
function asNeededPayload(): WizardPayload {
|
||||
return {
|
||||
...emptyWizardPayload(),
|
||||
mode: "asNeeded",
|
||||
treatmentRow: "painRelief",
|
||||
name: "Ibuprofen",
|
||||
doseAmount: "400",
|
||||
doseUnit: "mg",
|
||||
unitsPerDose: "2",
|
||||
};
|
||||
}
|
||||
|
||||
it("progressIndices walks the one-shot-shaped path (no cadence detail, no times)", () => {
|
||||
expect(progressIndices("asNeeded", "daily")).toEqual([1, 2, 3, 4, 8]);
|
||||
});
|
||||
|
||||
it("buildCreateBody emits asNeeded:true with an EMPTY schedules array", () => {
|
||||
const body = buildCreateBody(asNeededPayload());
|
||||
expect(body.asNeeded).toBe(true);
|
||||
expect(body.oneShot).toBe(false);
|
||||
expect(body.schedules).toEqual([]);
|
||||
// Dose fields stay first-class — they feed inventory consumption.
|
||||
expect(body.dose).toBe("400 mg");
|
||||
expect(body.unitsPerDose).toBe(2);
|
||||
expect(body.category).toBe("PAIN_RELIEF");
|
||||
});
|
||||
|
||||
it("a scheduled payload emits asNeeded:false (no accidental flag bleed)", () => {
|
||||
const body = buildCreateBody(withCadence("daily"));
|
||||
expect(body.asNeeded).toBe(false);
|
||||
expect(body.schedules.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hydrateWizardPayload round-trips an as-needed snapshot back onto the mode", () => {
|
||||
const hydrated = hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "PAIN_RELIEF",
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
});
|
||||
expect(hydrated.mode).toBe("asNeeded");
|
||||
// Step 5 pre-selects "Bei Bedarf" via the mode; the placeholder
|
||||
// draft carries it too so the flat mirror stays coherent.
|
||||
expect(hydrated.schedules).toHaveLength(1);
|
||||
expect(hydrated.schedules[0].mode).toBe("asNeeded");
|
||||
|
||||
// Full round-trip: hydrate → encode emits the same wire shape.
|
||||
const body = buildCreateBody(hydrated, "edit");
|
||||
expect(body.asNeeded).toBe(true);
|
||||
expect(body.schedules).toEqual([]);
|
||||
// Edit bodies never round-trip the reminders default (invariant 16).
|
||||
expect(body.notificationsEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summariseCadence reads the asNeeded key with no times phrase", () => {
|
||||
const t = makeStubT();
|
||||
const line = summariseCadence(asNeededPayload(), t);
|
||||
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
|
||||
expect(line).not.toContain("medications.wizard.summary.times");
|
||||
});
|
||||
|
||||
it("summariseScheduleDraft reads the asNeeded key with no times phrase", () => {
|
||||
const t = makeStubT();
|
||||
const line = summariseScheduleDraft(
|
||||
{ ...emptyWizardPayload().schedules[0], mode: "asNeeded" },
|
||||
t,
|
||||
);
|
||||
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
|
||||
expect(line).not.toContain("medications.wizard.summary.times");
|
||||
});
|
||||
|
||||
it("validateStep gates: Step 5 passes once the mode is picked; review mirrors Step 4", () => {
|
||||
const p = asNeededPayload();
|
||||
expect(validateStep(p, 5)).toBe(true);
|
||||
expect(validateStep(p, 8)).toBe(true);
|
||||
expect(validateStep({ ...p, mode: null }, 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,10 @@ import type { WizardPayload } from "../wizard-payload";
|
||||
import type { StepProps } from "./Step1Name";
|
||||
|
||||
/**
|
||||
* The six cadence rows surfaced in Step 5. Each maps to a (mode,
|
||||
* The seven cadence rows surfaced in Step 5. Each maps to a (mode,
|
||||
* cadenceKind) decision. "Einmalig" flips the wizard onto the
|
||||
* one-shot path; every other row stays recurring.
|
||||
* one-shot path; "Bei Bedarf" (v1.16.11, #316) onto the as-needed
|
||||
* path (no schedule at all); every other row stays recurring.
|
||||
*/
|
||||
type Step5Row =
|
||||
| "daily"
|
||||
@@ -18,7 +19,8 @@ type Step5Row =
|
||||
| "everyNWeeks"
|
||||
| "monthly"
|
||||
| "rolling"
|
||||
| "oneShot";
|
||||
| "oneShot"
|
||||
| "asNeeded";
|
||||
|
||||
const STEP5_ROWS: readonly Step5Row[] = [
|
||||
"daily",
|
||||
@@ -27,10 +29,12 @@ const STEP5_ROWS: readonly Step5Row[] = [
|
||||
"monthly",
|
||||
"rolling",
|
||||
"oneShot",
|
||||
"asNeeded",
|
||||
];
|
||||
|
||||
function rowFromPayload(payload: WizardPayload): Step5Row | null {
|
||||
if (payload.mode === "oneShot") return "oneShot";
|
||||
if (payload.mode === "asNeeded") return "asNeeded";
|
||||
if (payload.mode === "recurring") {
|
||||
switch (payload.cadence.kind) {
|
||||
case "daily":
|
||||
@@ -108,6 +112,13 @@ function applyRowPick(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row === "asNeeded") {
|
||||
// v1.16.11 — as-needed clears schedule configuration: the wizard
|
||||
// emits an empty `schedules` array on save (`buildCreateBody`), so
|
||||
// the cadence value left on the draft is inert.
|
||||
applyPartial({ mode: "asNeeded" });
|
||||
return;
|
||||
}
|
||||
const cadenceKind: CadenceKind = row;
|
||||
applyPartial({
|
||||
mode: "recurring",
|
||||
|
||||
@@ -145,7 +145,11 @@ export function Step8Summary({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{payload.mode !== "oneShot" && (
|
||||
{/* v1.16.11 — an as-needed medication carries no parallel
|
||||
schedules; the single "Bei Bedarf" card stays as the route
|
||||
back into Step 5 (mirroring the one-shot edit path), but the
|
||||
add affordance is hidden alongside one-shot. */}
|
||||
{payload.mode !== "oneShot" && payload.mode !== "asNeeded" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSchedule}
|
||||
@@ -163,6 +167,9 @@ export function Step8Summary({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v1.16.11 — an as-needed medication never reminds (no slots),
|
||||
so the reminders toggle is hidden alongside the schedules. */}
|
||||
{payload.mode !== "asNeeded" && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
@@ -185,8 +192,9 @@ export function Step8Summary({
|
||||
aria-label={t("medications.wizard.steps.step8.remindersLabel")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoChannelHint && (
|
||||
{payload.mode !== "asNeeded" && showNoChannelHint && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="wizard-no-channel-hint"
|
||||
|
||||
@@ -176,7 +176,7 @@ export function rowFromTreatment(
|
||||
*/
|
||||
export interface ScheduleDraft {
|
||||
id?: string;
|
||||
mode: "oneShot" | "recurring" | null;
|
||||
mode: "oneShot" | "recurring" | "asNeeded" | null;
|
||||
cadence: CadenceValue;
|
||||
subControls: CadenceSubControls;
|
||||
timesOfDay: string[];
|
||||
@@ -237,7 +237,7 @@ export interface WizardPayload {
|
||||
*/
|
||||
allowedInjectionSites: InjectionSiteKey[];
|
||||
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
|
||||
mode: "oneShot" | "recurring" | null;
|
||||
mode: "oneShot" | "recurring" | "asNeeded" | null;
|
||||
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
|
||||
cadence: CadenceValue;
|
||||
/** Mirrors `schedules[activeScheduleIndex].subControls`. */
|
||||
@@ -402,6 +402,14 @@ export function removeSchedule(
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ONE_SHOT_PATH: readonly number[] = [1, 2, 3, 4, 8];
|
||||
/**
|
||||
* v1.16.11 — as-needed (PRN) walks the one-shot shape: picking
|
||||
* "Bei Bedarf" in Step 5 skips the sub-cadence + times steps entirely
|
||||
* (an as-needed medication carries no schedule), landing on the
|
||||
* summary. The dose step stays — dose text + unitsPerDose still feed
|
||||
* inventory consumption.
|
||||
*/
|
||||
const AS_NEEDED_PATH: readonly number[] = [1, 2, 3, 4, 8];
|
||||
const DAILY_PATH: readonly number[] = [1, 2, 3, 4, 5, 7, 8];
|
||||
const RECURRING_PATH: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
@@ -418,6 +426,7 @@ export function progressIndices(
|
||||
cadenceKind: CadenceKind,
|
||||
): readonly number[] {
|
||||
if (mode === "oneShot") return ONE_SHOT_PATH;
|
||||
if (mode === "asNeeded") return AS_NEEDED_PATH;
|
||||
if (mode === "recurring" && cadenceKind === "daily") return DAILY_PATH;
|
||||
return RECURRING_PATH;
|
||||
}
|
||||
@@ -552,6 +561,11 @@ export interface CreateMedicationBody {
|
||||
startsOn?: string;
|
||||
endsOn?: string;
|
||||
oneShot: boolean;
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication. True emits an EMPTY
|
||||
* `schedules` array (the route 422s on any entry alongside the flag).
|
||||
*/
|
||||
asNeeded: boolean;
|
||||
schedules: Array<{
|
||||
id?: string;
|
||||
windowStart: string;
|
||||
@@ -675,10 +689,15 @@ export function buildCreateBody(
|
||||
// A one-shot medication carries exactly one schedule with no
|
||||
// recurrence. We honour the active draft (it carries the times the
|
||||
// user picked); the route enforces the one-schedule invariant.
|
||||
// v1.16.11 — an as-needed medication carries NO schedule at all; the
|
||||
// wizard clears the list client-side (the server 422s otherwise).
|
||||
const isOneShot = committed.mode === "oneShot";
|
||||
const draftsToEmit = isOneShot
|
||||
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
|
||||
: committed.schedules;
|
||||
const isAsNeeded = committed.mode === "asNeeded";
|
||||
const draftsToEmit = isAsNeeded
|
||||
? []
|
||||
: isOneShot
|
||||
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
|
||||
: committed.schedules;
|
||||
|
||||
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
|
||||
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
|
||||
@@ -716,6 +735,7 @@ export function buildCreateBody(
|
||||
notificationsEnabled: committed.notificationsEnabled,
|
||||
}),
|
||||
oneShot: isOneShot,
|
||||
asNeeded: isAsNeeded,
|
||||
schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)),
|
||||
};
|
||||
if (committed.startsOn) {
|
||||
@@ -739,6 +759,7 @@ function summaryKeyForDraft(
|
||||
sub: CadenceSubControls,
|
||||
): string {
|
||||
if (mode === "oneShot") return "oneShot";
|
||||
if (mode === "asNeeded") return "asNeeded";
|
||||
switch (cadence.kind) {
|
||||
case "daily":
|
||||
return "daily";
|
||||
@@ -856,7 +877,9 @@ export function summariseCadence(
|
||||
): string {
|
||||
const cadenceLine = cadenceLineFor(payload, t);
|
||||
const timesPhrase =
|
||||
payload.mode !== "oneShot" && payload.timesOfDay.length > 0
|
||||
payload.mode !== "oneShot" &&
|
||||
payload.mode !== "asNeeded" &&
|
||||
payload.timesOfDay.length > 0
|
||||
? t("medications.wizard.summary.times", {
|
||||
times: sortTimes(payload.timesOfDay).join(", "),
|
||||
})
|
||||
@@ -890,7 +913,9 @@ export function summariseScheduleDraft(
|
||||
): string {
|
||||
const cadenceLine = cadenceLineFor(draft, t);
|
||||
const timesPhrase =
|
||||
draft.mode !== "oneShot" && draft.timesOfDay.length > 0
|
||||
draft.mode !== "oneShot" &&
|
||||
draft.mode !== "asNeeded" &&
|
||||
draft.timesOfDay.length > 0
|
||||
? t("medications.wizard.summary.times", {
|
||||
times: sortTimes(draft.timesOfDay).join(", "),
|
||||
})
|
||||
@@ -928,6 +953,8 @@ export interface MedicationPayload {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
/** v1.16.11 — as-needed (PRN) flag; hydrates the "Bei Bedarf" mode. */
|
||||
asNeeded?: boolean;
|
||||
schedules: Array<{
|
||||
id?: string;
|
||||
windowStart: string;
|
||||
@@ -1012,8 +1039,13 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
const base = emptyWizardPayload();
|
||||
const parsedDose = parseDoseExpression(initial.dose ?? "");
|
||||
|
||||
const drafts: ScheduleDraft[] =
|
||||
initial.schedules.length > 0
|
||||
// v1.16.11 — an as-needed medication persists ZERO schedules; hydrate
|
||||
// one placeholder draft carrying the asNeeded mode so the flat mirror
|
||||
// and Step-5 selection round-trip (the encode path emits no schedule).
|
||||
const asNeeded = initial.asNeeded === true;
|
||||
const drafts: ScheduleDraft[] = asNeeded
|
||||
? [{ ...emptyScheduleDraft(), mode: "asNeeded" }]
|
||||
: initial.schedules.length > 0
|
||||
? initial.schedules.map((schedule) =>
|
||||
hydrateScheduleDraft(schedule, initial.oneShot),
|
||||
)
|
||||
|
||||
@@ -769,7 +769,9 @@ async function buildCoachSnapshotImpl(
|
||||
// can be reconstructed per medication.
|
||||
const complianceMedsPromise = wantsCompliance
|
||||
? prisma.medication.findMany({
|
||||
where: { userId },
|
||||
// v1.16.11 — as-needed (PRN) medications never reach the Coach
|
||||
// compliance context (no expected doses, no rate).
|
||||
where: { userId, asNeeded: false },
|
||||
select: {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medications are excluded from every
|
||||
* surface that computes or displays a compliance rate. A PRN medication
|
||||
* has no expected doses, so it must contribute NEITHER 0% NOR 100% to
|
||||
* any rate, streak, or aggregate.
|
||||
*
|
||||
* Source pins: each call site that feeds the compliance engine fetches
|
||||
* its medications with an explicit `asNeeded: false` predicate. The pin
|
||||
* walks the medication-feeding `findMany` of every rate-surfacing call
|
||||
* site (the documented set: Coach prompt, BP-status gate, health-score
|
||||
* pillar, insight features / targets / comprehensive, the nightly
|
||||
* compliance status, the batched card-compliance payload) and asserts
|
||||
* the predicate is present — a refactor that drops it fails here before
|
||||
* a PRN medication can drag a user's rate to 0% (never-taken-on-no-
|
||||
* schedule) or pad it to 100% (vacuous denominator).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CALL_SITES: Array<{ file: string; surface: string }> = [
|
||||
{
|
||||
file: "src/lib/analytics/health-score-fast-path.ts",
|
||||
surface: "health-score compliance pillar (dashboard)",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/blood-pressure-status.ts",
|
||||
surface: "BP-status compliance gate",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/medication-compliance-status.ts",
|
||||
surface: "nightly medication-compliance status",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/features.ts",
|
||||
surface: "insight feature extraction (c7/c30/c90)",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/insights/targets/route.ts",
|
||||
surface: "insight targets",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/insights/comprehensive/route.ts",
|
||||
surface: "comprehensive insight",
|
||||
},
|
||||
{
|
||||
file: "src/lib/ai/coach/snapshot.ts",
|
||||
surface: "Coach prompt compliance context",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/medications/compliance/route.ts",
|
||||
surface: "batched card/table compliance payload",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The medication-feeding read at each call site must carry the
|
||||
* exclusion inside its `where`: an `asNeeded: false` predicate within
|
||||
* the few lines following a `medication.findMany(` opener (comment
|
||||
* lines between them are fine).
|
||||
*/
|
||||
function hasExcludingFindMany(source: string): boolean {
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const at = source.indexOf("medication.findMany(", from);
|
||||
if (at < 0) return false;
|
||||
const window = source.slice(at, at + 600);
|
||||
// No dot in the pattern, so no dotall flag is needed — `[^}]` and
|
||||
// `\s` both span newlines.
|
||||
if (/where:\s*\{[^}]*\basNeeded:\s*false\b/.test(window)) return true;
|
||||
from = at + 1;
|
||||
}
|
||||
}
|
||||
|
||||
describe("as-needed compliance exclusion — source pins (v1.16.11, #316)", () => {
|
||||
for (const { file, surface } of CALL_SITES) {
|
||||
it(`${surface} excludes asNeeded medications (${file})`, () => {
|
||||
const source = readFileSync(resolve(process.cwd(), file), "utf8");
|
||||
expect(
|
||||
hasExcludingFindMany(source),
|
||||
`${file} must fetch medications with \`asNeeded: false\``,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it("the reminder tick carries the same predicate", () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/lib/jobs/reminder/medication-reminder-check.ts",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(hasExcludingFindMany(source)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -305,7 +305,10 @@ export async function computeUserHealthScoreFastPath(
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications carry no expected doses
|
||||
// and are excluded from every compliance rate: they must
|
||||
// contribute neither 0% nor 100% to the score pillar.
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
|
||||
@@ -264,7 +264,9 @@ export async function generateBloodPressureStatusForUser(
|
||||
const weightVsSystolicCorrelation = pearsonCorrelation(weightVsSystolicPairs);
|
||||
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications never feed the BP-status
|
||||
// compliance gate (no expected doses, no rate).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -911,7 +911,9 @@ export async function extractFeatures(
|
||||
|
||||
// Medications
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses), so the insight features exclude them.
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -154,7 +154,9 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v1.16.11 (#316) — the medication-reminder tick skips as-needed (PRN)
|
||||
* medications at the QUERY level: the candidate read carries
|
||||
* `asNeeded: false`, so a PRN medication never reaches the per-schedule
|
||||
* loop, never mints a missed-dose row, and never dispatches a phase
|
||||
* notification. (Structurally a PRN medication also has zero schedules,
|
||||
* so the loop would be a no-op — the predicate keeps it out of the
|
||||
* tick's per-medication intake reads entirely.)
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const prismaMock = {
|
||||
telegramScheduledDeletion: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
medication: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn(),
|
||||
},
|
||||
telegramReminderMessage: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
user: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/jobs/reminder/shared", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/jobs/reminder/shared")>();
|
||||
return {
|
||||
...actual,
|
||||
getWorkerPrisma: () => prismaMock,
|
||||
workerLog: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
recordError: vi.fn(),
|
||||
recordReminderCheck: vi.fn(),
|
||||
markWorkerStarted: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: vi.fn(
|
||||
async (
|
||||
_name: string,
|
||||
fn: (evt: {
|
||||
addMeta: (k: string, v: unknown) => void;
|
||||
addWarning: (m: string) => void;
|
||||
setError: (e: unknown) => void;
|
||||
}) => Promise<void>,
|
||||
) =>
|
||||
fn({ addMeta: vi.fn(), addWarning: vi.fn(), setError: vi.fn() }),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue({ dispatched: true }),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") }));
|
||||
vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() }));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.telegramScheduledDeletion.findMany.mockResolvedValue([]);
|
||||
prismaMock.medication.updateMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.medication.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("handleReminderCheck — as-needed skip (v1.16.11, #316)", () => {
|
||||
it("queries only active NON-as-needed medications", async () => {
|
||||
await handleReminderCheck([]);
|
||||
|
||||
expect(prismaMock.medication.findMany).toHaveBeenCalledTimes(1);
|
||||
const args = prismaMock.medication.findMany.mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(args.where).toEqual({ active: true, asNeeded: false });
|
||||
});
|
||||
|
||||
it("dispatches nothing when the candidate set is empty", async () => {
|
||||
await handleReminderCheck([]);
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
expect(prismaMock.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -133,9 +133,13 @@ export async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
|
||||
// Get all active medications with schedules and phase config
|
||||
// Get all active medications with schedules and phase config.
|
||||
// v1.16.11 — as-needed (PRN) medications never remind: they carry
|
||||
// zero schedules anyway (the per-schedule loop below would be a
|
||||
// no-op), but the explicit predicate keeps them out of the tick's
|
||||
// per-medication intake reads entirely.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { active: true },
|
||||
where: { active: true, asNeeded: false },
|
||||
include: {
|
||||
schedules: true,
|
||||
phaseConfig: true,
|
||||
|
||||
@@ -444,6 +444,37 @@ describe("consumeForIntake", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("consumes for an as-needed (schedule-less) medication exactly like a scheduled one (v1.16.11 #316)", async () => {
|
||||
// The consumption seam is MEDICATION-level: its only inputs are the
|
||||
// event row, the medication's `unitsPerDose`, and the container
|
||||
// pool — no schedule is read anywhere on the path (the fake client
|
||||
// doesn't even model one). An ad-hoc PRN intake therefore decrements
|
||||
// stock byte-identically to a slot-attributed dose.
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 2,
|
||||
events: [takenEvent()],
|
||||
items: [
|
||||
item({
|
||||
id: "open",
|
||||
state: "IN_USE",
|
||||
unitsRemaining: 6,
|
||||
firstUseAt: new Date(NOW.getTime() - 2 * MS_PER_DAY),
|
||||
expiresAt: new Date(NOW.getTime() + 28 * MS_PER_DAY),
|
||||
}),
|
||||
],
|
||||
};
|
||||
const client = makeClient(state);
|
||||
await consumeForIntake(consumeArgs(client));
|
||||
|
||||
expect(state.items[0].unitsRemaining).toBe(4);
|
||||
expect(state.events[0].inventoryConsumption).toEqual([
|
||||
{ itemId: "open", units: 2 },
|
||||
]);
|
||||
// The hook never asked for a schedule — the medication read covers
|
||||
// `unitsPerDose` only.
|
||||
expect(client.medication.findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stamps an empty consumption when no tracked stock exists (exactly-once gate)", async () => {
|
||||
const state: FakeState = {
|
||||
unitsPerDose: 1,
|
||||
|
||||
@@ -480,3 +480,37 @@ describe("resolved-slot marks — ad-hoc rows only match their own anchor", () =
|
||||
expect(due!.at.toISOString()).toBe("2026-06-10T08:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medications persist ZERO schedule
|
||||
* rows, so every due surface (list cards, dashboard, reminder worker)
|
||||
* resolves null by construction. Pin both the forward next-due and the
|
||||
* display-due (open-overdue) paths on the empty schedule list, with a
|
||||
* recent intake present — a logged ad-hoc take must not conjure a due.
|
||||
*/
|
||||
describe("as-needed (zero schedules) — never due", () => {
|
||||
it("computeNextDueAt returns null even with a recent intake", () => {
|
||||
const now = new Date();
|
||||
const next = computeNextDueAt({
|
||||
medication: makeMedication({ createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) }),
|
||||
schedules: [],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
|
||||
});
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it("computeDisplayDue returns null — no overdue escalation can ever mint", () => {
|
||||
const now = new Date();
|
||||
const display = computeDisplayDue({
|
||||
medication: makeMedication({ createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) }),
|
||||
schedules: [],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
|
||||
resolvedSlots: [],
|
||||
});
|
||||
expect(display).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,20 @@
|
||||
* Pure functions, no DB access. Caller fetches `lastIntakeAt`
|
||||
* (latest `MedicationIntakeEvent.takenAt`) and threads it through
|
||||
* `RecurrenceContext`.
|
||||
*
|
||||
* **As-needed medications (v1.16.11, #316).** A medication with
|
||||
* `Medication.asNeeded = true` carries ZERO schedule rows (enforced at
|
||||
* the create/update routes), so this engine is never consulted for it:
|
||||
* `computeNextDueAt` / `computeDisplayDue` return null on an empty
|
||||
* schedule list by construction, the reminder worker iterates no
|
||||
* schedules, the projector mints no slots, and compliance expands no
|
||||
* expected doses. The MEDICATION_LOW_STOCK runway is likewise null (no
|
||||
* consuming schedule) — that is correct and intended: an as-needed
|
||||
* supply is not falling on a predictable cadence, so no runway-based
|
||||
* alert can be honest. Intakes for such medications are ad-hoc rows
|
||||
* (`scheduledFor = takenAt`, the documented standalone-insert
|
||||
* contract); the schedule-level `scheduleType = "PRN"` early-returns
|
||||
* below are the per-schedule prior art for the same semantics.
|
||||
*/
|
||||
|
||||
import { RRule } from "rrule";
|
||||
|
||||
@@ -207,6 +207,11 @@ export const medicationResource = z
|
||||
.describe(
|
||||
"v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.",
|
||||
),
|
||||
asNeeded: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.",
|
||||
),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
schedules: z.array(medicationScheduleResource),
|
||||
|
||||
@@ -447,8 +447,31 @@ const courseWindowFields = {
|
||||
.describe(
|
||||
"Single-administration medication (e.g. flu shot, post-op single dose). When true the medication has at most one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped).",
|
||||
),
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication. No fixed schedule:
|
||||
* never due, never reminded, excluded from compliance, active
|
||||
* indefinitely. Intakes log ad-hoc and consume inventory. Carries
|
||||
* ZERO schedules — a `schedules` array alongside `asNeeded: true` is
|
||||
* a 422. Mutually exclusive with `oneShot`.
|
||||
*/
|
||||
asNeeded: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.",
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.16.11 — shared as-needed cross-field refines for the create +
|
||||
* update bodies: `asNeeded` and `oneShot` are mutually exclusive, and
|
||||
* an as-needed medication cannot carry schedule entries.
|
||||
*/
|
||||
const AS_NEEDED_ONE_SHOT_MESSAGE =
|
||||
"asNeeded and oneShot are mutually exclusive";
|
||||
const AS_NEEDED_SCHEDULES_MESSAGE =
|
||||
"An as-needed medication cannot carry schedules";
|
||||
|
||||
export const createMedicationSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -517,7 +540,12 @@ export const createMedicationSchema = z
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary, US). */
|
||||
rxNormCode: rxNormCodeField,
|
||||
...courseWindowFields,
|
||||
schedules: z.array(scheduleSchema).min(1, "Mindestens ein Zeitfenster"),
|
||||
/**
|
||||
* v1.16.11 — optional at the type level so an `asNeeded: true`
|
||||
* create can omit it; the refine below keeps the legacy
|
||||
* at-least-one-schedule contract for every scheduled medication.
|
||||
*/
|
||||
schedules: z.array(scheduleSchema).optional(),
|
||||
})
|
||||
.refine((b) => b.oneShot !== true || !!b.startsOn, {
|
||||
message: "startsOn is required when oneShot is true",
|
||||
@@ -531,10 +559,30 @@ export const createMedicationSchema = z
|
||||
path: ["endsOn"],
|
||||
},
|
||||
)
|
||||
.refine((b) => !(b.asNeeded === true && b.oneShot === true), {
|
||||
message: AS_NEEDED_ONE_SHOT_MESSAGE,
|
||||
path: ["asNeeded"],
|
||||
})
|
||||
.refine(
|
||||
(b) =>
|
||||
b.asNeeded === true || (!!b.schedules && b.schedules.length >= 1),
|
||||
{
|
||||
message: "Mindestens ein Zeitfenster",
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(b) =>
|
||||
b.asNeeded !== true || !b.schedules || b.schedules.length === 0,
|
||||
{
|
||||
message: AS_NEEDED_SCHEDULES_MESSAGE,
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.meta({
|
||||
id: "CreateMedicationRequest",
|
||||
description:
|
||||
"Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = \"FREQ=DAILY\"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it.",
|
||||
"Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = \"FREQ=DAILY\"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry.",
|
||||
});
|
||||
|
||||
export const updateMedicationSchema = z
|
||||
@@ -626,10 +674,32 @@ export const updateMedicationSchema = z
|
||||
path: ["endsOn"],
|
||||
},
|
||||
)
|
||||
.refine((b) => !(b.asNeeded === true && b.oneShot === true), {
|
||||
message: AS_NEEDED_ONE_SHOT_MESSAGE,
|
||||
path: ["asNeeded"],
|
||||
})
|
||||
.refine(
|
||||
(b) =>
|
||||
b.asNeeded !== true || !b.schedules || b.schedules.length === 0,
|
||||
{
|
||||
message: AS_NEEDED_SCHEDULES_MESSAGE,
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
// An empty replace-list is only legal on the as-needed path — for a
|
||||
// scheduled medication it would wipe every schedule and strand the
|
||||
// row in a state no surface can reason about.
|
||||
(b) => b.asNeeded === true || !b.schedules || b.schedules.length >= 1,
|
||||
{
|
||||
message: "Mindestens ein Zeitfenster",
|
||||
path: ["schedules"],
|
||||
},
|
||||
)
|
||||
.meta({
|
||||
id: "UpdateMedicationRequest",
|
||||
description:
|
||||
"Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match the create path.",
|
||||
"Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty `schedules` array without `asNeeded: true` is likewise a 422.",
|
||||
});
|
||||
|
||||
export const intakeSchema = z
|
||||
|
||||
Reference in new issue
Block a user