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:
Marc-André Bombeck committed 2026-06-12 22:56:03 +02:00
1 parent 550f314a54
commit 3943dcfb72
43 files changed
+1273 -70

No files matched your search

+40 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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 =
+3 -1
View File
@@ -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: {
+3 -1
View File
@@ -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);
});
});
+46 -1
View File
@@ -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);
});
});
+4 -1
View File
@@ -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.
+18 -4
View File
@@ -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).
+7 -2
View File
@@ -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 +
+19 -2
View File
@@ -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),
)
+3 -1
View File
@@ -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);
});
});
+4 -1
View File
@@ -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,
+3 -1
View File
@@ -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: {
+3 -1
View File
@@ -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";
+5
View File
@@ -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),
+73 -3
View File
@@ -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