From 0e34f9eddaf2596b625ca3c4a43b7b74fb72e6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 06:43:40 +0200 Subject: [PATCH 01/13] feat(medications): add per-schedule units-per-dose column Add a nullable units_per_dose to medication_schedules so a split-dose plan can consume a different unit count at different times of day (a whole tablet in the morning, a half at noon) on ONE medication instead of two. NULL inherits the medication-level units_per_dose, so every existing row keeps its current behaviour. Migration 0332 adds one nullable Decimal(10,4) column with no backfill. --- .../0332_schedule_units_per_dose/migration.sql | 6 ++++++ prisma/schema.prisma | 10 ++++++++++ 2 files changed, 16 insertions(+) create mode 100644 prisma/migrations/0332_schedule_units_per_dose/migration.sql diff --git a/prisma/migrations/0332_schedule_units_per_dose/migration.sql b/prisma/migrations/0332_schedule_units_per_dose/migration.sql new file mode 100644 index 000000000..0dabcd0ae --- /dev/null +++ b/prisma/migrations/0332_schedule_units_per_dose/migration.sql @@ -0,0 +1,6 @@ +-- #219 — per-schedule inventory units consumed per dose. +-- Additive, nullable, no backfill: every existing schedule row reads NULL and +-- the consume hook keeps inheriting the medication-level `units_per_dose`, so +-- behaviour is unchanged until a user sets a per-slot value. Same +-- Decimal(10,4) grain as `medications.units_per_dose`. +ALTER TABLE "medication_schedules" ADD COLUMN "units_per_dose" DECIMAL(10,4); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3c1597a60..6b3692997 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2738,6 +2738,16 @@ model MedicationSchedule { windowEnd String @map("window_end") // HH:mm format, e.g. "10:00" label String? // e.g. "Morning", "Evening" dose String? // Per-schedule dose override; null = use Medication.dose + /// v1.37.10 (#219) — per-schedule inventory units consumed per dose. The + /// intake consumption hook counts UNITS (tablets, ampoules, puffs); a + /// split-dose plan takes a different unit count at different times of day + /// (a whole tablet in the morning, a half at noon). NULL inherits the + /// medication-level `Medication.unitsPerDose`, so every existing row keeps + /// its current behaviour. Same Decimal(10,4) grain + curated fraction / + /// whole-number posture as the medication-level column; the consume hook + /// matches a taken intake's slot to its schedule by wall-clock time and + /// reads this first, falling back to the medication level on NULL. + unitsPerDose Decimal? @map("units_per_dose") @db.Decimal(10, 4) /// Legacy string encoding of the cadence ("null | 1,3,5 | i2;1,3,5"). /// v1.5 writes go to `rrule` / `rollingIntervalDays` and v1.5.1 flipped /// the readers to consult the new fields first. From 820dde23658a59ec1e2efece6efefe158ca75422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 06:45:12 +0200 Subject: [PATCH 02/13] feat(medications): consume per-schedule units for a taken dose The inventory consume hook counted the medication-level units_per_dose for every dose, so a split plan could not decrement a whole tablet in the morning and a half at noon. Resolve the units from the taken intake's own slot: match its scheduled wall-clock time in the user's zone to a schedule and read that schedule's units_per_dose, inheriting the medication level on NULL or no match. A medication whose schedules all leave the column NULL resolves exactly as before. The batch-import path binds each dose by its scheduled anchor the same way. --- src/lib/medications/inventory/consumption.ts | 113 ++++++++++++++++++- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts index 2d83e0082..dfb0c3c7a 100644 --- a/src/lib/medications/inventory/consumption.ts +++ b/src/lib/medications/inventory/consumption.ts @@ -36,6 +36,7 @@ import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { toJson } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { computeExpiresAt, computeInventoryState } from "./state-machine"; /** The Prisma surface the consumption hook needs. A bare transaction @@ -60,6 +61,52 @@ export interface InventoryConsumptionEntry { units: number; } +/** The per-schedule fields the units resolver needs. */ +export interface ScheduleUnitsInput { + timesOfDay: string[]; + windowStart: string; + unitsPerDose: Prisma.Decimal | null; +} + +/** + * v1.37.10 (#219) — resolve the inventory units ONE taken intake consumes. + * + * A medication owns N schedules, and each may now carry its own + * `unitsPerDose` (a whole tablet in the morning, a half at noon). An intake + * event has no schedule foreign key; it binds to its slot by `scheduledFor`. + * We match the intake's wall-clock time-of-day in the user's zone against + * each schedule's `timesOfDay` (falling back to the legacy `windowStart` + * when a schedule has no times) and read the FIRST matching schedule's + * `unitsPerDose`. A NULL per-slot value, or no slot match at all, inherits + * the medication-level `medicationUnitsPerDose` — so a medication whose + * schedules all leave the column NULL (every pre-#219 row) resolves exactly + * as it did before. + */ +export function resolveUnitsPerDose(input: { + scheduledFor: Date; + timeZone: string | null | undefined; + medicationUnitsPerDose: Prisma.Decimal; + schedules: readonly ScheduleUnitsInput[]; +}): number { + const { scheduledFor, timeZone, medicationUnitsPerDose, schedules } = input; + const wall = wallClockInTz(scheduledFor, timeZone ?? undefined); + const hhmm = `${String(wall.hour).padStart(2, "0")}:${String( + wall.minute, + ).padStart(2, "0")}`; + for (const schedule of schedules) { + if (schedule.unitsPerDose === null) continue; + const times = + schedule.timesOfDay.length > 0 + ? schedule.timesOfDay + : [schedule.windowStart]; + if (times.includes(hhmm)) { + const perSlot = Number(schedule.unitsPerDose); + if (perSlot > 0) return perSlot; + } + } + return Number(medicationUnitsPerDose); +} + /** * Run `fn` atomically. When the caller hands us the base client we * open an interactive transaction; when it hands us a transaction @@ -186,15 +233,41 @@ export async function consumeForIntake(input: { const medication = await tx.medication.findFirst({ where: { id: medicationId, userId }, - select: { unitsPerDose: true }, + select: { + unitsPerDose: true, + user: { select: { timezone: true } }, + // v1.37.10 (#219) — the per-slot units resolver needs each + // schedule's own units + its slot times to bind the taken + // intake to the right dose size. + schedules: { + select: { + timesOfDay: true, + windowStart: true, + unitsPerDose: true, + }, + }, + }, }); if (!medication) return; + // v1.37.10 (#219) — the event carries no schedule FK; bind it to its + // slot by wall-clock time and read that schedule's `unitsPerDose`, + // inheriting the medication-level column on NULL / no match. + const event = await tx.medicationIntakeEvent.findFirst({ + where: { id: eventId, userId }, + select: { scheduledFor: true }, + }); + if (!event) return; // v1.16.12 — Decimal column; a dose may consume a FRACTION of a // unit (½ / ¼ tablet for a split pill). Convert to a JS number for // the arithmetic below (unit counts stay well within double // precision) and gate at > 0, NOT at ≥ 1 — the old `Math.max(1, …)` // clamp would silently turn a half-tablet dose back into a whole one. - const unitsPerDose = Number(medication.unitsPerDose); + const unitsPerDose = resolveUnitsPerDose({ + scheduledFor: event.scheduledFor, + timeZone: medication.user.timezone, + medicationUnitsPerDose: medication.unitsPerDose, + schedules: medication.schedules, + }); if (!(unitsPerDose > 0)) return; // Candidate containers, in consumption order: the open container @@ -359,15 +432,35 @@ export async function consumeImportedIntakesBatch(input: { const medication = await tx.medication.findFirst({ where: { id: medicationId, userId }, - select: { unitsPerDose: true }, + select: { + unitsPerDose: true, + user: { select: { timezone: true } }, + schedules: { + select: { + timesOfDay: true, + windowStart: true, + unitsPerDose: true, + }, + }, + }, }); if (!medication) { throw new Error("Imported intake medication no longer exists"); } - const unitsPerDose = Number(medication.unitsPerDose); - if (!(unitsPerDose > 0)) { + if (!(Number(medication.unitsPerDose) > 0)) { throw new Error("Imported intake medication has an invalid dose size"); } + // v1.37.10 (#219) — each imported dose binds to its slot by + // `scheduledFor`, so a per-slot `unitsPerDose` decrements the right + // unit count even on the batch-import path. Load the anchors once. + const scheduledForById = new Map( + ( + await tx.medicationIntakeEvent.findMany({ + where: { id: { in: events.map(({ eventId }) => eventId) }, userId }, + select: { id: true, scheduledFor: true }, + }) + ).map((row) => [row.id, row.scheduledFor]), + ); const [inUse, active] = await Promise.all([ tx.medicationInventoryItem.findMany({ @@ -408,7 +501,15 @@ export async function consumeImportedIntakesBatch(input: { let autoOpened = 0; for (const event of events) { - let owed = unitsPerDose; + const scheduledFor = scheduledForById.get(event.eventId); + let owed = scheduledFor + ? resolveUnitsPerDose({ + scheduledFor, + timeZone: medication.user.timezone, + medicationUnitsPerDose: medication.unitsPerDose, + schedules: medication.schedules, + }) + : Number(medication.unitsPerDose); const stamp: InventoryConsumptionEntry[] = []; for (const item of items) { if (owed <= 0) break; From 88123c53da4fc08ba6779042c38a4a20d21df2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 06:48:56 +0200 Subject: [PATCH 03/13] feat(medications): accept and return per-schedule units-per-dose Add schedules[].unitsPerDose to the create/update contract with the same whole-number-or-curated-fraction validation as the medication level, built into the Prisma data field-by-field on both the create and the schedule-replace path. Unwrap the nullable Decimal to a JSON number at every medication read/write response through one shared serializer so the wire shape cannot drift, keeping NULL as NULL to mean inherit-the-medication-level. --- src/app/api/medications/[id]/route.ts | 9 ++++++++ src/app/api/medications/route.ts | 18 +++++++++++++++- src/lib/medications/list-read.ts | 3 +++ src/lib/medications/schedule-units-dto.ts | 24 ++++++++++++++++++++++ src/lib/validations/medication/schedule.ts | 15 +++++++++++++- 5 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 src/lib/medications/schedule-units-dto.ts diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 01ac66aeb..6dd257d6e 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -33,6 +33,7 @@ import { recomputeMedicationComplianceForDay, } from "@/lib/rollups/medication-compliance-rollups"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { NextRequest } from "next/server"; @@ -179,6 +180,7 @@ export const GET = apiHandler( return apiSuccess({ ...medication, unitsPerDose: Number(medication.unitsPerDose), + schedules: serializeScheduleUnitsPerDose(medication.schedules), category, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, @@ -404,6 +406,12 @@ export const PUT = apiHandler( windowEnd: window.windowEnd, label: s.label ?? null, dose: s.dose ?? null, + // #219 — per-schedule inventory units, field-by-field. A schedule + // replace re-creates the rows, so absent leaves the column NULL + // and the consume hook inherits the medication-level value. + ...(s.unitsPerDose !== undefined && { + unitsPerDose: s.unitsPerDose, + }), daysOfWeek: serializedDaysOfWeek, // v1.5 first-class times-of-day. timesOfDay: effectiveTimesOfDay, @@ -828,6 +836,7 @@ export const PUT = apiHandler( return apiSuccess({ ...medication, unitsPerDose: Number(medication.unitsPerDose), + schedules: serializeScheduleUnitsPerDose(medication.schedules), category: normalizedCategory, }); }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index b4a82bafc..4a90d9936 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -21,6 +22,7 @@ import { import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { readMedicationsListCached } from "@/lib/medications/list-read"; +import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; import { NextRequest } from "next/server"; // v1.32.25 — blast-radius cap on externally-mirrored medications per user. @@ -66,7 +68,11 @@ export const GET = apiHandler(async () => { * nothing was written. */ async function respondWithExistingMirror( - medication: Record & { id: string; unitsPerDose: unknown }, + medication: Record & { + id: string; + unitsPerDose: unknown; + schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>; + }, ): Promise { let category = "OTHER"; try { @@ -88,6 +94,8 @@ async function respondWithExistingMirror( return apiSuccess({ ...medication, unitsPerDose: Number(medication.unitsPerDose), + // #219 — Decimal → number for the per-schedule column too. + schedules: serializeScheduleUnitsPerDose(medication.schedules), category, }); } @@ -311,6 +319,12 @@ export const POST = apiHandler(async (request: NextRequest) => { windowEnd: s.windowEnd, label: s.label ?? null, dose: s.dose ?? null, + // #219 — per-schedule inventory units. Field-by-field (no mass + // assignment); absent leaves the column NULL so the consume + // hook inherits the medication-level units_per_dose. + ...(s.unitsPerDose !== undefined && { + unitsPerDose: s.unitsPerDose, + }), daysOfWeek: serializeScheduleRecurrence({ daysOfWeek: s.daysOfWeek ?? [], intervalWeeks: s.intervalWeeks ?? 1, @@ -398,6 +412,8 @@ export const POST = apiHandler(async (request: NextRequest) => { { ...medication, unitsPerDose: Number(medication.unitsPerDose), + // #219 — Decimal → number for the per-schedule column too. + schedules: serializeScheduleUnitsPerDose(medication.schedules), category: normalizedCategory, }, 201, diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 2e22cba9d..cbc190833 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -15,6 +15,7 @@ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { getMedicationCategories } from "@/lib/medication-category"; +import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, @@ -255,6 +256,8 @@ export async function buildMedicationsList( // v1.16.12 — Decimal → number so the wire stays a JSON number, not // the string Prisma would otherwise serialise a Decimal to. unitsPerDose: Number(m.unitsPerDose), + // #219 — same Decimal → number unwrap for the per-schedule column. + schedules: serializeScheduleUnitsPerDose(m.schedules), category: categoryMap[m.id] ?? "OTHER", // v1.32.25 — provenance echo. Surfacing the mirror source lets the // web UI and an operator tell an externally-mirrored row (today only diff --git a/src/lib/medications/schedule-units-dto.ts b/src/lib/medications/schedule-units-dto.ts new file mode 100644 index 000000000..6aa8b694c --- /dev/null +++ b/src/lib/medications/schedule-units-dto.ts @@ -0,0 +1,24 @@ +/** + * #219 — per-schedule `unitsPerDose` wire serialisation. + * + * The column is a nullable `Decimal(10,4)`; Prisma serialises a Decimal to a + * JSON STRING, which the client would then have to coerce. The medication-level + * `unitsPerDose` is already unwrapped to a JSON number at every response point + * (`Number(medication.unitsPerDose)`), so mirror that for the per-schedule + * column: convert to a number, keep NULL as NULL (NULL means "inherit the + * medication level"). One helper so the five medication read/write responses + * cannot drift on the shape. + */ +import type { Prisma } from "@/generated/prisma/client"; + +type ScheduleWithUnits = { unitsPerDose: Prisma.Decimal | null }; + +export function serializeScheduleUnitsPerDose( + schedules: readonly T[], +): Array & { unitsPerDose: number | null }> { + return schedules.map((schedule) => ({ + ...schedule, + unitsPerDose: + schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose), + })); +} diff --git a/src/lib/validations/medication/schedule.ts b/src/lib/validations/medication/schedule.ts index 9ee4ef4b4..5b464a3a2 100644 --- a/src/lib/validations/medication/schedule.ts +++ b/src/lib/validations/medication/schedule.ts @@ -1,7 +1,13 @@ import { z } from "zod/v4"; import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence"; -import { RRULE_PROPS, doseWindowEntrySchema, timeRegex } from "./base"; +import { + RRULE_PROPS, + UNITS_PER_DOSE_MESSAGE, + doseWindowEntrySchema, + isSupportedUnitsPerDose, + timeRegex, +} from "./base"; export const scheduleSchema = z .object({ @@ -29,6 +35,13 @@ export const scheduleSchema = z .describe( "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", ), + unitsPerDose: z + .number() + .refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE }) + .optional() + .describe( + "Per-schedule inventory units consumed per dose (#219). A whole number 1-100 or a supported fraction (¼ / ⅓ / ½ / ⅔ / ¾) for a split pill. Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets one medication decrement a different tablet count at different times of day (a whole tablet in the morning, a half at noon).", + ), daysOfWeek: z .array(z.number().int().min(0).max(6)) .optional() From 64093d2124e75c85866641bf83745bee6066ccde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 06:59:46 +0200 Subject: [PATCH 04/13] feat(medications): per-slot dose and units in the create wizard Give each parallel schedule its own optional dose and units-per-dose input on the times step, so a split plan (a whole tablet in the morning, a half at noon) is ONE medication with three schedules instead of two drugs that double-count. Leaving both blank inherits the medication-level values, so a single-dose plan is unchanged. An as-needed slot that sits alongside scheduled ones now rides the same medication as a PRN schedule rather than flipping the whole-medication as-needed flag. New keys land in all six locales. --- messages/de.json | 6 ++ messages/en.json | 6 ++ messages/es.json | 6 ++ messages/fr.json | 6 ++ messages/it.json | 6 ++ messages/pl.json | 6 ++ .../medications/wizard/steps/step7-times.tsx | 71 ++++++++++++++ .../medications/wizard/wizard-payload.ts | 97 ++++++++++++++++++- 8 files changed, 201 insertions(+), 3 deletions(-) diff --git a/messages/de.json b/messages/de.json index d768ca862..9e517ee7c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1476,6 +1476,11 @@ "evening": "Abends", "night": "Nachts" }, + "overrideHint": "Optional — für diese Uhrzeit eine abweichende Dosis oder Stückzahl festlegen. Leer lassen, um den Standard des Medikaments zu verwenden.", + "doseOverrideLabel": "Dosis für diese Uhrzeit (optional)", + "doseOverridePlaceholder": "z. B. eine halbe Tablette", + "unitsOverrideLabel": "Verbrauchte Einheiten zu dieser Uhrzeit", + "unitsOverrideInherit": "Standard", "short": "Zeiten" }, "step8": { @@ -2061,6 +2066,7 @@ "logInjectionSiteConfirm": "Stelle speichern", "logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.", "logInjectionSiteSaveFailed": "Injektionsstelle konnte nicht gespeichert werden. Bitte versuche es erneut.", + "perSlotUnits": "{units} pro Dosis", "trackInjectionSitesToggle": "Injektionsstellen erfassen", "trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.", "allowedSitesLabel": "Erlaubte Stellen", diff --git a/messages/en.json b/messages/en.json index 856b6bd93..9d5cbbf28 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1476,6 +1476,11 @@ "evening": "Evening", "night": "Night" }, + "overrideHint": "Optional — set a different dose or unit count just for this time of day. Leave blank to use the medication's default.", + "doseOverrideLabel": "Dose for this time (optional)", + "doseOverridePlaceholder": "e.g. half a tablet", + "unitsOverrideLabel": "Units consumed at this time", + "unitsOverrideInherit": "Default", "short": "Times" }, "step8": { @@ -2061,6 +2066,7 @@ "logInjectionSiteConfirm": "Save site", "logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.", "logInjectionSiteSaveFailed": "Could not save the injection site. Please try again.", + "perSlotUnits": "{units} per dose", "trackInjectionSitesToggle": "Track injection sites", "trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.", "allowedSitesLabel": "Allowed sites", diff --git a/messages/es.json b/messages/es.json index 1848d62e2..c82713070 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1476,6 +1476,11 @@ "evening": "Tarde", "night": "Noche" }, + "overrideHint": "Opcional: define una dosis o un número de unidades distinto solo para esta hora. Déjalo en blanco para usar el valor predeterminado del medicamento.", + "doseOverrideLabel": "Dosis para esta hora (opcional)", + "doseOverridePlaceholder": "p. ej. media pastilla", + "unitsOverrideLabel": "Unidades consumidas a esta hora", + "unitsOverrideInherit": "Predeterminado", "short": "Horas" }, "step8": { @@ -2061,6 +2066,7 @@ "logInjectionSiteConfirm": "Guardar sitio", "logInjectionSiteNoneAvailable": "No hay ningún sitio de inyección disponible: todos los sitios están excluidos en tu configuración.", "logInjectionSiteSaveFailed": "No se pudo guardar el sitio de inyección. Inténtalo de nuevo.", + "perSlotUnits": "{units} por dosis", "trackInjectionSitesToggle": "Registrar sitios de inyección", "trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.", "allowedSitesLabel": "Sitios permitidos", diff --git a/messages/fr.json b/messages/fr.json index b11b10ec5..ccd97dac0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1476,6 +1476,11 @@ "evening": "Soir", "night": "Nuit" }, + "overrideHint": "Facultatif — définissez une dose ou un nombre d'unités différent pour cet horaire uniquement. Laissez vide pour utiliser la valeur par défaut du médicament.", + "doseOverrideLabel": "Dose pour cet horaire (facultatif)", + "doseOverridePlaceholder": "ex. un demi-comprimé", + "unitsOverrideLabel": "Unités consommées à cet horaire", + "unitsOverrideInherit": "Par défaut", "short": "Heures" }, "step8": { @@ -2061,6 +2066,7 @@ "logInjectionSiteConfirm": "Enregistrer le site", "logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.", "logInjectionSiteSaveFailed": "Impossible d’enregistrer le site d’injection. Veuillez réessayer.", + "perSlotUnits": "{units} par dose", "trackInjectionSitesToggle": "Suivre les sites d'injection", "trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.", "allowedSitesLabel": "Sites autorisés", diff --git a/messages/it.json b/messages/it.json index 25df4a7ab..b0b95afe3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1476,6 +1476,11 @@ "evening": "Sera", "night": "Notte" }, + "overrideHint": "Facoltativo: imposta una dose o un numero di unità diverso solo per questo orario. Lascia vuoto per usare il valore predefinito del farmaco.", + "doseOverrideLabel": "Dose per questo orario (facoltativo)", + "doseOverridePlaceholder": "es. mezza compressa", + "unitsOverrideLabel": "Unità consumate a questo orario", + "unitsOverrideInherit": "Predefinito", "short": "Orari" }, "step8": { @@ -2061,6 +2066,7 @@ "logInjectionSiteConfirm": "Salva il sito", "logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.", "logInjectionSiteSaveFailed": "Impossibile salvare il sito di iniezione. Riprova.", + "perSlotUnits": "{units} per dose", "trackInjectionSitesToggle": "Registra i siti di iniezione", "trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.", "allowedSitesLabel": "Siti consentiti", diff --git a/messages/pl.json b/messages/pl.json index d27c7ecd8..cf7ab2e7d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1476,6 +1476,11 @@ "evening": "Wieczór", "night": "Noc" }, + "overrideHint": "Opcjonalnie — ustaw inną dawkę lub liczbę jednostek tylko dla tej pory. Pozostaw puste, aby użyć wartości domyślnej leku.", + "doseOverrideLabel": "Dawka dla tej pory (opcjonalnie)", + "doseOverridePlaceholder": "np. pół tabletki", + "unitsOverrideLabel": "Jednostki zużyte o tej porze", + "unitsOverrideInherit": "Domyślnie", "short": "Godziny" }, "step8": { @@ -2061,6 +2066,7 @@ "logInjectionSiteConfirm": "Zapisz miejsce", "logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.", "logInjectionSiteSaveFailed": "Nie udało się zapisać miejsca iniekcji. Spróbuj ponownie.", + "perSlotUnits": "{units} na dawkę", "trackInjectionSitesToggle": "Rejestruj miejsca iniekcji", "trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.", "allowedSitesLabel": "Dozwolone miejsca", diff --git a/src/components/medications/wizard/steps/step7-times.tsx b/src/components/medications/wizard/steps/step7-times.tsx index 696781be9..14109c771 100644 --- a/src/components/medications/wizard/steps/step7-times.tsx +++ b/src/components/medications/wizard/steps/step7-times.tsx @@ -5,6 +5,10 @@ import { Moon, Sun, Sunrise, Sunset } from "lucide-react"; import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips"; import { DoseWindowEditor } from "@/components/medications/scheduling/dose-window-editor"; import type { DoseWindowScale } from "@/components/medications/scheduling/dose-window"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { UNITS_PER_DOSE_OPTIONS } from "@/components/medications/units-per-dose"; import { useTranslations } from "@/lib/i18n/context"; import type { StepProps } from "./step1-name"; @@ -105,6 +109,73 @@ export function Step7Times({ payload, applyPartial }: StepProps) { onChange={(doseWindows) => applyPartial({ doseWindows })} scale={scaleForCadence(payload)} /> + + {/* #219 — per-schedule dose + units override. Leaving both blank keeps + the medication-level values, so a single-dose plan is unchanged; a + split plan (a whole tablet in the morning, a half at noon) sets a + different value on each schedule of ONE medication. */} +
+

