diff --git a/CHANGELOG.md b/CHANGELOG.md index fca9868fa..a36262fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.37.10] — 2026-08-10 + +### Added + +- A medication can take a different dose at each time of day, all from the one supply. If you take one tablet in the morning and half a tablet at noon and at night, that is one medication with three times now, not two separate entries that split your stock in two. Each time slot carries its own dose and its own count of units consumed, so the supply counts down by the right amount at each one. An occasional as-needed dose is a slot on the same medication too, drawing from the same box, rather than a third phantom entry. Existing medications are unchanged, and one that does not set a per-slot amount keeps counting down exactly as before. + ## [1.37.9] — 2026-08-10 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a6047c756..7179d03f3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.9 + version: 1.37.10 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -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` 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..34115c96c 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 singola 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/package.json b/package.json index a284e61c8..c428a467a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.9", + "version": "1.37.10", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", 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. 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/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/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), + })} + + )} ); })() 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/src/components/medications/wizard/steps/step7-times.tsx b/src/components/medications/wizard/steps/step7-times.tsx index 696781be9..78b241790 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,77 @@ 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, 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/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts index 7928e09e9..d83348f1f 100644 --- a/src/lib/medications/inventory/__tests__/consumption.test.ts +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -32,12 +32,20 @@ interface FakeEvent { id: string; userId: string; medicationId: string; + scheduledFor: Date; takenAt: Date | null; skipped: boolean; deletedAt: Date | null; inventoryConsumption: unknown; } +/** #219 — a schedule row the per-slot units resolver reads. */ +interface FakeSchedule { + timesOfDay: string[]; + windowStart: string; + unitsPerDose: number | null; +} + interface FakeItem { id: string; userId: string; @@ -57,6 +65,10 @@ interface FakeState { events: FakeEvent[]; items: FakeItem[]; unitsPerDose: number; + // #219 — per-slot resolver inputs. Default to no schedules + UTC so every + // pre-existing scenario inherits the medication-level `unitsPerDose`. + schedules?: FakeSchedule[]; + timezone?: string; } type OrderSpec = Record< @@ -86,7 +98,12 @@ function sortRows(rows: FakeItem[], orderBy: OrderSpec[]): FakeItem[] { function makeClient(state: FakeState) { return { medication: { - findFirst: vi.fn(async () => ({ unitsPerDose: state.unitsPerDose })), + findFirst: vi.fn(async () => ({ + unitsPerDose: state.unitsPerDose, + // #219 — the per-slot resolver reads the user's zone + the schedules. + user: { timezone: state.timezone ?? "UTC" }, + schedules: state.schedules ?? [], + })), }, medicationIntakeEvent: { findFirst: vi.fn( @@ -95,6 +112,14 @@ function makeClient(state: FakeState) { (e) => e.id === args.where.id && e.userId === args.where.userId, ) ?? null, ), + // #219 — the import-batch path loads each event's scheduledFor anchor. + findMany: vi.fn( + async (args: { where: { id: { in: string[] }; userId: string } }) => + state.events.filter( + (e) => + args.where.id.in.includes(e.id) && e.userId === args.where.userId, + ), + ), // The hooks' atomic claim — evaluate the conditional WHERE the way // Postgres would (the gate fields plus the stamp predicate: // `Prisma.AnyNull` matches a NULL stamp, a JSON value matches by @@ -216,6 +241,7 @@ function takenEvent(overrides: Partial = {}): FakeEvent { id: "evt-1", userId: "user-1", medicationId: "med-1", + scheduledFor: NOW, takenAt: NOW, skipped: false, deletedAt: null, 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; 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/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(), 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() 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..fe4cf1a4b --- /dev/null +++ b/tests/integration/medication-per-slot-consumption.test.ts @@ -0,0 +1,158 @@ +/** + * #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 === "object" && + 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); + }); +});