From 3943dcfb7274fc8f22178cba225a1436b27ecac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 22:56:03 +0200 Subject: [PATCH] feat(medications): as-needed medications log without a schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/api/openapi.yaml | 45 ++++++- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/pl.json | 11 +- .../migration.sql | 9 ++ prisma/schema.prisma | 10 ++ src/app/api/insights/comprehensive/route.ts | 4 +- src/app/api/insights/targets/route.ts | 4 +- .../medications/[id]/__tests__/route.test.ts | 114 +++++++++++++++++ .../[id]/intake/__tests__/route.test.ts | 120 ++++++++++++++++++ src/app/api/medications/[id]/route.ts | 47 ++++++- .../api/medications/__tests__/route.test.ts | 82 ++++++++++++ src/app/api/medications/compliance/route.ts | 5 +- src/app/api/medications/route.ts | 22 +++- src/app/medications/page.tsx | 9 +- .../medication-card-next-due.test.tsx | 72 +++++++++++ .../__tests__/medication-table.test.tsx | 37 ++++++ .../__tests__/take-all-due.test.tsx | 20 +++ .../card-parts/medication-card-body.tsx | 13 +- .../detail/MedicationDetailTabs.tsx | 66 +++++++--- .../medications/medication-card.tsx | 21 ++- .../medications/medication-detail-summary.tsx | 7 +- .../medications/medication-table.tsx | 20 ++- .../wizard/__tests__/wizard-payload.test.ts | 90 +++++++++++++ .../medications/wizard/steps/Step5Cadence.tsx | 17 ++- .../medications/wizard/steps/Step8Summary.tsx | 12 +- .../medications/wizard/wizard-payload.ts | 50 ++++++-- src/lib/ai/coach/snapshot.ts | 4 +- .../as-needed-compliance-exclusion.test.ts | 96 ++++++++++++++ src/lib/analytics/health-score-fast-path.ts | 5 +- src/lib/insights/blood-pressure-status.ts | 4 +- src/lib/insights/features.ts | 4 +- .../insights/medication-compliance-status.ts | 4 +- .../medication-reminder-check.test.ts | 96 ++++++++++++++ .../reminder/medication-reminder-check.ts | 8 +- .../inventory/__tests__/consumption.test.ts | 31 +++++ .../scheduling/__tests__/next-due.test.ts | 34 +++++ src/lib/medications/scheduling/recurrence.ts | 14 ++ src/lib/openapi/routes/medications.ts | 5 + src/lib/validations/medication.ts | 76 ++++++++++- 43 files changed, 1273 insertions(+), 70 deletions(-) create mode 100644 prisma/migrations/0153_v11611_medication_as_needed/migration.sql create mode 100644 src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts create mode 100644 src/lib/jobs/__tests__/medication-reminder-check.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9a0cd391a..c788bbfb6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index f2dd7099d..32bb23694 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." diff --git a/messages/en.json b/messages/en.json index 06cbc409d..0b0b6650e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index 03b2ec170..2c7d20c6c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." diff --git a/messages/fr.json b/messages/fr.json index 45039c071..ca8901e92 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index 0e5059568..d2eb8ab21 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/pl.json b/messages/pl.json index 7864d6bfa..167b7c4ca 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." diff --git a/prisma/migrations/0153_v11611_medication_as_needed/migration.sql b/prisma/migrations/0153_v11611_medication_as_needed/migration.sql new file mode 100644 index 000000000..ea3d38ffe --- /dev/null +++ b/prisma/migrations/0153_v11611_medication_as_needed/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02bfb1745..80ae7c70c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 = diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index acd4d896d..f0d999a04 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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: { diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index feaf66162..4b83a14b6 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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: { diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 8ca809f0a..121e368da 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -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 }; + 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 }; + 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(); + }); +}); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 4f7f52e5a..e2cb4676e 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index d8b7b4263..060779662 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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), diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 5f54b423e..af28f18f0 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -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 & { + schedules: { create: Record[] }; + } { + 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); + }); +}); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 11ad38d1e..d4d66f175 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -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. diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 219c633f4..74273246a 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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). diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 7a08976f9..ecf8c9f9b 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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() {
{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 ? (
{inactiveMeds.map((med) => - med.treatmentClass === "GLP1" ? ( + med.treatmentClass === "GLP1" && !med.asNeeded ? ( — 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(" — as-needed marker (v1.16.11, #316)", () => { + function makeAsNeededMed(overrides: Record = {}) { + 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( + {}} + 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( + {}} + 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"); + }); +}); diff --git a/src/components/medications/__tests__/medication-table.test.tsx b/src/components/medications/__tests__/medication-table.test.tsx index c65451d3d..44b42ec46 100644 --- a/src/components/medications/__tests__/medication-table.test.tsx +++ b/src/components/medications/__tests__/medication-table.test.tsx @@ -261,6 +261,43 @@ describe(" — 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( + , + 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(" — manual order + aria-sort", () => { diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index d6b79b61c..38d0a6a60 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -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. diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 1cf633ffc..3a05fb0d8 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -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 ? ( {currentCycle && }
diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index b90ee22b1..41ba790a6 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -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( - () => 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" /> - onTabChange("zeitplan")} - dataSlot="uebersicht-reminder" - /> + {/* v1.16.11 — as-needed never reminds; the row would only + mislead (the cron skips schedule-less medications). */} + {!asNeeded && ( + onTabChange("zeitplan")} + dataSlot="uebersicht-reminder" + /> + )} {inventoryItems.length > 0 && ( + {!asNeeded && ( )} + )} {/* ZEITPLAN — inline edit of the everyday levers: dose times + diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 30a7a8ec2..8bf824999 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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 ? ( + + {t("medications.asNeededMarker")} + + ) : 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 } diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index 9ceb3f407..f86b1206c 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -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 (
diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 3ab884d79..44ab97936 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -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 = ( – ); - 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 = ( + + {t("medications.asNeededMarker")} + + ); + } else if (medication.active) { if (medication.nextDueOverdue && nextAt) { nextCell = ( @@ -609,7 +625,7 @@ function MedicationTableRowItem({ {statusCell} {nextCell} - {medication.active ? complianceCell : –} + {medication.active && !medication.asNeeded ? complianceCell : –} {stockCell} {medication.active ? ( diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 481649cff..c2c8c217d 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -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); + }); +}); diff --git a/src/components/medications/wizard/steps/Step5Cadence.tsx b/src/components/medications/wizard/steps/Step5Cadence.tsx index 3fcbb7235..089f8985a 100644 --- a/src/components/medications/wizard/steps/Step5Cadence.tsx +++ b/src/components/medications/wizard/steps/Step5Cadence.tsx @@ -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", diff --git a/src/components/medications/wizard/steps/Step8Summary.tsx b/src/components/medications/wizard/steps/Step8Summary.tsx index c082b5c70..0cc776f46 100644 --- a/src/components/medications/wizard/steps/Step8Summary.tsx +++ b/src/components/medications/wizard/steps/Step8Summary.tsx @@ -145,7 +145,11 @@ export function Step8Summary({

)} - {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" && (