Merge pull request #770 from MBombeck/release/v1.37.10

v1.37.10 — one drug, a different dose at each time
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-10 08:45:18 +02:00
commit b81e3928d8
28 files changed
+688 -14

No files matched your search

+6
View File
@@ -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
+7 -1
View File
@@ -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`
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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);
+10
View File
@@ -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.
@@ -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,
+9
View File
@@ -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,
});
},
+17 -1
View File
@@ -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<string, unknown> & { id: string; unitsPerDose: unknown },
medication: Record<string, unknown> & {
id: string;
unitsPerDose: unknown;
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
},
): Promise<Response> {
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,
@@ -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)}
</span>
)}
{typeof schedule?.unitsPerDose === "number" &&
schedule.unitsPerDose > 0 && (
<span className="text-muted-foreground hidden sm:inline">
{" "}
·{" "}
{t("medications.perSlotUnits", {
units: formatUnitsPerDose(schedule.unitsPerDose),
})}
</span>
)}
</>
)
) : null;
@@ -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)}
</span>
)}
{typeof s.unitsPerDose === "number" && s.unitsPerDose > 0 && (
<span className="text-muted-foreground hidden sm:inline">
{" "}
·{" "}
{t("medications.perSlotUnits", {
units: formatUnitsPerDose(s.unitsPerDose),
})}
</span>
)}
</>
);
})()
@@ -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"),
@@ -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. */}
<div
className="border-border/60 space-y-3 rounded-lg border p-3"
data-slot="wizard-schedule-override"
>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step7.overrideHint")}
</p>
<div className="space-y-2">
<Label htmlFor="wizard-schedule-dose" className="text-sm">
{t("medications.wizard.steps.step7.doseOverrideLabel")}
</Label>
<Input
id="wizard-schedule-dose"
type="text"
value={payload.scheduleDose}
onChange={(e) => applyPartial({ scheduleDose: e.target.value })}
placeholder={t(
"medications.wizard.steps.step7.doseOverridePlaceholder",
)}
maxLength={50}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label id="wizard-schedule-units-label" className="text-sm">
{t("medications.wizard.steps.step7.unitsOverrideLabel")}
</Label>
<div
role="group"
aria-labelledby="wizard-schedule-units-label"
data-slot="wizard-schedule-units"
className="flex flex-wrap gap-1.5"
>
<Button
type="button"
size="sm"
variant={
payload.scheduleUnitsPerDose === "" ? "default" : "outline"
}
aria-pressed={payload.scheduleUnitsPerDose === ""}
className="min-w-10"
onClick={() => applyPartial({ scheduleUnitsPerDose: "" })}
>
{t("medications.wizard.steps.step7.unitsOverrideInherit")}
</Button>
{UNITS_PER_DOSE_OPTIONS.map((opt) => {
const selected = payload.scheduleUnitsPerDose === opt.raw;
return (
<Button
key={opt.raw}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
aria-pressed={selected}
className="min-w-10 tabular-nums"
onClick={() =>
applyPartial({ scheduleUnitsPerDose: opt.raw })
}
>
{opt.label}
</Button>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -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,
@@ -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",
}),
],
}),
+4
View File
@@ -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,
@@ -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> = {}): FakeEvent {
id: "evt-1",
userId: "user-1",
medicationId: "med-1",
scheduledFor: NOW,
takenAt: NOW,
skipped: false,
deletedAt: null,
+107 -6
View File
@@ -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<string, Date>(
(
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;
+3
View File
@@ -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
+24
View File
@@ -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<T extends ScheduleWithUnits>(
schedules: readonly T[],
): Array<Omit<T, "unitsPerDose"> & { unitsPerDose: number | null }> {
return schedules.map((schedule) => ({
...schedule,
unitsPerDose:
schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose),
}));
}
+3
View File
@@ -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(),
+14 -1
View File
@@ -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()
@@ -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<number> {
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<number>(
(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<string> {
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);
});
});