+ {t("medications.wizard.steps.step7.overrideHint")} +

+
+ + applyPartial({ scheduleDose: e.target.value })} + placeholder={t("medications.wizard.steps.step7.doseOverridePlaceholder")} + maxLength={50} + autoComplete="off" + /> +
+
+ +
+ + {UNITS_PER_DOSE_OPTIONS.map((opt) => { + const selected = payload.scheduleUnitsPerDose === opt.raw; + return ( + + ); + })} +
+
+
); } diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index df549d54e..7413270a9 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -183,6 +183,18 @@ export interface ScheduleDraft { cadence: CadenceValue; subControls: CadenceSubControls; timesOfDay: string[]; + /** + * #219 — per-schedule dose override (empty = inherit the medication + * `dose`). Lets a "1-0.5-0.5" split ride ONE medication with three + * schedules at different doses instead of three separate drugs. + */ + dose?: string; + /** + * #219 — per-schedule units-consumed-per-dose (empty = inherit the + * medication `unitsPerDose`). Decrements the right tablet count for a + * morning-whole / noon-half plan. + */ + unitsPerDose?: string; /** * v1.15.18 — per-dose explicit on-time windows. One entry per dose * time the user widened beyond the symmetric ±1h default; a time with @@ -247,6 +259,15 @@ export interface WizardPayload { subControls: CadenceSubControls; /** Mirrors `schedules[activeScheduleIndex].timesOfDay`. */ timesOfDay: string[]; + /** + * #219 — mirrors `schedules[activeScheduleIndex].dose`. The per-slot + * dose + units inputs on Step 7 read + write these flat slots; the + * active-draft helpers keep them in sync with the schedule list. Empty + * string = inherit the medication-level value. + */ + scheduleDose: string; + /** #219 — mirrors `schedules[activeScheduleIndex].unitsPerDose`. */ + scheduleUnitsPerDose: string; /** * v1.15.18 — mirrors `schedules[activeScheduleIndex].doseWindows`. The * per-dose window editor in Step 7 reads + writes this flat slot; the @@ -298,6 +319,8 @@ export function emptyWizardPayload(): WizardPayload { cadence: draft.cadence, subControls: draft.subControls, timesOfDay: draft.timesOfDay, + scheduleDose: draft.dose ?? "", + scheduleUnitsPerDose: draft.unitsPerDose ?? "", doseWindows: draft.doseWindows ?? [], startsOn: todayUtc(), endsOn: null, @@ -326,6 +349,14 @@ export function commitActiveDraft(payload: WizardPayload): WizardPayload { cadence: payload.cadence, subControls: payload.subControls, timesOfDay: payload.timesOfDay, + // #219 — project the per-slot dose / units flat mirror onto the draft. + // Empty string collapses to undefined so the encode path omits the + // field and the schedule inherits the medication-level value. + dose: payload.scheduleDose.trim() === "" ? undefined : payload.scheduleDose, + unitsPerDose: + payload.scheduleUnitsPerDose.trim() === "" + ? undefined + : payload.scheduleUnitsPerDose, doseWindows: payload.doseWindows, }; const schedules = payload.schedules.slice(); @@ -351,6 +382,8 @@ export function setActiveSchedule( cadence: draft.cadence, subControls: draft.subControls, timesOfDay: draft.timesOfDay, + scheduleDose: draft.dose ?? "", + scheduleUnitsPerDose: draft.unitsPerDose ?? "", doseWindows: draft.doseWindows ?? [], }; } @@ -372,6 +405,8 @@ export function addSchedule(payload: WizardPayload): WizardPayload { cadence: draft.cadence, subControls: draft.subControls, timesOfDay: draft.timesOfDay, + scheduleDose: draft.dose ?? "", + scheduleUnitsPerDose: draft.unitsPerDose ?? "", }; } @@ -397,6 +432,8 @@ export function removeSchedule( cadence: draft.cadence, subControls: draft.subControls, timesOfDay: draft.timesOfDay, + scheduleDose: draft.dose ?? "", + scheduleUnitsPerDose: draft.unitsPerDose ?? "", }; } @@ -578,6 +615,17 @@ export interface CreateMedicationBody { rollingIntervalDays?: number; daysOfWeek?: number[]; intervalWeeks?: number; + /** #219 — per-schedule dose override. Omitted = inherit `dose`. */ + dose?: string; + /** #219 — per-schedule units per dose. Omitted = inherit `unitsPerDose`. */ + unitsPerDose?: number; + /** + * #219 — schedule type. PRN routes a per-draft as-needed slot onto the + * SAME medication (an as-needed extra dose alongside scheduled ones) + * instead of flipping the whole-medication `asNeeded` flag. Omitted = + * SCHEDULED. + */ + scheduleType?: "SCHEDULED" | "PRN"; /** v1.15.18 — per-dose explicit on-time windows. Omitted when none. */ doseWindows?: DoseWindowEntry[]; }>; @@ -626,6 +674,11 @@ export function encodeScheduleDraft( oneShotMedication: boolean, ): CreateMedicationBody["schedules"][number] { const isOneShot = oneShotMedication || draft.mode === "oneShot"; + // #219 — a per-draft as-needed slot rides the SAME medication as a PRN + // schedule (never due / reminded / scored), so an as-needed extra dose + // sits alongside the scheduled ones instead of splitting into a second + // drug. A PRN slot carries no cadence. + const isPrn = draft.mode === "asNeeded"; const times = sortTimes( isOneShot ? draft.timesOfDay.slice(0, 1) : draft.timesOfDay, ); @@ -638,6 +691,14 @@ export function encodeScheduleDraft( timesOfDay: times, }; if (draft.id) out.id = draft.id; + // #219 — per-slot dose + units overrides. Empty / whitespace collapses to + // omitted so the schedule inherits the medication-level value. + if (draft.dose && draft.dose.trim() !== "") out.dose = draft.dose.trim(); + if (draft.unitsPerDose && draft.unitsPerDose.trim() !== "") { + const parsed = Number.parseFloat(draft.unitsPerDose); + if (Number.isFinite(parsed) && parsed > 0) out.unitsPerDose = parsed; + } + if (isPrn) out.scheduleType = "PRN"; // v1.15.18 — emit only the explicit windows that still name a live dose // time and actually differ from the default ±1h band (a point-equivalent // window leaves the column on the default derivation). @@ -648,7 +709,10 @@ export function encodeScheduleDraft( ); if (windows.length > 0) out.doseWindows = windows; } - if (!isOneShot) { + // #219 — a PRN slot carries no cadence (the schedule validator rejects a + // PRN with rrule / rollingIntervalDays), so suppress the recurrence block + // exactly as the one-shot path does. + if (!isOneShot && !isPrn) { if (draft.cadence.rrule !== null) { out.rrule = draft.cadence.rrule; } else if (draft.cadence.rollingIntervalDays !== null) { @@ -695,7 +759,15 @@ export function buildCreateBody( // 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 isAsNeeded = committed.mode === "asNeeded"; + // #219 — an as-needed slot only flips the WHOLE-medication asNeeded flag + // (zero schedules, never due / scored) when EVERY draft is as-needed. When + // an as-needed draft sits alongside scheduled ones it is emitted as a PRN + // schedule on the same medication (see `encodeScheduleDraft`), so a + // scheduled plan can carry an as-needed extra dose without a second drug. + const hasScheduledDraft = committed.schedules.some( + (d) => d.mode !== "asNeeded", + ); + const isAsNeeded = !isOneShot && !hasScheduledDraft; const draftsToEmit = isAsNeeded ? [] : isOneShot @@ -972,11 +1044,15 @@ export interface MedicationPayload { windowEnd: string; label?: string | null; dose?: string | null; + /** #219 — per-schedule units per dose (edit-hydrate). NULL = inherit. */ + unitsPerDose?: number | null; daysOfWeek?: number[]; intervalWeeks?: number; timesOfDay?: string[]; rrule?: string | null; rollingIntervalDays?: number | null; + /** #219 — per-schedule as-needed slot (PRN) round-trips as a mode. */ + scheduleType?: string | null; /** v1.15.18 — per-dose explicit on-time windows (edit-hydrate). */ doseWindows?: DoseWindowEntry[] | null; }>; @@ -1004,6 +1080,9 @@ interface MedicationScheduleSnapshot { id?: string; windowStart: string; windowEnd: string; + dose?: string | null; + unitsPerDose?: number | null; + scheduleType?: string | null; daysOfWeek?: number[]; intervalWeeks?: number; timesOfDay?: string[]; @@ -1023,13 +1102,23 @@ function hydrateScheduleDraft( : schedule.windowStart ? [schedule.windowStart] : ["08:00"]; + // #219 — a PRN schedule round-trips as an as-needed draft; every other + // type hydrates as recurring (one-shot is driven by the medication flag). + const isPrn = !oneShot && schedule.scheduleType === "PRN"; const draft: ScheduleDraft = { - mode: oneShot ? "oneShot" : "recurring", + mode: oneShot ? "oneShot" : isPrn ? "asNeeded" : "recurring", cadence: cadence.value, subControls: cadence.subControls, timesOfDay: times, }; if (schedule.id) draft.id = schedule.id; + // #219 — round-trip the per-slot dose + units so an edit keeps the split. + if (typeof schedule.dose === "string" && schedule.dose.trim() !== "") { + draft.dose = schedule.dose; + } + if (typeof schedule.unitsPerDose === "number" && schedule.unitsPerDose > 0) { + draft.unitsPerDose = String(schedule.unitsPerDose); + } // v1.15.18 — round-trip the persisted per-dose windows so an edit keeps // the user's explicit ranges instead of resetting them to the default. if (schedule.doseWindows && schedule.doseWindows.length > 0) { @@ -1089,6 +1178,8 @@ export function hydrateWizardPayload( cadence: first.cadence, subControls: first.subControls, timesOfDay: first.timesOfDay, + scheduleDose: first.dose ?? "", + scheduleUnitsPerDose: first.unitsPerDose ?? "", doseWindows: first.doseWindows ?? [], startsOn: initial.startsOn ?? base.startsOn, endsOn: initial.endsOn, From 80958ab30765608088fb16d38048590c94dfe4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 06:59:57 +0200 Subject: [PATCH 05/13] feat(medications): show per-slot units on both med cards Surface the per-schedule units-per-dose beside the per-slot dose the card already prints, identically on the standard and the GLP-1 card, so a split plan reads at a glance which time takes a half. Muted addendum, no width movement. --- src/components/medications/glp1-medication-card.tsx | 13 +++++++++++++ src/components/medications/medication-card.tsx | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 002b04117..3f270f4f1 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -23,6 +23,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { formatDateTime, formatTime } from "@/lib/format"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; +import { formatUnitsPerDose } from "@/components/medications/units-per-dose"; import { getDayOfWeekInTz } from "@/lib/tz/local-day"; import { type InjectionSiteKey } from "@/lib/medications/injection-sites"; import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog"; @@ -72,6 +73,8 @@ interface ScheduleLite { /** Cadence fields the supply-runway estimate reads (v1.16.11). */ rrule?: string | null; rollingIntervalDays?: number | null; + /** #219 — per-schedule units consumed per dose. NULL inherits the med level. */ + unitsPerDose?: number | null; } interface DoseChangeLite { @@ -483,6 +486,16 @@ export function Glp1MedicationCard({ — {formatDose(schedule.dose, t)} )} + {typeof schedule?.unitsPerDose === "number" && + schedule.unitsPerDose > 0 && ( + + {" "} + ·{" "} + {t("medications.perSlotUnits", { + units: formatUnitsPerDose(schedule.unitsPerDose), + })} + + )} ) ) : null; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 6a43f03cb..afb30fd04 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -13,6 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format"; import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; +import { formatUnitsPerDose } from "@/components/medications/units-per-dose"; import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; @@ -52,6 +53,8 @@ interface Schedule { /** Cadence fields the supply-runway estimate reads (v1.16.11). */ rrule?: string | null; rollingIntervalDays?: number | null; + /** #219 — per-schedule units consumed per dose. NULL inherits the med level. */ + unitsPerDose?: number | null; } interface Medication { @@ -448,6 +451,15 @@ export function MedicationCard({ — {formatDose(s.dose, t)} )} + {typeof s.unitsPerDose === "number" && s.unitsPerDose > 0 && ( + + {" "} + ·{" "} + {t("medications.perSlotUnits", { + units: formatUnitsPerDose(s.unitsPerDose), + })} + + )} ); })() From 08a1dbccacb6863b5a6140df7bad072fb1a9cfd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 06:59:57 +0200 Subject: [PATCH 06/13] feat(backup): carry per-schedule units-per-dose through backup and restore The schedule exporter enumerates columns, so the new per-schedule units-per-dose needs adding by hand: export it as a Decimal string in the disaster-recovery payload, accept it in the wire schema, and write it back on restore (NULL stays NULL to inherit). Extend the round-trip test to prove a half-tablet slot survives export and re-import. --- src/app/api/admin/backups/[id]/restore/route.ts | 3 +++ src/lib/export/__tests__/full-backup-payload.test.ts | 4 ++++ src/lib/export/full-backup-payload.ts | 4 ++++ src/lib/validations/backup.ts | 3 +++ 4 files changed, 14 insertions(+) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index d7fac6681..52dc674fa 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -581,6 +581,9 @@ const handler = apiHandler( windowEnd: s.windowEnd, label: s.label ?? null, dose: s.dose ?? null, + // #219 — per-schedule units per dose. Prisma coerces the + // Decimal string; NULL / absent stays NULL (inherit). + unitsPerDose: s.unitsPerDose ?? null, daysOfWeek: s.daysOfWeek ?? null, timesOfDay: s.timesOfDay ?? [], reminderGraceMinutes: s.reminderGraceMinutes ?? null, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index c7aeccfbd..1bfe3d447 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -131,6 +131,8 @@ function makePrisma() { scheduleType: "CYCLIC", cyclicOnWeeks: 3, cyclicOffWeeks: 1, + // #219 — per-schedule units per dose (a half tablet at this slot). + unitsPerDose: { toString: () => "0.5000" }, doseWindows: [ { timeOfDay: "08:00", start: "07:30", end: "09:00" }, ], @@ -502,6 +504,8 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { scheduleType: "CYCLIC", cyclicOnWeeks: 3, cyclicOffWeeks: 1, + // #219 — Decimal → string, round-tripped through the DR payload. + unitsPerDose: "0.5000", }), ], }), diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 05a9537a7..22b31a229 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -507,6 +507,10 @@ export async function buildFullBackupPayload( cyclicOnWeeks: s.cyclicOnWeeks, cyclicOffWeeks: s.cyclicOffWeeks, doseWindows: s.doseWindows, + // #219 — per-schedule units per dose. Decimal → string like the + // medication-level column; NULL (inherit) stays NULL. + unitsPerDose: + s.unitsPerDose == null ? null : s.unitsPerDose.toString(), } : {}), windowStart: s.windowStart, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index e22f68524..b9a84977a 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -127,6 +127,9 @@ const medicationScheduleSchema = z windowEnd: z.string().min(1), label: z.string().nullable().optional(), dose: z.string().nullable().optional(), + // #219 — per-schedule units per dose. Serialised as a Decimal string in a + // DR file (or a number in a hand-authored one); NULL means inherit. + unitsPerDose: z.union([z.string(), z.number()]).nullable().optional(), daysOfWeek: z.string().nullable().optional(), timesOfDay: z.array(z.string()).optional(), reminderGraceMinutes: z.number().int().nullable().optional(), From 3d6770ddb6d46bdfe20bcbfbf6061a74abcd35e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 07:00:17 +0200 Subject: [PATCH 07/13] chore(api): regenerate OpenAPI for schedules[].unitsPerDose --- docs/api/openapi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a6047c756..ebcd2131f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15349,6 +15349,12 @@ components: description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`. type: string maxLength: 50 + unitsPerDose: + description: Per-schedule inventory units consumed per dose (#219). A whole number 1-100 or a supported fraction (¼ / ⅓ + / ½ / ⅔ / ¾) for a split pill. Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets + one medication decrement a different tablet count at different times of day (a whole tablet in the morning, + a half at noon). + type: number daysOfWeek: description: Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field is preserved for pre-v1.5 rows and is the input the route serialises into the persisted `days_of_week` From 33769de3727abadf46ad478e392be6c7a2ebdfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 07:03:38 +0200 Subject: [PATCH 08/13] test(medications): cover per-slot units consumption and wizard round-trip Integration: a medication with a morning slot at 1 unit and a noon slot at 0.5 draws the right count for each taken dose against a real Postgres. Unit: a per-slot dose + units set in the wizard survives create and round-trips on edit-hydrate. --- .../wizard/__tests__/wizard-payload.test.ts | 55 ++++++ .../medication-per-slot-consumption.test.ts | 156 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 tests/integration/medication-per-slot-consumption.test.ts diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index e59142d4a..6c6897a44 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -426,6 +426,61 @@ describe("buildCreateBody", () => { expect(hydratedDefault.unitsPerDose).toBe("1"); }); + it("carries a per-slot dose + units onto the body and round-trips them on edit-hydrate (#219)", () => { + // A split plan: this schedule takes half a tablet, overriding the + // medication-level dose + units. The flat mirror is what Step 7 writes. + const p: WizardPayload = { + ...withCadence("daily"), + treatmentRow: "bloodPressure", + timesOfDay: ["12:00"], + startsOn: new Date(Date.UTC(2026, 4, 28)), + endsOn: null, + scheduleDose: "0.5 tablet", + scheduleUnitsPerDose: "0.5", + }; + const body = buildCreateBody(p); + // This is the assertion that goes RED when the encode path stops + // emitting the per-slot fields on save. + expect(body.schedules[0].dose).toBe("0.5 tablet"); + expect(body.schedules[0].unitsPerDose).toBe(0.5); + + // A blank override omits both, so the schedule inherits the med level. + const inherit = buildCreateBody({ + ...p, + scheduleDose: "", + scheduleUnitsPerDose: "", + }); + expect(inherit.schedules[0].dose).toBeUndefined(); + expect(inherit.schedules[0].unitsPerDose).toBeUndefined(); + + // Edit-hydrate: a persisted schedule carrying the split round-trips back + // onto the draft (and the flat mirror) so an edit keeps it. + const hydrated = hydrateWizardPayload({ + id: "m1", + name: "Foo", + dose: "1 tablet", + category: "BLOOD_PRESSURE", + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + windowStart: "12:00", + windowEnd: "13:00", + timesOfDay: ["12:00"], + rrule: "FREQ=DAILY", + dose: "0.5 tablet", + unitsPerDose: 0.5, + }, + ], + }); + expect(hydrated.scheduleDose).toBe("0.5 tablet"); + expect(hydrated.scheduleUnitsPerDose).toBe("0.5"); + expect(hydrated.schedules[0].dose).toBe("0.5 tablet"); + expect(hydrated.schedules[0].unitsPerDose).toBe("0.5"); + }); + it("emits DIABETES category for the diabetes row", () => { const p: WizardPayload = { ...withCadence("daily"), diff --git a/tests/integration/medication-per-slot-consumption.test.ts b/tests/integration/medication-per-slot-consumption.test.ts new file mode 100644 index 000000000..597b91aa4 --- /dev/null +++ b/tests/integration/medication-per-slot-consumption.test.ts @@ -0,0 +1,156 @@ +/** + * #219 — per-schedule units-per-dose consumption. + * + * A medication owns two schedules at different times of day, each with its own + * `unitsPerDose`: a whole tablet in the morning, a half at noon. The intake + * consume hook must decrement the SLOT's unit count, not the medication-level + * one, so a noon half-dose draws 0.5 tablets while a morning dose draws 1. + * + * A unit test with a mocked client cannot prove this: the hook resolves the + * slot by binding the intake's `scheduledFor` wall-clock time (in the user's + * zone) to a schedule, then reads that schedule's Decimal column. This drives + * the real consume hook against a testcontainers Postgres so the Decimal, the + * timezone match, and the inventory decrement are all real. + * + * Requires Docker / OrbStack; runs under `pnpm test:integration`. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { consumeForIntake } from "@/lib/medications/inventory/consumption"; +import type { PrismaClient } from "@/generated/prisma/client"; + +const USER_ID = "per-slot-units-user"; + +/** Sum of the units the intake event's consumption stamp recorded. */ +async function consumedUnits( + prisma: PrismaClient, + eventId: string, +): Promise { + const row = await prisma.medicationIntakeEvent.findUniqueOrThrow({ + where: { id: eventId }, + select: { inventoryConsumption: true }, + }); + const stamp = row.inventoryConsumption; + if (!Array.isArray(stamp)) return 0; + return stamp.reduce( + (sum, entry) => + sum + + (entry && typeof (entry as { units?: unknown }).units === "number" + ? (entry as { units: number }).units + : 0), + 0, + ); +} + +async function takeDose( + prisma: PrismaClient, + medicationId: string, + scheduledFor: Date, +): Promise { + const event = await prisma.medicationIntakeEvent.create({ + data: { + userId: USER_ID, + medicationId, + scheduledFor, + takenAt: scheduledFor, + }, + select: { id: true }, + }); + await consumeForIntake({ + client: prisma, + userId: USER_ID, + medicationId, + eventId: event.id, + intakeAt: scheduledFor, + }); + return event.id; +} + +describe("#219 per-schedule units-per-dose consumption — integration", () => { + let medicationId: string; + + beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + // Zone fixed to UTC so a `scheduledFor` at 08:00Z / 12:00Z reads back as + // the "08:00" / "12:00" wall clock the schedules name. + await prisma.user.create({ + data: { + id: USER_ID, + username: "per-slot-units", + email: "per-slot-units@example.test", + timezone: "UTC", + }, + }); + const med = await prisma.medication.create({ + data: { + userId: USER_ID, + name: "Split tablet", + dose: "10mg", + deliveryForm: "ORAL", + // Medication-level default is a WHOLE unit; the noon slot overrides it. + unitsPerDose: 1, + schedules: { + create: [ + { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + unitsPerDose: 1, + }, + { + windowStart: "12:00", + windowEnd: "13:00", + timesOfDay: ["12:00"], + rrule: "FREQ=DAILY", + unitsPerDose: 0.5, + }, + ], + }, + }, + select: { id: true }, + }); + medicationId = med.id; + // One open container with ample stock so neither dose floors at zero. + await prisma.medicationInventoryItem.create({ + data: { + userId: USER_ID, + medicationId, + state: "IN_USE", + containerType: "BOTTLE", + unitsTotal: 100, + unitsRemaining: 100, + firstUseAt: new Date("2026-08-01T00:00:00.000Z"), + }, + }); + }); + + it("draws 1 unit for the morning slot and 0.5 for the noon slot", async () => { + const prisma = getPrismaClient(); + + const morning = await takeDose( + prisma, + medicationId, + new Date("2026-08-10T08:00:00.000Z"), + ); + const noon = await takeDose( + prisma, + medicationId, + new Date("2026-08-10T12:00:00.000Z"), + ); + + // The morning slot (unitsPerDose 1) draws a whole unit; the noon slot + // (unitsPerDose 0.5) draws a half. This is the assertion that goes RED + // when the hook reads only the medication-level column. + expect(await consumedUnits(prisma, morning)).toBe(1); + expect(await consumedUnits(prisma, noon)).toBe(0.5); + + const item = await prisma.medicationInventoryItem.findFirstOrThrow({ + where: { medicationId }, + select: { unitsRemaining: true }, + }); + expect(Number(item.unitsRemaining)).toBe(98.5); + }); +}); From 83d31dc9c159c03230e30db073b3077ec3fc5c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 07:05:37 +0200 Subject: [PATCH 09/13] style(medications): prettier formatting for the per-slot override step --- src/components/medications/wizard/steps/step7-times.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/medications/wizard/steps/step7-times.tsx b/src/components/medications/wizard/steps/step7-times.tsx index 14109c771..78b241790 100644 --- a/src/components/medications/wizard/steps/step7-times.tsx +++ b/src/components/medications/wizard/steps/step7-times.tsx @@ -130,7 +130,9 @@ export function Step7Times({ payload, applyPartial }: StepProps) { type="text" value={payload.scheduleDose} onChange={(e) => applyPartial({ scheduleDose: e.target.value })} - placeholder={t("medications.wizard.steps.step7.doseOverridePlaceholder")} + placeholder={t( + "medications.wizard.steps.step7.doseOverridePlaceholder", + )} maxLength={50} autoComplete="off" /> @@ -148,7 +150,9 @@ export function Step7Times({ payload, applyPartial }: StepProps) {