From 13d885b8b3b0e426cab7bba0c8f9473af4f8b7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 16:03:52 +0200 Subject: [PATCH 01/31] =?UTF-8?q?feat(medications):=20window-band=20intake?= =?UTF-8?q?=E2=86=92slot=20attribution=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure band-membership attribution replacing the wide nearest-neighbour matchers that mis-assigned irregular intakes (a 13:02 take snapped onto the 19:00 slot, an 15:46 take onto the 07:00 slot). A take pairs to a slot only inside that slot's dose window plus a capped late tail: intake in [onTimeStart, onTimeEnd] -> on_time intake in (onTimeEnd, overdueEnd] -> late otherwise -> ad-hoc (standalone row) The on-time band is the per-dose window; overdueEnd is capped at the next slot's on-time start so adjacent slots never both claim one intake. Pure and instant-based; callers mint the bounds DST-correctly via localHmAsUtc. 13 tests pin summer/winter, on-time/late/ad-hoc, the cap, and a widened window. --- .../scheduling/__tests__/attribution.test.ts | 168 ++++++++++++++++++ src/lib/medications/scheduling/attribution.ts | 116 ++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 src/lib/medications/scheduling/__tests__/attribution.test.ts create mode 100644 src/lib/medications/scheduling/attribution.ts diff --git a/src/lib/medications/scheduling/__tests__/attribution.test.ts b/src/lib/medications/scheduling/__tests__/attribution.test.ts new file mode 100644 index 000000000..ccc59ce50 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/attribution.test.ts @@ -0,0 +1,168 @@ +/** + * v1.15.18 — window-band intake→slot attribution. + * + * Replaces the wide nearest-neighbour matchers (the ±6h write-time snap in + * `resolve-slot-instant.ts` and the ±12h/half-gap read-time radius in + * `cadence.ts pairDoses`) that forced an irregular real intake onto the + * nearest configured slot — producing absurd rows like "07:00 dose taken + * 15:46". Attribution is now pure band membership: + * + * - intake ∈ [onTimeStart, onTimeEnd] → on_time (this slot) + * - intake ∈ (onTimeEnd, overdueEnd] → late (this slot) + * - otherwise → null (ad-hoc) + * + * `overdueEnd` is the late tail past `onTimeEnd`, capped so it can never + * bleed into the next slot's on-time start (adjacent slots never both claim + * one intake). The on-time band IS the user's configurable dose window; the + * default point window is ±60min on-time + a 180min late tail + * (`DOSE_WINDOW_DEFAULTS`). All math is in instants, DST handled by the + * caller minting the band bounds via `localHmAsUtc`. + */ +import { describe, expect, it } from "vitest"; + +import { + buildSlotBands, + attributeIntakeToSlot, + type SlotWindowInput, +} from "../attribution"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +const MIN = 60_000; + +/** A point-time slot with a symmetric on-time half-width + a late tail. */ +function pointSlot( + at: Date, + timeOfDay: string, + onTimeHalfMin = 60, + lateGraceMin = 180, +): SlotWindowInput { + return { + at, + timeOfDay, + onTimeStart: new Date(at.getTime() - onTimeHalfMin * MIN), + onTimeEnd: new Date(at.getTime() + onTimeHalfMin * MIN), + lateGraceMs: lateGraceMin * MIN, + }; +} + +/** {07:00, 19:00} Europe/Berlin for the local day implied by `dayRef`. */ +function ramiprilSlots(dayRef: Date): SlotWindowInput[] { + return [ + pointSlot(localHmAsUtc(dayRef, TZ, 7, 0), "07:00"), + pointSlot(localHmAsUtc(dayRef, TZ, 19, 0), "19:00"), + ]; +} + +/** Berlin wall-clock HH:mm on the same local day → instant. */ +function at(dayRef: Date, h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); +} + +describe("attributeIntakeToSlot — summer (CEST)", () => { + const day = new Date("2026-06-08T12:00:00Z"); + const bands = buildSlotBands(ramiprilSlots(day)); + + it("07:00 dose taken 07:00 is on_time for the 07:00 slot", () => { + const r = attributeIntakeToSlot(at(day, 7, 0), bands); + expect(r?.band.timeOfDay).toBe("07:00"); + expect(r?.status).toBe("on_time"); + }); + + it("07:00 dose taken 08:30 is late for the 07:00 slot (inside the tail)", () => { + const r = attributeIntakeToSlot(at(day, 8, 30), bands); + expect(r?.band.timeOfDay).toBe("07:00"); + expect(r?.status).toBe("late"); + }); + + it("a take at 11:29 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => { + // The exact case Marc reported: an 11:29 take must NOT show as the 07:00 slot. + expect(attributeIntakeToSlot(at(day, 11, 29), bands)).toBeNull(); + }); + + it("a midday take at 13:02 is ad-hoc (not snapped onto 19:00)", () => { + expect(attributeIntakeToSlot(at(day, 13, 2), bands)).toBeNull(); + }); + + it("19:00 dose taken 19:00 is on_time for the 19:00 slot", () => { + const r = attributeIntakeToSlot(at(day, 19, 0), bands); + expect(r?.band.timeOfDay).toBe("19:00"); + expect(r?.status).toBe("on_time"); + }); + + it("an early evening take at 18:30 is on_time for the 19:00 slot", () => { + const r = attributeIntakeToSlot(at(day, 18, 30), bands); + expect(r?.band.timeOfDay).toBe("19:00"); + expect(r?.status).toBe("on_time"); + }); + + it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => { + expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull(); + }); +}); + +describe("attributeIntakeToSlot — winter (CET, DST-robust)", () => { + const day = new Date("2026-01-15T12:00:00Z"); + const bands = buildSlotBands(ramiprilSlots(day)); + + it("07:00 dose taken 07:00 is on_time in winter too", () => { + const r = attributeIntakeToSlot(at(day, 7, 0), bands); + expect(r?.band.timeOfDay).toBe("07:00"); + expect(r?.status).toBe("on_time"); + }); + + it("11:29 take is ad-hoc in winter too", () => { + expect(attributeIntakeToSlot(at(day, 11, 29), bands)).toBeNull(); + }); + + it("the 07:00 slot's true instant is 06:00Z in winter (CET = UTC+1)", () => { + expect(bands[0].at.toISOString()).toBe("2026-01-15T06:00:00.000Z"); + }); +}); + +describe("buildSlotBands — late tail never bleeds into the next slot", () => { + // Two close slots 08:00 / 12:00 with a generous 180min tail: 08:00's tail + // (08:00+60+180 = 11:00) is capped at 12:00's on-time start (11:00). With + // an even larger tail the cap binds. + const day = new Date("2026-06-08T12:00:00Z"); + const slots: SlotWindowInput[] = [ + pointSlot(localHmAsUtc(day, TZ, 8, 0), "08:00", 60, 600), // 10h tail + pointSlot(localHmAsUtc(day, TZ, 12, 0), "12:00", 60, 180), + ]; + const bands = buildSlotBands(slots); + + it("caps the 08:00 overdueEnd at the 12:00 on-time start (11:00)", () => { + expect(bands[0].overdueEnd.toISOString()).toBe( + bands[1].onTimeStart.toISOString(), + ); + }); + + it("a take at 11:30 belongs to the 12:00 slot, not the over-long 08:00 tail", () => { + const r = attributeIntakeToSlot(at(day, 11, 30), bands); + expect(r?.band.timeOfDay).toBe("12:00"); + expect(r?.status).toBe("on_time"); + }); +}); + +describe("attributeIntakeToSlot — configurable window widens on-time", () => { + const day = new Date("2026-06-08T12:00:00Z"); + + it("an explicit 07:00–12:00 window makes an 11:29 take on_time for that dose", () => { + // Marc's lever: a real morning window of 07:00–12:00 (not a ±60 point) + // means his habitual ~11:00 intake counts as on-time, not ad-hoc. + const slots: SlotWindowInput[] = [ + { + at: localHmAsUtc(day, TZ, 7, 0), + timeOfDay: "07:00", + onTimeStart: localHmAsUtc(day, TZ, 7, 0), + onTimeEnd: localHmAsUtc(day, TZ, 12, 0), + lateGraceMs: 180 * MIN, + }, + pointSlot(localHmAsUtc(day, TZ, 19, 0), "19:00"), + ]; + const bands = buildSlotBands(slots); + const r = attributeIntakeToSlot(at(day, 11, 29), bands); + expect(r?.band.timeOfDay).toBe("07:00"); + expect(r?.status).toBe("on_time"); + }); +}); diff --git a/src/lib/medications/scheduling/attribution.ts b/src/lib/medications/scheduling/attribution.ts new file mode 100644 index 000000000..7d8b705bb --- /dev/null +++ b/src/lib/medications/scheduling/attribution.ts @@ -0,0 +1,116 @@ +/** + * v1.15.18 — window-band intake→slot attribution. + * + * The medical core of the traceable dose-history model. It replaces the two + * wide nearest-neighbour matchers that mis-attributed irregular real intakes: + * + * - WRITE-time: the ±6h `snapToleranceMs` snap in `resolve-slot-instant.ts` + * (½ the inter-slot gap), which pulled a 13:02 take onto the 19:00 slot + * and an 15:46 take onto the 07:00 slot. + * - READ-time: the ±12h/half-gap `slotMatchRadius` in `cadence.ts`. + * + * Attribution is now pure band membership against each slot's configurable + * dose window (the on-time band) plus a late tail: + * + * - intake ∈ [onTimeStart, onTimeEnd] → on_time (this slot) + * - intake ∈ (onTimeEnd, overdueEnd] → late (this slot) + * - otherwise → null (ad-hoc: standalone row, + * the slot stays missed if unfilled) + * + * `overdueEnd` (the late tail past `onTimeEnd`) is capped at the next slot's + * `onTimeStart`, so two adjacent same-day slots can never both claim one + * intake. The on-time band IS the user's per-dose window: a point time maps to + * a symmetric ±half-width, an explicit window to `[start, end]`. All bounds are + * instants — the caller mints them DST-correctly via `localHmAsUtc`, keeping + * this module timezone-agnostic and trivially testable. + */ + +/** Per-slot window the caller supplies (bounds already minted as instants). */ +export interface SlotWindowInput { + /** Canonical anchor instant for the slot (labelling / next-due / pairing). */ + at: Date; + /** "HH:mm" the slot represents (for the history label + dedup key). */ + timeOfDay: string; + /** On-time band lower bound (inclusive). */ + onTimeStart: Date; + /** On-time band upper bound (inclusive). */ + onTimeEnd: Date; + /** Late tail past `onTimeEnd` that still counts as taken-late, in ms. */ + lateGraceMs: number; +} + +/** A slot window with its (capped) late-tail cutoff resolved. */ +export interface SlotBand extends SlotWindowInput { + /** Cutoff past which a take no longer pairs to this slot (capped). */ + overdueEnd: Date; +} + +export type AttributionStatus = "on_time" | "late"; + +export interface AttributionResult { + band: SlotBand; + status: AttributionStatus; +} + +/** + * Resolve each slot's `overdueEnd` = `onTimeEnd + lateGraceMs`, capped at the + * next slot's `onTimeStart` so adjacent slots' capture bands stay disjoint. + * The input is sorted by anchor instant; the returned bands preserve that order. + */ +export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] { + const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime()); + return sorted.map((slot, i) => { + let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs; + const next = sorted[i + 1]; + if (next) { + // Never let one slot's late tail bleed into the next slot's on-time + // window — the cap keeps the two capture bands disjoint. + overdueEnd = Math.min(overdueEnd, next.onTimeStart.getTime()); + } + // A degenerate/negative tail collapses to the on-time end. + overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime()); + return { ...slot, overdueEnd: new Date(overdueEnd) }; + }); +} + +/** + * Attribute an intake instant to the slot whose window contains it, or `null` + * when it falls in no slot's band (an ad-hoc / off-schedule intake). + * + * On overlap-free bands at most one slot matches. As a defensive tie-break + * (custom windows a caller hand-builds could overlap), an on-time match wins + * over a late match, then the nearer anchor wins. + */ +export function attributeIntakeToSlot( + intakeAt: Date, + bands: SlotBand[], +): AttributionResult | null { + const t = intakeAt.getTime(); + let best: { result: AttributionResult; rank: number; dist: number } | null = + null; + + for (const band of bands) { + const onStart = band.onTimeStart.getTime(); + const onEnd = band.onTimeEnd.getTime(); + const overdueEnd = band.overdueEnd.getTime(); + + let status: AttributionStatus | null = null; + if (t >= onStart && t <= onEnd) status = "on_time"; + else if (t > onEnd && t <= overdueEnd) status = "late"; + if (status === null) continue; + + // on_time (rank 0) beats late (rank 1); within a rank the nearer anchor + // wins. Keeps a deterministic pick if a caller's custom bands overlap. + const rank = status === "on_time" ? 0 : 1; + const dist = Math.abs(t - band.at.getTime()); + if ( + best === null || + rank < best.rank || + (rank === best.rank && dist < best.dist) + ) { + best = { result: { band, status }, rank, dist }; + } + } + + return best?.result ?? null; +} From e5221b45e63df5228dc7462f76e8e4dc2496645e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 16:08:12 +0200 Subject: [PATCH 02/31] feat(medications): traceable dose-history read-model Reconstructs a complete, attributable ledger from the expected-slot bands + the actual intakes: every expected slot carries a status (taken_on_time / taken_late / skipped / missed / upcoming) even when never taken, and a taken intake that matches no band is emitted as a tagged ad-hoc row at its real time instead of vanishing. A taken intake attributes by its real takenAt (not the stored, possibly mis-snapped scheduledFor), so corrected bands converge forward over legacy rows with no data migration. Skipped / auto-missed / pending rows bind to their slot by anchor. Pure and instant-based. 9 tests. --- .../scheduling/__tests__/dose-history.test.ts | 153 +++++++++++++++++ .../medications/scheduling/dose-history.ts | 162 ++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 src/lib/medications/scheduling/__tests__/dose-history.test.ts create mode 100644 src/lib/medications/scheduling/dose-history.ts diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts new file mode 100644 index 000000000..38f709960 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -0,0 +1,153 @@ +/** + * v1.15.18 — traceable dose-history reconstruction (spec B). + * + * Produces a complete, attributable ledger from the expected-slot bands + + * the actual intake rows: + * - every expected slot appears with a status (taken_on_time / taken_late / + * skipped / missed / upcoming) even when never taken; + * - a TAKEN intake is attributed to a slot by its real `takenAt` band + * membership (NOT the stored, possibly-snapped `scheduledFor`), so the + * corrected bands converge forward over legacy mis-snapped rows; + * - skipped / auto-missed / pending rows bind to their slot by anchor + * (they were written against a slot deliberately); + * - a taken intake matching no slot band is emitted as a tagged `ad_hoc` + * row at its real time — never silently snapped onto a scheduled slot. + */ +import { describe, expect, it } from "vitest"; + +import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "../dose-history"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +const MIN = 60_000; +const day = new Date("2026-06-08T12:00:00Z"); + +function pointSlot(at: Date, timeOfDay: string): SlotWindowInput { + return { + at, + timeOfDay, + onTimeStart: new Date(at.getTime() - 60 * MIN), + onTimeEnd: new Date(at.getTime() + 60 * MIN), + lateGraceMs: 180 * MIN, + }; +} +const bands = buildSlotBands([ + pointSlot(localHmAsUtc(day, TZ, 7, 0), "07:00"), + pointSlot(localHmAsUtc(day, TZ, 19, 0), "19:00"), +]); + +function at(h: number, m: number): Date { + return localHmAsUtc(day, TZ, h, m); +} +function intake(over: Partial): HistoryIntake { + return { + id: "i", + scheduledFor: at(7, 0), + takenAt: null, + skipped: false, + autoMissed: false, + ...over, + }; +} + +// Late "now" so both of today's slots are past their miss cutoff. +const nowEvening = at(23, 59); + +describe("reconstructDoseHistory", () => { + it("emits one row per expected slot even with no intakes", () => { + const rows = reconstructDoseHistory(bands, [], nowEvening); + expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]); + expect(rows.every((r) => r.kind === "slot")).toBe(true); + expect(rows.map((r) => r.status)).toEqual(["missed", "missed"]); + }); + + it("attributes an on-time take to its slot", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(7, 0), scheduledFor: at(7, 0) })], + nowEvening, + ); + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("taken_on_time"); + expect(morning?.intake?.takenAt?.toISOString()).toBe(at(7, 0).toISOString()); + }); + + it("attributes a take inside the late tail as taken_late", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(8, 30), scheduledFor: at(7, 0) })], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("taken_late"); + }); + + it("emits an 11:29 take as an ad-hoc row and leaves 07:00 missed", () => { + // Marc's case: the stored scheduledFor was snapped to 07:00 by the old + // write path, but the real takenAt (11:29) is outside every band → ad-hoc. + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })], + nowEvening, + ); + const adhoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adhoc?.status).toBe("ad_hoc"); + expect(adhoc?.at.toISOString()).toBe(at(11, 29).toISOString()); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + + it("binds an explicit skip to its slot by anchor", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ skipped: true, scheduledFor: at(7, 0) })], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped"); + }); + + it("counts an auto-missed pending row as missed", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ autoMissed: true, scheduledFor: at(7, 0) })], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + + it("marks a future slot upcoming, not missed", () => { + const earlyMorning = at(5, 0); // before every slot's miss cutoff + const rows = reconstructDoseHistory(bands, [], earlyMorning); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("upcoming"); + expect(rows.find((r) => r.timeOfDay === "19:00")?.status).toBe("upcoming"); + }); + + it("a second take near an already-filled slot becomes ad-hoc", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ id: "a", takenAt: at(7, 0), scheduledFor: at(7, 0) }), + intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }), + ], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe( + "taken_on_time", + ); + const adhoc = rows.filter((r) => r.kind === "ad_hoc"); + expect(adhoc).toHaveLength(1); + expect(adhoc[0].intake?.id).toBe("b"); + }); + + it("returns rows in chronological order (slots + ad-hoc interleaved)", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "x", takenAt: at(13, 0), scheduledFor: at(13, 0) })], + nowEvening, + ); + const times = rows.map((r) => r.at.getTime()); + expect(times).toEqual([...times].sort((a, b) => a - b)); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts new file mode 100644 index 000000000..6a8d214e7 --- /dev/null +++ b/src/lib/medications/scheduling/dose-history.ts @@ -0,0 +1,162 @@ +/** + * v1.15.18 — traceable dose-history reconstruction (spec B). + * + * Builds a complete, attributable ledger for a medication's day(s): every + * expected slot with a status, plus every off-schedule intake tagged ad-hoc. + * It is the read-model behind the medication history view + the card's + * last/next dose, and it converges forward over legacy mis-snapped rows + * because it attributes a TAKEN intake by its real `takenAt`, not the stored + * `scheduledFor` the old write path may have snapped to a slot. + * + * Attribution rules: + * - skipped / auto-missed / pending rows (no `takenAt`) bind to the slot + * whose anchor equals their `scheduledFor` (±epsilon): these were written + * against a slot deliberately, so they annotate that slot; + * - a TAKEN intake is attributed by `attributeIntakeToSlot(takenAt, bands)`; + * inside a band → that slot (on-time / late), outside every band → ad-hoc; + * - each slot is claimed by at most one intake (first/best wins); extra + * intakes near a filled slot fall through to ad-hoc. + * + * Pure / synchronous / instant-based — the caller mints the bands DST-correctly + * via `localHmAsUtc` and supplies the per-dose window. + */ +import { + attributeIntakeToSlot, + type SlotBand, +} from "@/lib/medications/scheduling/attribution"; + +/** The minimal intake shape the ledger reads. */ +export interface HistoryIntake { + id?: string; + /** Stored slot anchor (may be a legacy mis-snapped instant for takes). */ + scheduledFor: Date; + /** Real intake time, or null for skipped / pending / auto-missed rows. */ + takenAt: Date | null; + skipped: boolean; + /** Cron-marked forgotten dose (counts as missed, not skipped). */ + autoMissed?: boolean; +} + +export type DoseHistoryStatus = + | "taken_on_time" + | "taken_late" + | "skipped" + | "missed" + | "upcoming" + | "ad_hoc"; + +export interface DoseHistoryRow { + /** A scheduled slot, or a standalone off-schedule intake. */ + kind: "slot" | "ad_hoc"; + /** The slot anchor instant, or the ad-hoc take's own time. */ + at: Date; + /** The slot's "HH:mm" label, or null for an ad-hoc row. */ + timeOfDay: string | null; + status: DoseHistoryStatus; + /** The intake attributed to this row, if any. */ + intake: HistoryIntake | null; +} + +/** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */ +const ANCHOR_EPSILON_MS = 60_000; + +/** + * Reconstruct the dose-history ledger for the slots described by `bands` and + * the supplied intakes. Returns rows in chronological order. + */ +export function reconstructDoseHistory( + bands: SlotBand[], + intakes: HistoryIntake[], + now: Date, +): DoseHistoryRow[] { + // Per-band claim: the intake attributed to it + the resolved status. + const claim = new Map< + SlotBand, + { intake: HistoryIntake; status: DoseHistoryStatus } + >(); + const adHoc: HistoryIntake[] = []; + + // Partition: anchored rows (skip / auto-missed / pending — no takenAt) bind + // by scheduledFor; taken rows attribute by real takenAt band membership. + // Process anchored first so a deliberate skip/miss owns its slot before a + // stray take could. + const anchored = intakes.filter((i) => i.takenAt === null); + const taken = intakes.filter((i) => i.takenAt !== null); + + for (const i of anchored) { + const band = nearestAnchorBand(i.scheduledFor, bands); + const status: DoseHistoryStatus = i.skipped ? "skipped" : "missed"; + if (band && !claim.has(band)) { + claim.set(band, { intake: i, status }); + } else { + // A skip/miss with no matching slot (legacy off-grid) — surface it so + // nothing silently vanishes; tag it ad-hoc. + adHoc.push(i); + } + } + + for (const i of taken) { + const matched = attributeIntakeToSlot(i.takenAt as Date, bands); + if (matched && !claim.has(matched.band)) { + claim.set(matched.band, { + intake: i, + status: matched.status === "on_time" ? "taken_on_time" : "taken_late", + }); + } else { + adHoc.push(i); + } + } + + const rows: DoseHistoryRow[] = bands.map((band) => { + const c = claim.get(band); + if (c) { + return { + kind: "slot", + at: band.at, + timeOfDay: band.timeOfDay, + status: c.status, + intake: c.intake, + }; + } + // Unfilled slot: missed only once the miss cutoff (the late tail's end) + // has passed; until then the dose is still takeable, so it reads upcoming + // rather than prematurely missed. + const status: DoseHistoryStatus = + now.getTime() > band.overdueEnd.getTime() ? "missed" : "upcoming"; + return { + kind: "slot", + at: band.at, + timeOfDay: band.timeOfDay, + status, + intake: null, + }; + }); + + for (const i of adHoc) { + rows.push({ + kind: "ad_hoc", + at: i.takenAt ?? i.scheduledFor, + timeOfDay: null, + status: "ad_hoc", + intake: i, + }); + } + + rows.sort((a, b) => a.at.getTime() - b.at.getTime()); + return rows; +} + +/** The band whose anchor is within epsilon of `instant`, nearest wins. */ +function nearestAnchorBand(instant: Date, bands: SlotBand[]): SlotBand | null { + const t = instant.getTime(); + let best: SlotBand | null = null; + let bestDist = Infinity; + for (const band of bands) { + const dist = Math.abs(band.at.getTime() - t); + if (dist <= ANCHOR_EPSILON_MS && dist < bestDist) { + bestDist = dist; + best = band; + } + } + return best; +} From 75bf9829a87f75cafbfec978a05fb9cef5c44b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 17:28:55 +0200 Subject: [PATCH 03/31] feat(medications): shared cadence-aware band minter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the keystone that builds the correct SlotBand[] for every medication cadence, so the dose-history read-model, the compliance rate, and the write/edit attribution all consume one source of slot windows and can never contradict each other. Per cadence: - daily / multi-time: minute-scale on-time (±60min default) + daily late tail; - fixed weekdays / every-N-weeks: family derived from the realised inter-slot gap, not the field-shape heuristic that mislabels legacy daysOfWeek meds as daily; day-scale on-time + 4-day tail; - rolling (GLP-1): retrospective bands anchored at each logged intake, not the forward-only single slot that would orphan every past shot to ad-hoc; - cyclic: off-week slots already dropped by the engine; band the survivors; - one-shot: a single whole-day on-time band, never auto-missed; - PRN / empty / malformed rrule: no bands, hasExpectedSlots=false; - DST day-scale bounds minted via localHmAsUtc on calendar days, not ±N·DAY_MS; - multi-schedule: bands per schedule so a weekly tail is never clipped by a daily anchor. Pure and synchronous; the rolling intake instants are pre-fetched and threaded in like resolve-slot-instant. 25 tests covering each cadence plus DST summer/winter, the realised-gap family, and the per-schedule no-clip invariant. --- .../scheduling/__tests__/band-minter.test.ts | 440 +++++++++++++++++ src/lib/medications/scheduling/band-minter.ts | 455 ++++++++++++++++++ 2 files changed, 895 insertions(+) create mode 100644 src/lib/medications/scheduling/__tests__/band-minter.test.ts create mode 100644 src/lib/medications/scheduling/band-minter.ts diff --git a/src/lib/medications/scheduling/__tests__/band-minter.test.ts b/src/lib/medications/scheduling/__tests__/band-minter.test.ts new file mode 100644 index 000000000..af94cfdd2 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/band-minter.test.ts @@ -0,0 +1,440 @@ +/** + * v1.15.18 — shared cadence-aware band minter. + * + * The keystone that builds the correct `SlotBand[]` for EVERY medication + * cadence, so the read-model + the compliance % + the write/edit paths all + * consume ONE source of slot windows. These tests pin each cadence the audit + * called out: + * + * - daily / multi-time → minute-scale on-time + daily late tail; + * - fixed weekdays / N-weeks → day-scale on-time (realised-gap family, NOT + * the field-shape `doseCadenceFamily`); + * - rolling (GLP-1) → retrospective bands anchored AT each intake; + * - cyclic → off-week slots already filtered out; + * - one-shot → one WIDE whole-day on-time band, no auto-miss; + * - PRN / empty / bad rrule → [] + `hasExpectedSlots: false`; + * - DST day-scale → ±N calendar days via `localHmAsUtc`, not ±N·DAY; + * - multi-schedule → bands PER SCHEDULE, no cross-schedule clip. + */ +import { describe, expect, it } from "vitest"; + +import { + buildBandsForMedication, + buildBandsForSchedules, + type BandMinterMedication, +} from "../band-minter"; +import { + type CanonicalSchedule, + type RecurrenceContext, +} from "../recurrence"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +function med(over: Partial = {}): BandMinterMedication { + return { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + ...over, + }; +} + +function schedule(over: Partial = {}): CanonicalSchedule { + return { + id: "sched-1", + rrule: null, + rollingIntervalDays: null, + timesOfDay: [], + daysOfWeek: null, + windowStart: "08:00", + windowEnd: "08:00", + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...over, + }; +} + +function ctxFor( + m: BandMinterMedication, + lastIntakeAt: Date | null = null, +): RecurrenceContext { + return { + medication: { + id: m.id, + startsOn: m.startsOn, + endsOn: m.endsOn, + oneShot: m.oneShot, + createdAt: m.createdAt, + }, + timeZone: TZ, + lastIntakeAt, + }; +} + +/** Berlin wall-clock HH:mm on the local day implied by `dayRef`. */ +function at(dayRef: Date, h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); +} + +// ──────────────────────────────────────────────────────────────────── +// 1. Daily / multi-time +// ──────────────────────────────────────────────────────────────────── + +describe("daily multi-time (07:00 / 19:00)", () => { + const m = med(); + const sched = schedule({ timesOfDay: ["07:00", "19:00"], daysOfWeek: null }); + const day = new Date("2026-06-08T12:00:00Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(day, 0, 0), to: at(day, 23, 59) }, + now: at(day, 23, 59), + }); + + it("mints one band per time-of-day", () => { + expect(out.hasExpectedSlots).toBe(true); + expect(out.bands.map((b) => b.timeOfDay)).toEqual(["07:00", "19:00"]); + }); + + it("classifies as a daily (minute-scale) family", () => { + expect(out.family).toBe("daily"); + }); + + it("uses a minute-scale on-time band (±60min default)", () => { + const morning = out.bands[0]; + expect(morning.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime() - 60 * MIN); + expect(morning.onTimeEnd.getTime()).toBe(at(day, 7, 0).getTime() + 60 * MIN); + }); + + it("applies a daily late tail (180min default) capped before the next slot", () => { + const morning = out.bands[0]; + // 07:00 + 60 on-time + 180 tail = 10:00, well before 19:00 → not capped. + expect(morning.overdueEnd.getTime()).toBe(at(day, 7, 0).getTime() + (60 + 180) * MIN); + }); + + it("DST-correct anchors: 07:00 Berlin summer is 05:00Z", () => { + expect(out.bands[0].at.toISOString()).toBe("2026-06-08T05:00:00.000Z"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 2. Fixed weekdays / every-N-weeks (realised-gap family) +// ──────────────────────────────────────────────────────────────────── + +describe("fixed weekdays Mon/Thu via legacy daysOfWeek", () => { + // A Mon/Thu med stored the legacy way. `doseCadenceFamily` would mislabel + // it daily (±60min). The realised inter-slot gap (3–4 days) must classify + // it weekly → day-scale on-time. + const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") }); + const sched = schedule({ daysOfWeek: "1,4", timesOfDay: ["09:00"] }); + // Mon 2026-06-08 .. Sun 2026-06-14 inclusive. + const from = new Date("2026-06-08T00:00:00Z"); + const to = new Date("2026-06-14T23:59:59Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from, to }, + now: new Date("2026-06-20T00:00:00Z"), + }); + + it("derives the weekly family from the realised gap, not the field shape", () => { + expect(out.family).toBe("weekly"); + }); + + it("emits the Mon and Thu slots", () => { + expect(out.bands).toHaveLength(2); + }); + + it("uses a day-scale (±1 day) on-time band", () => { + const mon = out.bands[0]; + const span = mon.onTimeEnd.getTime() - mon.onTimeStart.getTime(); + // ±1 day on-time → roughly 2 days wide (DST may shift by an hour, allow slop). + expect(span).toBeGreaterThanOrEqual(2 * DAY - 2 * HOUR); + expect(span).toBeLessThanOrEqual(2 * DAY + 2 * HOUR); + }); + + it("applies the 4-day late tail (capped before the next slot)", () => { + const mon = out.bands[0]; + const thu = out.bands[1]; + // Mon on-time-end + 4d would overrun Thu's on-time start (Thu − 1d), so it + // is capped there. + expect(mon.overdueEnd.getTime()).toBeLessThanOrEqual(thu.onTimeStart.getTime()); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 3. Rolling / "every N days from last intake" (GLP-1) +// ──────────────────────────────────────────────────────────────────── + +describe("rolling GLP-1 (every 7 days) — retrospective bands", () => { + const m = med({ startsOn: new Date("2026-05-01T00:00:00Z") }); + const sched = schedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + windowStart: "08:00", + windowEnd: "08:00", + }); + // Three irregular real injections (not exactly 7d apart). + const shot1 = new Date("2026-05-04T09:12:00Z"); + const shot2 = new Date("2026-05-12T18:40:00Z"); // 8d later + const shot3 = new Date("2026-05-18T07:55:00Z"); // 6d later + const intakeInstants = [shot1, shot2, shot3]; + const now = new Date("2026-05-20T12:00:00Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m, shot3), + userTz: TZ, + range: { + from: new Date("2026-05-01T00:00:00Z"), + to: now, + }, + now, + intakeInstants, + }); + + it("anchors a band AT each logged injection (not one forward slot)", () => { + // Three intakes → at least three retrospective bands. + expect(out.bands.length).toBeGreaterThanOrEqual(3); + }); + + it("each shot's own injection time falls inside its band (on-time)", () => { + for (const shot of intakeInstants) { + const owning = out.bands.find( + (b) => shot.getTime() >= b.onTimeStart.getTime() && shot.getTime() <= b.onTimeEnd.getTime(), + ); + expect(owning, `shot ${shot.toISOString()} should anchor a band`).toBeDefined(); + } + }); + + it("uses a day-scale (weekly) family for the rolling cadence", () => { + expect(out.family).toBe("weekly"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 4. Cyclic (on/off weeks) +// ──────────────────────────────────────────────────────────────────── + +describe("cyclic (3 weeks on / 1 week off) — off-week slots dropped", () => { + const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") }); + // Daily within an on-week, 3 on / 1 off; week 4 (2026-06-22..) is off. + const sched = schedule({ + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + // Off-week 4 runs Mon 2026-06-22 .. Sat 2026-06-27; Sunday 06-28 is already + // the start of the next (Sunday-rooted) on-week, so the range stops Saturday. + const offWeekFrom = new Date("2026-06-22T00:00:00Z"); + const offWeekTo = new Date("2026-06-27T23:59:59Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: offWeekFrom, to: offWeekTo }, + now: new Date("2026-07-10T00:00:00Z"), + }); + + it("mints no bands inside an off week", () => { + expect(out.bands).toHaveLength(0); + }); + + it("still reports the schedule HAS expected slots (it is scheduled)", () => { + expect(out.hasExpectedSlots).toBe(true); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 5. One-shot +// ──────────────────────────────────────────────────────────────────── + +describe("one-shot — single wide whole-day on-time band", () => { + const m = med({ + oneShot: true, + startsOn: new Date("2026-06-08T00:00:00Z"), + }); + const sched = schedule({ timesOfDay: ["08:00"] }); + const day = new Date("2026-06-08T12:00:00Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(day, 0, 0), to: at(day, 23, 59) }, + now: at(day, 23, 59), + }); + + it("mints exactly one band", () => { + expect(out.bands).toHaveLength(1); + }); + + it("the on-time band spans the whole local day", () => { + const b = out.bands[0]; + // Whole-day on-time → start at local midnight, end at next local midnight. + expect(b.onTimeStart.getTime()).toBeLessThanOrEqual(at(day, 0, 0).getTime()); + expect(b.onTimeEnd.getTime()).toBeGreaterThanOrEqual(at(day, 23, 59).getTime()); + }); + + it("a take any time that day is on-time (a 23:00 take still counts)", () => { + const b = out.bands[0]; + const late = at(day, 23, 0); + expect(late.getTime()).toBeGreaterThanOrEqual(b.onTimeStart.getTime()); + expect(late.getTime()).toBeLessThanOrEqual(b.onTimeEnd.getTime()); + }); + + it("classifies as a one-shot family", () => { + expect(out.family).toBe("one_shot"); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 6. PRN / empty / malformed rrule +// ──────────────────────────────────────────────────────────────────── + +describe("PRN / empty / malformed → no expected slots", () => { + const m = med(); + const day = new Date("2026-06-08T12:00:00Z"); + const range = { from: at(day, 0, 0), to: at(day, 23, 59) }; + + it("PRN yields [] and hasExpectedSlots=false", () => { + const out = buildBandsForMedication({ + medication: m, + schedule: schedule({ scheduleType: "PRN", timesOfDay: ["08:00"] }), + ctx: ctxFor(m), + userTz: TZ, + range, + now: at(day, 23, 59), + }); + expect(out.bands).toEqual([]); + expect(out.hasExpectedSlots).toBe(false); + expect(out.family).toBe("none"); + }); + + it("a malformed rrule yields [] and hasExpectedSlots=false", () => { + const out = buildBandsForMedication({ + medication: m, + schedule: schedule({ rrule: "this-is-not-a-valid-rrule" }), + ctx: ctxFor(m), + userTz: TZ, + range, + now: at(day, 23, 59), + }); + expect(out.bands).toEqual([]); + expect(out.hasExpectedSlots).toBe(false); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 7. DST for day-scale bands +// ──────────────────────────────────────────────────────────────────── + +describe("DST day-scale bounds via localHmAsUtc (not ±N·DAY_MS)", () => { + // A weekly slot whose ±1-day window spans the spring-forward transition + // (Europe/Berlin 2026-03-29 02:00→03:00). The day-scale bound must be the + // calendar day before/after at the same wall-clock, not a raw 24h subtraction. + const m = med({ startsOn: new Date("2026-03-01T00:00:00Z") }); + const sched = schedule({ + rrule: "FREQ=WEEKLY;BYDAY=SU", + timesOfDay: ["09:00"], + }); + const from = new Date("2026-03-26T00:00:00Z"); + const to = new Date("2026-04-01T23:59:59Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from, to }, + now: new Date("2026-04-10T00:00:00Z"), + }); + + it("emits the Sunday slot in the DST week", () => { + expect(out.bands.length).toBeGreaterThanOrEqual(1); + }); + + it("the on-time lower bound is 09:00 the previous calendar day (DST-correct)", () => { + const b = out.bands[0]; + const anchorParts = b.at; // 2026-03-29 09:00 Berlin + // The lower bound must read 09:00 Berlin on 2026-03-28, i.e. a calendar-day + // shift, even though the raw clock distance is 23h across the spring-forward. + const lower = b.onTimeStart; + const expectedLower = localHmAsUtc( + new Date(anchorParts.getTime() - DAY), + TZ, + 9, + 0, + ); + expect(lower.toISOString()).toBe(expectedLower.toISOString()); + // And critically NOT the naive ±24h subtraction. + const naive = new Date(anchorParts.getTime() - DAY); + expect(lower.getTime()).not.toBe(naive.getTime()); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// 8. Multi-schedule on one med +// ──────────────────────────────────────────────────────────────────── + +describe("multi-schedule — bands built PER SCHEDULE (no cross-clip)", () => { + // A daily oral 08:00 + a weekly injection Sunday 08:00 on the same med. If + // pooled into one buildSlotBands call, the weekly slot's 4-day tail would be + // clipped by the next daily 08:00 anchor. Per-schedule banding keeps the + // weekly tail intact. + const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") }); + const daily = schedule({ id: "daily", timesOfDay: ["08:00"], daysOfWeek: null }); + const weekly = schedule({ + id: "weekly", + rrule: "FREQ=WEEKLY;BYDAY=SU", + timesOfDay: ["08:00"], + }); + const from = new Date("2026-06-07T00:00:00Z"); // Sunday + const to = new Date("2026-06-13T23:59:59Z"); + const out = buildBandsForSchedules({ + medication: m, + schedules: [daily, weekly], + ctx: ctxFor(m), + userTz: TZ, + range: { from, to }, + now: new Date("2026-06-20T00:00:00Z"), + }); + + it("returns one group per schedule", () => { + expect(out).toHaveLength(2); + expect(out.map((g) => g.scheduleId).sort()).toEqual(["daily", "weekly"]); + }); + + it("the weekly band keeps a multi-day late tail (not clipped by a daily anchor)", () => { + const weeklyGroup = out.find((g) => g.scheduleId === "weekly"); + expect(weeklyGroup).toBeDefined(); + expect(weeklyGroup!.family).toBe("weekly"); + const sun = weeklyGroup!.bands[0]; + const tailMs = sun.overdueEnd.getTime() - sun.onTimeEnd.getTime(); + // The weekly tail must be on the day scale (>1 day), not clipped to the + // next daily 08:00 (~24h − on-time) as a pooled call would do. + expect(tailMs).toBeGreaterThan(DAY); + }); + + it("the daily group keeps minute-scale bands", () => { + const dailyGroup = out.find((g) => g.scheduleId === "daily"); + expect(dailyGroup!.family).toBe("daily"); + const oneBand = dailyGroup!.bands[0]; + const span = oneBand.onTimeEnd.getTime() - oneBand.onTimeStart.getTime(); + expect(span).toBe(2 * 60 * MIN); // ±60min + }); +}); diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts new file mode 100644 index 000000000..d7e44f7ac --- /dev/null +++ b/src/lib/medications/scheduling/band-minter.ts @@ -0,0 +1,455 @@ +/** + * v1.15.18 — shared cadence-aware band minter. + * + * THE keystone of the traceable dose-history model. It builds the correct + * `SlotBand[]` for EVERY medication cadence so the three downstream surfaces + * — the read-model (`reconstructDoseHistory`), the compliance %, and the + * write/edit attribution — all consume ONE source of slot windows and can + * never contradict each other (the v1.15.18 audit's CRITICAL-2 finding). + * + * Per the audit, each cadence needs its own band shape: + * + * 1. daily / multi-time — `occurrencesBetween` slots; minute-scale + * on-time (±60min default) + a daily late tail. + * 2. fixed weekdays / N-weeks — derive the family from the REALISED + * inter-slot gap, NOT `doseCadenceFamily`'s + * field-shape heuristic (which mislabels legacy + * `daysOfWeek` meds as daily); day-scale on-time + * (±1 day) + a 4-day late tail. + * 3. rolling (GLP-1) — bands from `expandRollingRetrospective`, + * anchored AT each logged intake (NOT the + * forward-only `occurrencesBetween`, which would + * make every past shot read ad-hoc). Day-scale. + * 4. cyclic — `occurrencesBetween` already drops off-week + * slots; mint bands only for the survivors. + * 5. one-shot — a single WIDE whole-day on-time band; never + * auto-flips to missed. + * 6. PRN / empty / bad rrule — `[]` (the caller renders intakes as plain + * entries) + `hasExpectedSlots: false`. + * 7. DST for day-scale bands — ±N-day bounds minted via `localHmAsUtc` on + * calendar days, never `±N·DAY_MS`. + * 8. multi-schedule on one med — bands built PER SCHEDULE so a weekly tail is + * never clipped by a daily oral's anchor. + * + * Pure / synchronous: no DB access. The DB-reading parts (the rolling + * `intakeInstants`, `lastIntakeAt`) are pre-fetched and threaded in, like + * `resolve-slot-instant.ts`. + */ +import { RRule } from "rrule"; + +import { DOSE_WINDOW_DEFAULTS } from "@/lib/analytics/compliance"; +import { + buildSlotBands, + type SlotBand, + type SlotWindowInput, +} from "@/lib/medications/scheduling/attribution"; +import { + expandRollingRetrospective, + occurrencesBetween, + type CanonicalSchedule, + type Occurrence, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; +import { localHmAsUtc } from "@/lib/timezone"; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +/** + * Realised-gap threshold separating the day-scale (weekly) family from the + * minute-scale (daily) family. A cadence whose closest two slots sit at least + * 36h apart is treated as day-scale: that catches every-other-day, weekly, + * bi-weekly and monthly cadences while keeping daily (24h) and intraday + * multi-dose schedules on the minute scale. Derived from the REALISED gap, not + * `doseCadenceFamily`'s field-shape heuristic (audit MEDIUM-6). + */ +const DAY_SCALE_GAP_THRESHOLD_MS = 36 * HOUR_MS; + +/** Which band shape the minter chose for a schedule. */ +export type BandFamily = "daily" | "weekly" | "one_shot" | "none"; + +/** The medication projection the minter reads (pre-fetched, no DB access). */ +export interface BandMinterMedication { + id: string; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; +} + +/** + * Per-dose window override. When absent the minter derives the on-time width + + * late tail from `DOSE_WINDOW_DEFAULTS` keyed on the realised family. A caller + * can widen the on-time band per Marc's configurable-window lever. + */ +export interface DoseWindowConfig { + /** Daily on-time half-width around the slot instant (minutes). */ + dailyOnTimeMinutes?: number; + /** Daily late tail past the on-time window (minutes). */ + dailyOverdueMinutes?: number; + /** Weekly / rolling on-time half-width around the slot day (days). */ + weeklyOnTimeDays?: number; + /** Weekly / rolling late tail past the on-time day (days). */ + weeklyOverdueDays?: number; +} + +export interface BuildBandsInput { + medication: BandMinterMedication; + schedule: CanonicalSchedule; + ctx: RecurrenceContext; + userTz: string; + /** The [from, to] window to mint expected slots over (inclusive). */ + range: { from: Date; to: Date }; + /** Wall-clock reference, threaded to the rolling retrospective expansion. */ + now: Date; + /** Per-dose window override; defaults to `DOSE_WINDOW_DEFAULTS`. */ + windowConfig?: DoseWindowConfig; + /** + * Non-skipped intake instants for THIS medication. Required for a rolling + * cadence (the retrospective grid anchors AT each intake); ignored by every + * other cadence. Pre-fetched by the caller — the minter has no DB access. + */ + intakeInstants?: Date[]; +} + +export interface BandMinterResult { + bands: SlotBand[]; + /** False for PRN / empty / malformed schedules (no slot machinery). */ + hasExpectedSlots: boolean; + /** The band shape chosen — surfaced for diagnostics + the UI label. */ + family: BandFamily; +} + +/** One schedule's band group, for a multi-schedule medication. */ +export interface ScheduleBandGroup extends BandMinterResult { + scheduleId: string; +} + +/** + * Build the `SlotBand[]` for one schedule of one medication over `range`. + * + * The single per-schedule entry point. A multi-schedule medication routes + * each schedule through here independently (see `buildBandsForSchedules`) so a + * sparse cadence's late tail is never clipped by a denser sibling's anchor. + */ +export function buildBandsForMedication( + input: BuildBandsInput, +): BandMinterResult { + const { medication, schedule, ctx, userTz, range, now } = input; + + // PRN / empty / malformed → no slot machinery. The caller renders the + // intakes as plain entries. `occurrencesBetween` already returns [] for PRN; + // we also short-circuit a schedule that describes no cadence at all (no + // rrule, no rolling, no legacy weekday/time, not one-shot) so a malformed or + // empty schedule never silently mints a stray daily band. + if (schedule.scheduleType === "PRN" || !describesACadence(schedule, medication)) { + return { bands: [], hasExpectedSlots: false, family: "none" }; + } + + // One-shot — a single WIDE whole-day on-time band that never auto-misses. + if (medication.oneShot) { + const bands = buildOneShotBands(schedule, ctx, userTz, range); + // A one-shot whose anchor lies outside the window mints no band here, but + // it IS a scheduled dose — keep `hasExpectedSlots` true. + return { bands, hasExpectedSlots: true, family: "one_shot" }; + } + + // Rolling (GLP-1) — retrospective bands anchored AT each logged intake. + if (schedule.rollingIntervalDays !== null) { + const occ = expandRollingRetrospective( + schedule, + ctx, + range.from, + range.to, + input.intakeInstants ?? [], + now, + ); + // Rolling is inherently day-scale (every-N-days). Mint day-scale bands. + const bands = mintBands(occ, "weekly", input.windowConfig, userTz); + return { bands, hasExpectedSlots: true, family: "weekly" }; + } + + // Daily / weekly / cyclic / rrule — `occurrencesBetween` already filters + // cyclic off-week slots. Classify the family by the REALISED inter-slot gap + // probed over a padded window (so a sparse cadence yields ≥2 slots), NOT the + // field-shape `doseCadenceFamily` heuristic. + const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx); + const family = realisedFamily(schedule, ctx, range); + const bands = mintBands(occurrences, family, input.windowConfig, userTz); + return { bands, hasExpectedSlots: true, family }; +} + +/** + * Build per-schedule band groups for a multi-schedule medication. Each + * schedule is banded independently — the keystone invariant that keeps a + * weekly injection's 4-day tail from being clipped by a daily oral's anchor. + */ +export function buildBandsForSchedules(input: { + medication: BandMinterMedication; + schedules: CanonicalSchedule[]; + ctx: RecurrenceContext; + userTz: string; + range: { from: Date; to: Date }; + now: Date; + windowConfig?: DoseWindowConfig; + /** Per-medication intake instants (the rolling anchors). */ + intakeInstants?: Date[]; +}): ScheduleBandGroup[] { + return input.schedules.map((schedule) => { + const result = buildBandsForMedication({ + medication: input.medication, + schedule, + ctx: input.ctx, + userTz: input.userTz, + range: input.range, + now: input.now, + windowConfig: input.windowConfig, + intakeInstants: input.intakeInstants, + }); + return { scheduleId: schedule.id, ...result }; + }); +} + +// ──────────────────────────────────────────────────────────────────── +// Band construction +// ──────────────────────────────────────────────────────────────────── + +/** + * Turn engine occurrences into `SlotWindowInput`s of the given family and run + * them through `buildSlotBands` (which resolves + caps the late tails so + * adjacent slots never both claim one intake). + */ +function mintBands( + occurrences: Occurrence[], + family: "daily" | "weekly", + windowConfig: DoseWindowConfig | undefined, + userTz: string, +): SlotBand[] { + const w = resolveWindows(windowConfig); + const inputs: SlotWindowInput[] = occurrences.map((occ) => + family === "weekly" + ? weeklyWindow(occ, w, userTz) + : dailyWindow(occ, w), + ); + return buildSlotBands(inputs); +} + +/** Minute-scale band around a slot instant (daily / intraday cadences). */ +function dailyWindow( + occ: Occurrence, + w: ResolvedWindows, +): SlotWindowInput { + return { + at: occ.at, + timeOfDay: occ.timeOfDay, + onTimeStart: new Date(occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS), + onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS), + lateGraceMs: w.dailyOverdueMinutes * MINUTE_MS, + }; +} + +/** + * Day-scale band around a slot instant (weekly / rolling / sparse cadences). + * The ±N-day bounds are minted via `localHmAsUtc` on the calendar days N days + * before / after the slot's local day — DST-correct, unlike `±N·DAY_MS`, which + * drifts an hour across a transition (audit MEDIUM-8). + */ +function weeklyWindow( + occ: Occurrence, + w: ResolvedWindows, + userTz: string, +): SlotWindowInput { + const { hour, minute } = localHourMinute(occ.timeOfDay); + const onTimeStart = localHmAsUtc( + new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS), + userTz, + hour, + minute, + ); + const onTimeEnd = localHmAsUtc( + new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS), + userTz, + hour, + minute, + ); + // The late tail is also day-scale: the instant N overdue-days past the + // on-time end, minted on the calendar day for DST-correctness, then expressed + // as a millisecond grace `buildSlotBands` consumes. + const overdueAnchor = localHmAsUtc( + new Date(onTimeEnd.getTime() + w.weeklyOverdueDays * DAY_MS), + userTz, + hour, + minute, + ); + const lateGraceMs = Math.max(0, overdueAnchor.getTime() - onTimeEnd.getTime()); + return { + at: occ.at, + timeOfDay: occ.timeOfDay, + onTimeStart, + onTimeEnd, + lateGraceMs, + }; +} + +/** + * One-shot — a single whole-day on-time band per `timesOfDay` anchor. The + * band spans the local day start..end so a take any time that day is on-time, + * and the late tail is zero (a one-shot never auto-misses; an unfilled slot + * simply reads `upcoming` until the day closes and then stays a never-acted + * slot rather than a hard miss). + */ +function buildOneShotBands( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + userTz: string, + range: { from: Date; to: Date }, +): SlotBand[] { + const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx); + const inputs: SlotWindowInput[] = occurrences.map((occ) => { + const dayStart = localHmAsUtc(occ.at, userTz, 0, 0); + // End at the next local midnight (start of the following day) so a 23:59 + // take is still inside the band. + const nextDayStart = localHmAsUtc( + new Date(dayStart.getTime() + 25 * HOUR_MS), + userTz, + 0, + 0, + ); + return { + at: occ.at, + timeOfDay: occ.timeOfDay, + onTimeStart: dayStart, + onTimeEnd: nextDayStart, + lateGraceMs: 0, + }; + }); + return buildSlotBands(inputs); +} + +// ──────────────────────────────────────────────────────────────────── +// Family classification (realised inter-slot gap) +// ──────────────────────────────────────────────────────────────────── + +/** + * Classify the schedule's family from the REALISED inter-slot gap rather than + * the field-shape `doseCadenceFamily` heuristic (which mislabels a legacy + * Mon/Thu `daysOfWeek` med as daily ±60min). Probe a window padded to ≥16 + * weeks so a sparse cadence yields ≥2 slots and a real gap is observable; the + * minimum consecutive gap decides the scale. + */ +function realisedFamily( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, + range: { from: Date; to: Date }, +): "daily" | "weekly" { + const probeDays = Math.max( + Math.ceil((range.to.getTime() - range.from.getTime()) / DAY_MS), + 16 * 7, + ); + const probeFrom = new Date(range.to.getTime() - probeDays * DAY_MS); + const slots = occurrencesBetween(schedule, probeFrom, range.to, ctx); + if (slots.length < 2) { + // Too few slots to measure a gap. Fall back to the field-shape signal: + // an explicit rolling/weekly/monthly shape is day-scale, else daily. + return fieldShapeFamily(schedule); + } + const times = slots + .map((s) => s.at.getTime()) + .sort((a, b) => a - b); + let minGap = Infinity; + for (let i = 1; i < times.length; i++) { + const gap = times[i] - times[i - 1]; + if (gap > 0 && gap < minGap) minGap = gap; + } + if (!Number.isFinite(minGap)) return fieldShapeFamily(schedule); + return minGap >= DAY_SCALE_GAP_THRESHOLD_MS ? "weekly" : "daily"; +} + +/** Field-shape fallback when too few slots exist to measure a realised gap. */ +function fieldShapeFamily(schedule: CanonicalSchedule): "daily" | "weekly" { + if ( + schedule.rollingIntervalDays !== null && + schedule.rollingIntervalDays >= 2 + ) { + return "weekly"; + } + if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(schedule.rrule ?? "")) { + return "weekly"; + } + return "daily"; +} + +// ──────────────────────────────────────────────────────────────────── +// Cadence detection + small helpers +// ──────────────────────────────────────────────────────────────────── + +/** + * True when the schedule describes ANY cadence the engine can expand — a + * one-shot, a rolling interval, an rrule, or a legacy `daysOfWeek`/`timesOfDay` + * shape. A schedule with none of these (a stray/empty row) is treated like PRN: + * no expected slots. Keeps a malformed row from minting a phantom daily band. + */ +function describesACadence( + schedule: CanonicalSchedule, + medication: BandMinterMedication, +): boolean { + if (medication.oneShot) return true; + if (schedule.rollingIntervalDays !== null) return true; + if (schedule.rrule !== null && schedule.rrule.trim() !== "") { + // A malformed rrule describes no usable cadence — `expandRrule` swallows + // the parse error and returns no slots, so without this gate the schedule + // would read `hasExpectedSlots: true` with an empty band list (audit + // CRITICAL-3: a bad rrule must fall to the no-slot-machinery path). + return isParseableRrule(schedule.rrule); + } + if (schedule.daysOfWeek !== null && schedule.daysOfWeek.trim() !== "") { + return true; + } + if (schedule.timesOfDay.length > 0) return true; + return false; +} + +/** True when the bare RRULE body parses (mirrors `expandRrule`'s try/catch). */ +function isParseableRrule(rrule: string): boolean { + try { + RRule.fromString(`RRULE:${rrule}`); + return true; + } catch { + return false; + } +} + +interface ResolvedWindows { + dailyOnTimeMinutes: number; + dailyOverdueMinutes: number; + weeklyOnTimeDays: number; + weeklyOverdueDays: number; +} + +/** Merge the caller's per-dose overrides over `DOSE_WINDOW_DEFAULTS`. */ +function resolveWindows(config: DoseWindowConfig | undefined): ResolvedWindows { + return { + dailyOnTimeMinutes: + config?.dailyOnTimeMinutes ?? DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes, + dailyOverdueMinutes: + config?.dailyOverdueMinutes ?? DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes, + weeklyOnTimeDays: + config?.weeklyOnTimeDays ?? DOSE_WINDOW_DEFAULTS.weeklyOnTimeDays, + weeklyOverdueDays: + config?.weeklyOverdueDays ?? DOSE_WINDOW_DEFAULTS.weeklyOverdueDays, + }; +} + +/** + * Parse an "HH:mm" slot label into hour/minute, defaulting to the slot's own + * local time when the label is malformed. The engine always sets a parseable + * `timeOfDay`, but the day-scale bound minting must never crash the read path. + */ +function localHourMinute(timeOfDay: string): { hour: number; minute: number } { + const [hStr, mStr] = timeOfDay.split(":"); + const h = Number(hStr); + const m = Number(mStr); + if (!Number.isFinite(h) || !Number.isFinite(m)) return { hour: 0, minute: 0 }; + return { hour: h, minute: m }; +} From 56bc46417e278ae1f8101378c919f1af91d377b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 17:30:31 +0200 Subject: [PATCH 04/31] =?UTF-8?q?fix(medications):=20keep=20degenerate=20?= =?UTF-8?q?=E2=89=A41-day=20rolling=20on=20the=20minute=20scale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review: a rolling interval of 1 day is effectively daily, so a ±1-day day-scale on-time window would over-widen and let one take claim adjacent slots. Classify rolling by its interval — ≥2 days is day-scale (the canonical weekly+ injection), ≤1 day stays minute-scale. Two tests pin the distinction. --- .../scheduling/__tests__/band-minter.test.ts | 30 +++++++++++++++++++ src/lib/medications/scheduling/band-minter.ts | 12 ++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/lib/medications/scheduling/__tests__/band-minter.test.ts b/src/lib/medications/scheduling/__tests__/band-minter.test.ts index af94cfdd2..e2975302f 100644 --- a/src/lib/medications/scheduling/__tests__/band-minter.test.ts +++ b/src/lib/medications/scheduling/__tests__/band-minter.test.ts @@ -224,6 +224,36 @@ describe("rolling GLP-1 (every 7 days) — retrospective bands", () => { }); }); +describe("rolling — a ≤1-day interval stays minute-scale (daily family)", () => { + // A degenerate every-1-day rolling interval is effectively daily; a ±1-day + // on-time window would over-widen, so it must classify daily (minute-scale). + const m = med({ startsOn: new Date("2026-06-01T00:00:00Z") }); + const sched = schedule({ rollingIntervalDays: 1, timesOfDay: ["08:00"] }); + const shot = new Date("2026-06-08T08:05:00Z"); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m, shot), + userTz: TZ, + range: { + from: new Date("2026-06-07T00:00:00Z"), + to: new Date("2026-06-09T00:00:00Z"), + }, + now: new Date("2026-06-09T00:00:00Z"), + intakeInstants: [shot], + }); + + it("uses the daily family for a 1-day rolling cadence", () => { + expect(out.family).toBe("daily"); + }); + + it("the on-time band is minute-scale (±60min), not day-scale", () => { + const b = out.bands[0]; + const span = b.onTimeEnd.getTime() - b.onTimeStart.getTime(); + expect(span).toBe(2 * 60 * MIN); + }); +}); + // ──────────────────────────────────────────────────────────────────── // 4. Cyclic (on/off weeks) // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index d7e44f7ac..4d62ba424 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -165,9 +165,15 @@ export function buildBandsForMedication( input.intakeInstants ?? [], now, ); - // Rolling is inherently day-scale (every-N-days). Mint day-scale bands. - const bands = mintBands(occ, "weekly", input.windowConfig, userTz); - return { bands, hasExpectedSlots: true, family: "weekly" }; + // The canonical rolling case is a once-weekly+ injection (day-scale). A + // degenerate ≤1-day rolling interval is effectively daily, so a ±1-day + // on-time window would over-widen and let one take claim adjacent slots — + // keep it minute-scale. The realised every-N-days cadence is the field + // here (intakes drive the anchors), so the interval is the right signal. + const family: "daily" | "weekly" = + schedule.rollingIntervalDays >= 2 ? "weekly" : "daily"; + const bands = mintBands(occ, family, input.windowConfig, userTz); + return { bands, hasExpectedSlots: true, family }; } // Daily / weekly / cyclic / rrule — `occurrencesBetween` already filters From 79e7c866e47d3561fdfa10f4af4e2af99556eb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 17:50:06 +0200 Subject: [PATCH 05/31] fix(medications): unify compliance % and per-dose status onto one dose-history ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medication compliance percentage and the history view drew their per-dose attribution from four different matchers — the ±12h pairDoses proximity used by calculateCompliance, the deriveDoseStatus bands (open dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a dose could read "taken late" in the rate while the history called it an off-schedule ad-hoc take. Route the rate through the shared cadence-aware band minter + reconstructDoseHistory so the percentage is a tally over the SAME ledger the history view renders. The same intake can no longer be taken in the % and ad-hoc in the ledger. - calculateCompliance: when a medication context is supplied, the count fields (taken / skipped / missed / rate) come from tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a late dose still counts as taken — the taking-vs-timing split is kept available); denominator = taken + missed; skipped, ad-hoc, upcoming and whole PRN groups are excluded; rate capped at 100. The streak still walks the cadence timeline. Context-less pure-math callers keep the legacy timeline tally byte-stable. - complianceChips + missedDoses: adherence rate + missed count come from the same ledger when an engine context is threaded. - /api/medications/[id]/compliance heatmap: the per-day timing split and missed marks are bucketed from the one ledger instead of the per-day classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree with the headline rate. - medication_compliance_rollups stays a raw cross-medication daily coverage count, documented as distinct from the band-attributed % (the band minter is per-cadence and cannot run inside a single SQL tally). Downstream readers (health score, insight compliance status) keep calling calculateCompliance; their numbers shift to the honest band-attributed values. Some currently-counted off-time takes (e.g. an 11:29 take of a 07:00 dose) now read ad-hoc with the slot missed rather than taken-late — the configurable per-dose window is the lever to widen the on-time band. Tests: the irregular twice-daily, single-time-late heatmap, and legacy parity fixtures are updated with rationale for the intended number changes; new tally tests pin the taking/timing split, the skip / ad-hoc / upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling retrospective families, and the cross-consistency invariant. --- .../[id]/compliance/__tests__/route.test.ts | 23 +- .../api/medications/[id]/compliance/route.ts | 378 ++++++++------- .../analytics/__tests__/compliance.test.ts | 443 ++++++++++++++++-- src/lib/analytics/compliance.ts | 257 +++++++++- src/lib/medications/scheduling/cadence.ts | 99 ++++ src/lib/medications/scheduling/compliance.ts | 142 +++++- .../rollups/medication-compliance-rollups.ts | 19 + 7 files changed, 1128 insertions(+), 233 deletions(-) diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index ae9e21ed9..4541af588 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -169,7 +169,15 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { expect(entry!.onTime).toBe(2); }); - it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => { + it("a 12h-late dose is an ad-hoc take + a missed 07:00 slot (band model)", async () => { + // v1.15.18 — the heatmap now buckets the unified dose-history ledger. A + // single 07:00 daily dose logged at 19:00 is 12h outside the 07:00 slot's + // on-time band (±60min) AND its late tail (to ~10:00), so it is an AD-HOC + // off-schedule take — a real logged dose (taken / on-time tone), NOT a + // "very late" attribution onto the 07:00 slot. The 07:00 slot itself reads + // missed (no intake inside its window past its cutoff). This is the audit's + // intent: the cell no longer mislabels an off-schedule take as a punctuality + // grade against a slot it never belonged to. const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET); const dateKey = userDayKey(dayStart, TZ); @@ -179,7 +187,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { id: "sched-1", windowStart: "07:00", windowEnd: "08:00", - timesOfDay: [], // single-time: candidate = [windowStart] + timesOfDay: [], // single-time daily: the minter mints a 07:00 band daysOfWeek: null, rrule: null, rollingIntervalDays: null, @@ -191,7 +199,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { ]) as never, ); - // Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late. + // Single 07:00 schedule, dose logged at 19:00 → outside every band → ad-hoc. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ { takenAt: atSlotUTC(dayStart, "19:00"), @@ -203,9 +211,14 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { const daily = await callRoute(); const entry = daily[dateKey] as Record | undefined; expect(entry).toBeDefined(); - expect(entry!.veryLate).toBe(1); - expect(entry!.onTime).toBe(0); + // The off-schedule take reads as a logged dose; no late-attribution. + expect(entry!.veryLate).toBe(0); expect(entry!.late).toBe(0); + expect(entry!.onTime).toBe(1); + expect(entry!.taken).toBe(1); + // The 07:00 slot itself is missed (uncovered, past its cutoff). + // expected = the missed slot + the ad-hoc take's own slot. + expect(entry!.expected).toBe(2); }); it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => { diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 4a1cc2e85..ef845778a 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -6,83 +6,128 @@ import { buildComplianceDisplay, buildComplianceMedicationContext, calculateCompliance, - classifyIntakeTiming, - expectedSlotCountForDay, lastNonSkippedTakenAt, } from "@/lib/analytics/compliance"; import type { DailyComplianceEntry } from "@/lib/analytics/compliance"; +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type DoseHistoryRow, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import { + type CanonicalSchedule, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; -import { getUserTodayBounds } from "@/lib/timezone"; import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -/** A single effective dose slot with its derived punctuality window. */ -interface SlotWindow { - /** Minute-of-day of the slot's start time (used for nearest-slot match). */ - startMinutes: number; - /** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */ +type MedicationScheduleRow = { + id: string; + rrule: string | null; + rollingIntervalDays: number | null; + timesOfDay: string[]; + daysOfWeek: string | null; windowStart: string; - /** "HH:mm" slot end = slot start + the schedule's window span. */ windowEnd: string; -} + reminderGraceMinutes: number | null; + scheduleType: "SCHEDULED" | "PRN" | "CYCLIC"; + cyclicOnWeeks: number | null; + cyclicOffWeeks: number | null; +}; -const MINUTES_PER_DAY = 24 * 60; - -/** Parse "HH:mm" into minute-of-day. */ -function hhmmToMinutes(time: string): number { - const [h, m] = time.split(":").map(Number); - return h * 60 + m; -} - -/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */ -function minutesToHHmm(minutes: number): string { - const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % - MINUTES_PER_DAY; - const h = Math.floor(wrapped / 60); - const m = wrapped % 60; - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +/** + * v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the + * band minter consumes. A legacy daily row carrying only `windowStart` + * surfaces it as the single `timeOfDay` so the minter mints its daily band. + */ +function toCanonicalForBands( + s: MedicationScheduleRow, + oneShot: boolean, +): CanonicalSchedule { + const base: CanonicalSchedule = { + id: s.id, + rrule: s.rrule, + rollingIntervalDays: s.rollingIntervalDays, + timesOfDay: s.timesOfDay, + daysOfWeek: s.daysOfWeek, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + reminderGraceMinutes: s.reminderGraceMinutes, + scheduleType: s.scheduleType, + cyclicOnWeeks: s.cyclicOnWeeks, + cyclicOffWeeks: s.cyclicOffWeeks, + }; + if ( + base.timesOfDay.length === 0 && + base.rrule === null && + base.rollingIntervalDays === null && + base.scheduleType !== "PRN" && + !oneShot + ) { + return { ...base, timesOfDay: [base.windowStart] }; + } + return base; } /** - * v1.8.1 B15 follow-on — expand every schedule into its effective dose - * slots so timing classification matches against the right time-of-day. + * v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry. * - * Candidate slot times for a schedule are its `timesOfDay` when non-empty, - * else `[windowStart]` (so single-time schedules collapse to one slot at - * `windowStart` and stay byte-identical to the pre-fix matcher). Each - * slot's punctuality window is anchored at the slot time and spans the - * schedule's `windowEnd − windowStart` duration; overnight windows - * (`windowEnd ≤ windowStart`) add a day to the span before deriving the - * per-slot end. Multi-schedule meds union their slots — the matcher then - * picks the single closest slot across all of them. + * Slot rows contribute to `expected` / `expectedCount` (the day's due-slot + * count): a taken slot adds to `taken` and its timing bucket; a missed slot + * stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming + * slot is still due but not yet acted on. An ad-hoc row is a real off-schedule + * take — it counts as `taken` AND adds its own `expected` slot (so the + * heatmap's `missed = expected − taken − skipped` math stays non-negative) and + * reads on-time (a logged dose colours green). */ -function buildSlotCandidates( - schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[], -): SlotWindow[] { - const slots: SlotWindow[] = []; - for (const sched of schedules) { - const startMin = hhmmToMinutes(sched.windowStart); - let endMin = hhmmToMinutes(sched.windowEnd); - // Overnight window: the end is on the following day. - if (endMin <= startMin) endMin += MINUTES_PER_DAY; - const spanMinutes = endMin - startMin; - - const times = - sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart]; - - for (const time of times) { - const slotStartMin = hhmmToMinutes(time); - slots.push({ - startMinutes: slotStartMin, - windowStart: minutesToHHmm(slotStartMin), - // `classifyIntakeTiming` re-detects the overnight wrap from the - // HH:mm pair, so a wrapped end formats back into 0..1439 cleanly. - windowEnd: minutesToHHmm(slotStartMin + spanMinutes), - }); - } +function bucketLedgerRow( + entry: DailyComplianceEntry, + row: DoseHistoryRow, +): void { + switch (row.status) { + case "taken_on_time": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; + case "taken_late": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.late++; + break; + case "missed": + entry.expected++; + entry.expectedCount++; + break; + case "skipped": + entry.expected++; + entry.expectedCount++; + entry.skipped++; + break; + case "upcoming": + // A future / still-takeable slot is due but not yet acted on. It counts + // toward the day's expected/due grid but not toward taken or missed. + entry.expected++; + entry.expectedCount++; + break; + case "ad_hoc": + // An off-schedule take: a real taken dose with no scheduled slot. Count + // it as taken + its own expected slot so the heatmap missed math holds. + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; } - return slots; } export const GET = apiHandler( @@ -165,134 +210,103 @@ export const GET = apiHandler( { now }, ); - // Build daily compliance map for heatmap/line chart (90 days) + // v1.15.18 — the heatmap / line-chart daily map is now bucketed from the + // ONE unified dose-history ledger (the same bands the % + the history view + // read), so the per-day timing split (on-time / late) and the per-day + // missed marks can never disagree with the headline rate. The legacy + // per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount + // ForDay` walk are retired here in favour of band membership: a take inside + // a slot's on-time band reads on-time, inside its late tail reads late, + // outside every band reads ad-hoc; an unfilled slot past its miss cutoff + // reads missed; a future slot is upcoming (not yet due). const dailyCompliance: Record = {}; - for (let d = 0; d < 90; d++) { - // v1.7.0 code-correctness M1 — anchor each day's [start,end) on the - // user-tz local-day boundary, not a UTC-midnight slice. The engine - // applies `timesOfDay` in the user timezone, so for tz-distant users - // a UTC slice could attach `due` / `expectedCount` to the adjacent - // calendar cell. Anchor on a noon-local representative instant - // (dodges the DST midnight-ambiguity edge) and key off the user-tz - // day so the heatmap cell, the engine frame, and `dateKey` agree. - const representative = new Date( - now.getTime() - d * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000, - ); - const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( - representative, - userTz, - ); - // `getUserTodayBounds` returns an inclusive end (local 23:59:59.999); - // the slicing loops below use a half-open `[dayStart, dayEnd)`. - const dayEnd = new Date(dayEndInclusive.getTime() + 1); + // Build the bands across the whole 90-day heatmap window once. The window + // floor clamps to the medication's creation so pre-existence days never + // mint phantom slots. + const DAY_MS = 24 * 60 * 60 * 1000; + const windowFrom = new Date( + Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), + ); + const bandMedication: BandMinterMedication = { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + }; + const bandCtx: RecurrenceContext = { + medication: { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + }, + timeZone: userTz, + lastIntakeAt, + }; + const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map( + (s) => toCanonicalForBands(s, medication.oneShot), + ); + const intakeInstants = mapped + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + const groups = buildBandsForSchedules({ + medication: bandMedication, + schedules: canonicalSchedules, + ctx: bandCtx, + userTz, + range: { from: windowFrom, to: now }, + now, + intakeInstants, + }); + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + const historyIntakes: HistoryIntake[] = mapped + .filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + })); + const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now); - // Skip days before medication was created - if (dayEnd <= createdAt) continue; - - const dateKey = userDayKey(dayStart, userTz); - - const dayEvents = mapped.filter( - (e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd, - ); - - const takenEvents = dayEvents.filter( - (e) => e.takenAt !== null && !e.skipped, - ); - - // Classify timing for each taken event against the best-matching schedule - let onTime = 0; - let late = 0; - let veryLate = 0; - let early = 0; - - // v1.8.1 B15 follow-on — match each taken event to the closest - // effective time-of-day SLOT, not merely `windowStart`. A single - // schedule row carrying `timesOfDay = ["07:00","19:00"]` is two - // distinct dose slots; matching the evening dose against the lone - // `windowStart` "07:00" mis-classified it as `very_late` (~12h late) - // and painted the heatmap cell orange even when both doses were - // taken on time. Candidate slots for a schedule are its `timesOfDay` - // when non-empty, else `[windowStart]` — so single-time schedules - // collapse to exactly one candidate at `windowStart` and stay - // byte-identical to the pre-fix behaviour. The per-slot window - // mirrors the projector / cadence anchor: effective windowStart is - // the slot time, effective windowEnd is the slot time plus the - // schedule's `windowEnd − windowStart` span (overnight spans where - // `windowEnd ≤ windowStart` add a day). - const slotCandidates = buildSlotCandidates(medication.schedules); - - for (const evt of takenEvents) { - if (slotCandidates.length === 0) { - // No schedule info: treat all taken as on_time - onTime++; - continue; - } - - // Match event to the closest slot window by scheduledFor time. - const evtHour = evt.scheduledFor.getUTCHours(); - const evtMin = evt.scheduledFor.getUTCMinutes(); - const evtMinutes = evtHour * 60 + evtMin; - - let bestSlot = slotCandidates[0]; - let bestDist = Infinity; - - for (const slot of slotCandidates) { - const dist = Math.abs(evtMinutes - slot.startMinutes); - if (dist < bestDist) { - bestDist = dist; - bestSlot = slot; - } - } - - const timing = classifyIntakeTiming( - evt.takenAt, - bestSlot.windowStart, - bestSlot.windowEnd, - dayStart, // the scheduled date - ); - - // v1.4.34 IW-C — `early` is the new compliant bucket; it counts - // alongside `onTime` for the heatmap so a proactive logger reads - // green. The classifier still emits a distinct `"early"` value - // for downstream consumers that want to differentiate; the - // separate counter is surfaced on the daily entry below. - if (timing === "on_time") onTime++; - else if (timing === "early") early++; - else if (timing === "late") late++; - else veryLate++; + // Bucket each ledger row into its user-tz day. A slot row buckets on its + // anchor; an ad-hoc row on its real take time. + const byDay = new Map(); + const ensureDay = (key: string): DailyComplianceEntry => { + let entry = byDay.get(key); + if (!entry) { + entry = { + expected: 0, + expectedCount: 0, + due: false, + taken: 0, + skipped: 0, + onTime: 0, + late: 0, + veryLate: 0, + early: 0, + }; + byDay.set(key, entry); } + return entry; + }; - // v1.7.0 item 5 — the per-day expected count is the engine's actual - // due-slot count for THIS day, not the static schedule count. iOS - // history paints a "missed" mark only when `due === true`, so - // off-weeks / non-matching weekdays / PRN days no longer show a - // false miss. `expected` is kept populated (= expectedCount) for - // existing web consumers that read it; `due` + `expectedCount` are - // the explicit additive fields iOS keys off. - const expectedCount = expectedSlotCountForDay( - medication.schedules, - dayStart, - dayEnd, - medicationContext, - // v1.13.x — pass the intake history so a ROLLING schedule's per-day - // `due` flags come from the retrospective grid (each logged dose is a - // due day) instead of the engine's single forward slot — keeping the - // heatmap, the denominator, and the displayed rate in agreement. - mapped, - ); + for (const row of ledgerRows) { + const key = userDayKey(row.at, userTz); + const entry = ensureDay(key); + bucketLedgerRow(entry, row); + } - dailyCompliance[dateKey] = { - expected: expectedCount, - expectedCount, - due: expectedCount > 0, - taken: takenEvents.length, - skipped: dayEvents.filter((e) => e.skipped).length, - onTime: onTime + early, - late, - veryLate, - early, - }; + for (const [key, entry] of byDay) { + entry.due = entry.expectedCount > 0; + dailyCompliance[key] = entry; } annotate({ diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 6e1527cce..95ee18abd 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -25,6 +25,7 @@ import { expectedSlotCountForDay, expectedSlotsBetween, lastNonSkippedTakenAt, + tallyComplianceFromLedger, type ComplianceMedicationContext, type ComplianceSchedule, } from "../compliance"; @@ -447,20 +448,67 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { }; } - it("parity: a legacy daysOfWeek-only schedule yields identical numbers with no context", () => { + it("on-time takes: the ledger scores every logged 08:00 dose as taken (100%)", () => { + // v1.15.18 — the context path now tallies the unified dose-history + // ledger (band membership) instead of the legacy ±12h proximity timeline. + // An 08:00 daily dose taken at 08:00 sits inside the ±60min on-time band → + // taken_on_time. The seven logged days all score taken; the current-day + // 08:00 slot is still inside its takeable window at NOW (12:00, the late + // tail ends at 12:00) so it reads upcoming, not missed → a clean 100%. + // (The off-time / stricter behaviour is pinned in the next test.) const schedules: ComplianceSchedule[] = [ - { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, + { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] }, ]; - const events = Array.from({ length: 7 }, (_, i) => - eventAt(new Date(NOW.getTime() - (i + 1) * DAY_MS), true), - ); - const legacy = calculateCompliance(events, schedules, 7); + const onTime = (d: Date) => ({ + scheduledFor: d, + takenAt: d, + skipped: false, + }); + const events = Array.from({ length: 7 }, (_, i) => { + const day = new Date(NOW.getTime() - (i + 1) * DAY_MS); + day.setUTCHours(8, 0, 0, 0); + return onTime(day); + }); const withCtx = calculateCompliance(events, schedules, 7, undefined, { medicationContext: ctx(), }); - // Legacy daysOfWeek-only schedule has no canonical fields → both - // paths run the legacy walker → identical numbers. - expect(withCtx).toEqual(legacy); + // The window floors at NOW − 7d (Jan-8 12:00), so Jan-8's 08:00 dose falls + // just before it; the six in-window logged doses (Jan-9..Jan-14) all score + // taken and the current-day slot is still upcoming → 100%. + expect(withCtx.taken).toBe(6); + expect(withCtx.missed).toBe(0); + expect(withCtx.rate).toBe(100); + }); + + it("off-time take: the ledger is stricter than the legacy ±12h proximity matcher", () => { + // v1.15.18 — the keystone behaviour change. A daily 08:00 dose logged at + // 12:30 (4.5h late) USED to count as taken under the ±12h `pairDoses` + // proximity matcher. The unified band model attributes it honestly: 12:30 + // is outside the 08:00 slot's on-time band (07:00–09:00) AND its late tail + // (to ~12:00), so it is an AD-HOC take and the slot reads MISSED. The + // percentage now agrees with the history view (the dose cannot read + // "taken" in the % while the ledger calls it ad-hoc). + const schedules: ComplianceSchedule[] = [ + { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] }, + ]; + // takenAt = 08:00 slot + 4.5h = 12:30 (eventAt adds 30min onto a 12:00 + // scheduledFor; here we pin the off-time take explicitly). + const events = Array.from({ length: 7 }, (_, i) => { + const day = new Date(NOW.getTime() - (i + 1) * DAY_MS); + day.setUTCHours(8, 0, 0, 0); + return { + scheduledFor: day, + takenAt: new Date(day.getTime() + 4.5 * 60 * 60 * 1000), + skipped: false, + }; + }); + const withCtx = calculateCompliance(events, schedules, 7, undefined, { + medicationContext: ctx(), + }); + // Every off-time take is ad-hoc → its slot is missed → 0% taken. + expect(withCtx.taken).toBe(0); + expect(withCtx.missed).toBeGreaterThanOrEqual(6); + expect(withCtx.rate).toBe(0); }); it("FREQ=WEEKLY;BYDAY=MO — took every Monday → 100%, denominator counts only Mondays", () => { @@ -2163,39 +2211,40 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", ( return new Date(`2025-01-${String(day).padStart(2, "0")}T${hhmm}:00Z`); } - it("snaps two off-time same-day takes to BOTH slots → 2 taken, not 1 taken + 1 missed", () => { - // The crux of BUG 3: a day with a late-morning take (09:13) and an - // evening take (19:00) must read as two taken doses. The morning take - // pairs to the 07:00 slot (2h13m away, well inside the ±6h half-gap) and - // the evening to the 19:00 slot — NOT the morning take landing on 19:00 - // and the 07:00 slot reading falsely missed. + it("attributes two same-day takes to BOTH slots — a near-on-time morning take is taken_late, not orphaned", () => { + // v1.15.18 — the band model attributes each take to its OWN slot. A + // morning take at 08:13 (07:00 slot + 1h13m) is inside the 07:00 slot's + // late tail (07:00 + 60min on-time + 180min tail → to 10:00), so it reads + // taken_late and does NOT orphan the 07:00 slot onto the 19:00 row. The + // evening 19:00 take is on-time. The crux of the original BUG-3 fix + // survives the move off the ±6h snap: a same-day morning + evening take + // never collapse into one taken + one falsely-missed. // - // A 2-day window so BOTH of Jan-14's slots fall inside [NOW-2d, NOW] - // (a 1-day window starts at noon Jan-14, which is after the 07:00 slot). + // Cover EVERY slot the window emits so there is no stray uncovered slot: + // a 3-day window (NOW − 3d = Jan-12 12:00 .. Jan-15 12:00) holds Jan-12 + // 19:00, Jan-13 07:00+19:00, Jan-14 07:00+19:00, Jan-15 07:00 = 6 slots. + const onTime = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false }); const events = [ - // 07:00 slot, logged at 09:13 (off-time, snapped onto the 07:00 row). + onTime(slot(12, "19:00")), + onTime(slot(13, "07:00")), + onTime(slot(13, "19:00")), + // 07:00 slot, logged at 08:13 (within the late tail → taken_late). { scheduledFor: slot(14, "07:00"), - takenAt: new Date(slot(14, "07:00").getTime() + (2 * 60 + 13) * 60 * 1000), + takenAt: new Date(slot(14, "07:00").getTime() + (1 * 60 + 13) * 60 * 1000), skipped: false, }, - // 19:00 slot, logged on time. - { scheduledFor: slot(14, "19:00"), takenAt: slot(14, "19:00"), skipped: false }, - // Jan-13's slots are covered too so the window has no stray miss. - { scheduledFor: slot(13, "07:00"), takenAt: slot(13, "07:00"), skipped: false }, - { scheduledFor: slot(13, "19:00"), takenAt: slot(13, "19:00"), skipped: false }, + onTime(slot(14, "19:00")), + onTime(slot(15, "07:00")), ]; - const result = calculateCompliance(events, twiceDaily, 2, undefined, { + const result = calculateCompliance(events, twiceDaily, 3, undefined, { now: NOW, medicationContext: ctxVal, }); - // Both of Jan-14's off-time/on-time takes pair to their own slot → the - // morning slot is NOT falsely missed. No missed slot anywhere → 100%. + // Every slot is covered by its own take → no miss, 100%. expect(result.missed).toBe(0); expect(result.rate).toBe(100); - // The morning take did not orphan the 07:00 slot: at least Jan-14's two - // doses both register as taken. - expect(result.taken).toBeGreaterThanOrEqual(3); + expect(result.taken).toBe(6); }); it("computes the rate as taken/(taken+missed) over a 7-day irregular pattern", () => { @@ -2437,3 +2486,337 @@ describe("doseCadenceFamily", () => { ).toBe("daily"); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.15.18 — the UNIFIED ledger tally. `tallyComplianceFromLedger` is the +// ONE authority behind the compliance %: it builds the cadence-aware bands +// per schedule, reconstructs the dose-history ledger, and tallies it so the +// % and the history view can never contradict. These tests pin the +// taking-vs-timing split, the exclusions (skip / ad-hoc / upcoming / PRN), +// auto-missed=missed, and the cross-consistency invariant that the SAME +// intake can't be "taken" in the % while the ledger calls it "ad_hoc". +// ──────────────────────────────────────────────────────────────────── + +import { localHmAsUtc } from "@/lib/timezone"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import type { CanonicalSchedule, RecurrenceContext } from "@/lib/medications/scheduling/recurrence"; + +describe("tallyComplianceFromLedger — the unified % keystone", () => { + const TZ = "Europe/Berlin"; + const dayRef = new Date("2026-06-08T12:00:00Z"); + function at(h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); + } + function ctxFor( + over: Partial = {}, + ): ComplianceMedicationContext { + return { + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-06-01T00:00:00Z"), + lastIntakeAt: null, + timeZone: TZ, + ...over, + }; + } + // Twice-daily 07:00 / 19:00, single window covering the whole day. + const twiceDaily: ComplianceSchedule[] = [ + { + windowStart: "07:00", + windowEnd: "19:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + }, + ]; + // Late evening "now" so both of the reference day's slots are past cutoff. + const nowEvening = at(23, 59); + // Window covers just the reference day. + const from = at(0, 0); + + it("a late take counts as taken (taking adherence), with the timing split exposed", () => { + // 07:00 on-time + 19:00 logged at 20:30 (inside the 180min late tail). + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(2); + expect(tally.takenOnTime).toBe(1); + expect(tally.takenLate).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.rate).toBe(100); + }); + + it("a deliberate user-skip is EXCLUDED from the denominator", () => { + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: true }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(1); + expect(tally.skipped).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.denominator).toBe(1); // taken + missed, skip excluded + expect(tally.rate).toBe(100); + }); + + it("an auto-missed forgotten dose IS a miss (counts against the rate)", () => { + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { + scheduledFor: at(19, 0), + takenAt: null, + skipped: false, + autoMissed: true, + }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(1); + expect(tally.skipped).toBe(0); + expect(tally.rate).toBe(50); + }); + + it("an off-schedule (ad-hoc) take is EXCLUDED + leaves its slot missed", () => { + // Marc's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc; + // the 07:00 slot is missed; the 19:00 slot is also missed (untouched). + const events = [ + { scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.adHoc).toBe(1); + // The ad-hoc take is NOT in taken; both slots are missed. + expect(tally.taken).toBe(0); + expect(tally.missed).toBe(2); + // Denominator = taken + missed; ad-hoc never enters it. + expect(tally.denominator).toBe(2); + expect(tally.rate).toBe(0); + }); + + it("a future slot is upcoming — excluded, never a premature miss", () => { + // Early morning now: both slots still ahead of their miss cutoff. + const earlyNow = at(5, 0); + const tally = tallyComplianceFromLedger( + [], + twiceDaily, + ctxFor(), + from, + earlyNow, + earlyNow, + ); + expect(tally.taken).toBe(0); + expect(tally.missed).toBe(0); + expect(tally.denominator).toBe(0); + expect(tally.rate).toBe(100); // empty-window contract + }); + + it("a PRN schedule contributes NO denominator (every intake excluded)", () => { + const prn: ComplianceSchedule[] = [ + { + windowStart: "07:00", + windowEnd: "19:00", + daysOfWeek: null, + timesOfDay: ["07:00"], + scheduleType: "PRN", + }, + ]; + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(14, 0), takenAt: at(14, 0), skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + prn, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + // PRN → hasExpectedSlots false → no bands → every take ad-hoc, no slots. + expect(tally.taken).toBe(0); + expect(tally.missed).toBe(0); + expect(tally.adHoc).toBe(2); + expect(tally.denominator).toBe(0); + expect(tally.rate).toBe(100); + }); + + it("the rate is capped at 100% (extra doses never inflate it)", () => { + // Both slots taken + an ad-hoc extra. Ad-hoc is excluded so the rate is a + // clean 100, never >100. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false }, + { scheduledFor: at(13, 0), takenAt: at(13, 0), skipped: false }, // extra + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(2); + expect(tally.adHoc).toBe(1); + expect(tally.rate).toBe(100); + }); + + it("CROSS-CONSISTENCY: the same intake cannot be taken in the % and ad_hoc in the ledger", () => { + // The keystone invariant. Build the exact bands the tally builds, run the + // SAME reconstructDoseHistory, and assert the tally's per-status counts + // equal the ledger's per-status counts — so a dose graded "taken" by the % + // is a "taken_*" row in the history view, and an "ad_hoc" row never leaks + // into the taken numerator. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, // on-time + { scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false }, // late + { scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, // ad-hoc + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + + // Re-derive the ledger independently the same way the tally does. + const medication: BandMinterMedication = { + id: "x", + startsOn: ctxFor().startsOn, + endsOn: null, + oneShot: false, + createdAt: ctxFor().createdAt, + }; + const recurrenceCtx: RecurrenceContext = { + medication: { + id: "x", + startsOn: ctxFor().startsOn, + endsOn: null, + oneShot: false, + createdAt: ctxFor().createdAt, + }, + timeZone: TZ, + lastIntakeAt: null, + }; + const canonical: CanonicalSchedule = { + id: "s", + rrule: null, + rollingIntervalDays: null, + timesOfDay: ["07:00", "19:00"], + daysOfWeek: null, + windowStart: "07:00", + windowEnd: "19:00", + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + const groups = buildBandsForSchedules({ + medication, + schedules: [canonical], + ctx: recurrenceCtx, + userTz: TZ, + range: { from, to: nowEvening }, + now: nowEvening, + intakeInstants: events + .filter((e) => !e.skipped && e.takenAt) + .map((e) => e.takenAt as Date), + }); + const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : [])); + const intakes: HistoryIntake[] = events.map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: false, + })); + const rows = reconstructDoseHistory(bands, intakes, nowEvening); + + const fromLedger = { + onTime: rows.filter((r) => r.status === "taken_on_time").length, + late: rows.filter((r) => r.status === "taken_late").length, + missed: rows.filter((r) => r.status === "missed").length, + adHoc: rows.filter((r) => r.status === "ad_hoc").length, + }; + expect(tally.takenOnTime).toBe(fromLedger.onTime); + expect(tally.takenLate).toBe(fromLedger.late); + expect(tally.missed).toBe(fromLedger.missed); + expect(tally.adHoc).toBe(fromLedger.adHoc); + // And the load-bearing claim: the ad-hoc take is NOT counted as taken. + expect(tally.taken).toBe(fromLedger.onTime + fromLedger.late); + expect(tally.adHoc).toBe(1); + }); + + it("rolling weekly (GLP-1) — each logged shot is a taken slot (retrospective family)", () => { + const rolling: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const shot1 = new Date("2026-05-04T09:12:00Z"); + const shot2 = new Date("2026-05-11T18:40:00Z"); + const shot3 = new Date("2026-05-18T07:55:00Z"); + const now = new Date("2026-05-20T12:00:00Z"); + const events = [shot1, shot2, shot3].map((d) => ({ + scheduledFor: d, + takenAt: d, + skipped: false, + })); + const tally = tallyComplianceFromLedger( + events, + rolling, + ctxFor({ + startsOn: new Date("2026-05-01T00:00:00Z"), + createdAt: new Date("2026-05-01T00:00:00Z"), + lastIntakeAt: shot3, + }), + new Date("2026-05-01T00:00:00Z"), + now, + now, + ); + // Each irregular shot anchors its own retrospective band → all taken. + expect(tally.taken).toBeGreaterThanOrEqual(3); + expect(tally.rate).toBeGreaterThanOrEqual(90); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index de336999b..d6f5c0d23 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -19,6 +19,12 @@ * daily compliance heatmap on `/api/medications/[id]/compliance`. */ +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, + type DoseWindowConfig, +} from "@/lib/medications/scheduling/band-minter"; import { buildCadenceTimeline, type CadenceEngineContext, @@ -26,6 +32,10 @@ import { type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; import { expandRollingRetrospective, nextOccurrenceAfter, @@ -1094,6 +1104,177 @@ function soonestCadenceFamily( return "daily"; } +/** + * v1.15.18 — the unified compliance tally over the dose-history ledger. + * + * THE single source of the medication compliance %. It builds the + * cadence-aware `SlotBand[]` per schedule (the shared band minter — every + * cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic, + * PRN), reconstructs the ONE dose-history ledger + * (`reconstructDoseHistory`), and tallies it so the percentage and the + * history view are mathematically incapable of contradicting each other + * (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher + * that `calculateCompliance` used for the engine-routed path. + * + * The tally follows the adherence literature's TAKING-vs-TIMING split: + * - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is + * still a taken dose; "late" is NOT collapsed into "missed"; + * - denominator = taken + `missed`; + * - EXCLUDED from the denominator: `skipped` (deliberate user decision), + * `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the + * window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false` + * — PRN has no defensible denominator per the literature); + * - the rate is capped at 100% (extra doses never inflate it). + * + * The on-time / late split is surfaced separately so a caller can show both + * the TAKING rate (the headline) and the TIMING quality. + * + * Pure / synchronous: the bands are minted from pre-fetched schedules + + * intake instants; no DB access. + */ +export interface LedgerComplianceTally { + /** Doses taken (on-time + late). The TAKING-adherence numerator. */ + taken: number; + /** Of `taken`, the count inside the on-time band. */ + takenOnTime: number; + /** Of `taken`, the count in the late tail (still counts as taken). */ + takenLate: number; + /** Expected doses never acted on past their miss cutoff. */ + missed: number; + /** Deliberate user skips — excluded from the denominator. */ + skipped: number; + /** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */ + adHoc: number; + /** taken + missed (the rate denominator). */ + denominator: number; + /** round(100 · taken / denominator), capped at 100. 100 on empty. */ + rate: number; +} + +export function tallyComplianceFromLedger( + events: IntakeEvent[], + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + from: Date, + to: Date, + now: Date, + windowConfig?: DoseWindowConfig, +): LedgerComplianceTally { + const medication: BandMinterMedication = { + id: "compliance-tally", + startsOn: ctx.startsOn, + endsOn: ctx.endsOn, + oneShot: ctx.oneShot, + createdAt: ctx.createdAt, + }; + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); + const canonicalSchedules = schedules.map((s, i) => { + const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); + // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, + // no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy` + // reads that as "every day at windowStart", but the band minter's + // cadence-detection gate needs an explicit time signal — surface + // `windowStart` as the single time-of-day so the daily band is minted. + if ( + canonical.timesOfDay.length === 0 && + canonical.rrule === null && + canonical.rollingIntervalDays === null && + canonical.scheduleType !== "PRN" && + !ctx.oneShot + ) { + return { ...canonical, timesOfDay: [canonical.windowStart] }; + } + return canonical; + }); + // Rolling cadences anchor their retrospective grid AT each logged intake; + // the bands need every non-skipped take in (or before) the window. Reuse + // the same instant-extraction the legacy rolling path uses so the + // numerator and denominator are built from one expansion. + const intakeInstants = intakeInstantsAtOrBefore( + events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })), + to, + ); + + const groups = buildBandsForSchedules({ + medication, + schedules: canonicalSchedules, + ctx: recurrenceCtx, + userTz: ctx.timeZone, + range: { from, to }, + now, + windowConfig, + intakeInstants, + }); + + // The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The + // bands already partition the slot space, so the union of every + // non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct + // DoseHistory` claims each slot by at most one intake, so pooling the + // (already correctly-minted) bands is safe. PRN groups (no expected + // slots) contribute no bands; their intakes surface as ad-hoc and are + // excluded from the denominator, exactly as the literature requires. + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const rows = reconstructDoseHistory(bands, intakes, now); + + let takenOnTime = 0; + let takenLate = 0; + let missed = 0; + let skipped = 0; + let adHoc = 0; + for (const row of rows) { + switch (row.status) { + case "taken_on_time": + takenOnTime++; + break; + case "taken_late": + takenLate++; + break; + case "missed": + missed++; + break; + case "skipped": + skipped++; + break; + case "ad_hoc": + adHoc++; + break; + // `upcoming` slots are future / still-takeable → excluded from every + // counter so a partial head-of-window day never pollutes the rate. + } + } + + const taken = takenOnTime + takenLate; + const denominator = taken + missed; + const rate = + denominator > 0 + ? Math.min(100, Math.round((taken / denominator) * 100)) + : 100; + + return { + taken, + takenOnTime, + takenLate, + missed, + skipped, + adHoc, + denominator, + rate, + }; +} + /** * Calculate compliance for a medication over a given period. * @@ -1168,6 +1349,41 @@ export function calculateCompliance( Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS), ); + // v1.15.18 — when a medication context is supplied, the count fields + // (taken / skipped / missed / rate) come from the UNIFIED dose-history + // ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the + // keystone unification: the percentage is a tally over the exact same + // ledger the history view renders, so the two can never contradict (a + // dose can't read "taken late" in the % while the ledger calls it + // "ad-hoc"). The numerator is on-time + late takes (a late dose still + // counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded + // from the denominator. The streak still walks the cadence timeline below + // (its day-grain "every dose taken or skipped" rule is unchanged). + // + // Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the + // legacy timeline tally byte-stable — they have no engine context to mint + // bands from. + const ledgerCtx = options?.medicationContext; + let ledgerCounts: + | { taken: number; skipped: number; missed: number; rate: number } + | null = null; + if (ledgerCtx) { + const tally = tallyComplianceFromLedger( + events, + schedules, + ledgerCtx, + effectiveStart, + now, + now, + ); + ledgerCounts = { + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + rate: tally.rate, + }; + } + // Normalise the schedule shape so legacy callers that pass only // `{ windowStart, windowEnd }` still produce a usable `daysOfWeek` // field (treated as daily by the cadence parser). @@ -1252,23 +1468,36 @@ export function calculateCompliance( retro, ); - let taken = 0; - let skipped = 0; - let missed = 0; - for (const slot of timeline) { - if (slot.status === "taken") taken++; - else if (slot.status === "skipped") skipped++; - else if (slot.status === "missed") missed++; - // `upcoming` slots (future window) are excluded from every counter - // so a partial day at the head of the window doesn't pollute the rate. + // v1.15.18 — the count fields come from the unified ledger tally when a + // medication context was supplied (the keystone unification), and from the + // legacy timeline tally otherwise (context-less pure-math callers). The + // streak below always walks the timeline — its day-grain rule is orthogonal + // to the per-dose attribution and stays byte-stable for every caller. + let taken: number; + let skipped: number; + let missed: number; + let rate: number; + if (ledgerCounts) { + ({ taken, skipped, missed, rate } = ledgerCounts); + } else { + taken = 0; + skipped = 0; + missed = 0; + for (const slot of timeline) { + if (slot.status === "taken") taken++; + else if (slot.status === "skipped") skipped++; + else if (slot.status === "missed") missed++; + // `upcoming` slots (future window) are excluded from every counter + // so a partial day at the head of the window doesn't pollute the rate. + } + // Skipped doses are excluded from the denominator — they represent a + // deliberate user decision rather than a missed dose. + const denom = taken + missed; + rate = + denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; } const totalExpected = taken + skipped + missed; - // Skipped doses are excluded from the denominator — they represent a - // deliberate user decision rather than a missed dose. - const denom = taken + missed; - const rate = - denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100; // Streak: consecutive days, ending today, where every expected dose // was taken or skipped. Days with no expected dose advance the diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 5288000a2..63b6bff73 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -28,6 +28,15 @@ */ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; import { type CanonicalSchedule, type RecurrenceContext, @@ -790,6 +799,19 @@ export function missedDoses( timeZone?: string, engineCtx?: CadenceEngineContext, ): number { + // v1.15.18 — when an engine context is threaded the missed count comes from + // the unified band ledger (the same one the % and the history view read), so + // every "missed" surface agrees. Pure-math callers (no context) keep the + // legacy timeline-status count. + const ledger = missedFromLedger( + schedules, + events, + asOf, + windowDays, + timeZone, + engineCtx, + ); + if (ledger !== null) return ledger; const timeline = buildCadenceTimeline( schedules, events, @@ -801,3 +823,80 @@ export function missedDoses( ); return timeline.filter((d) => d.status === "missed").length; } + +/** + * v1.15.18 — the count of `missed` rows in the unified dose-history ledger + * over the trailing window. Returns null when no engine context is supplied + * (the caller then uses the legacy timeline tally). Shares the band minter + + * `reconstructDoseHistory` with the compliance % so the numbers agree. + */ +function missedFromLedger( + schedules: ScheduleLike[], + events: IntakeEventLike[], + asOf: Date, + windowDays: number, + timeZone: string | undefined, + engineCtx: CadenceEngineContext | undefined, +): number | null { + if (!engineCtx) return null; + const from = new Date(asOf.getTime() - windowDays * DAY_MS); + const userTz = engineCtx.timeZone || timeZone || "UTC"; + const medication: BandMinterMedication = { + id: "missed-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }; + const recurrenceCtx: RecurrenceContext = { + medication: { + id: "missed-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }, + timeZone: userTz, + lastIntakeAt: engineCtx.lastIntakeAt, + }; + const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { + const base = toCanonical(s, i); + if ( + base.timesOfDay.length === 0 && + base.rrule === null && + base.rollingIntervalDays === null && + base.scheduleType !== "PRN" && + !engineCtx.oneShot + ) { + return { ...base, timesOfDay: [base.windowStart] }; + } + return base; + }); + const intakeInstants = events + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + const groups = buildBandsForSchedules({ + medication, + schedules: canonicalSchedules, + ctx: recurrenceCtx, + userTz, + range: { from, to: asOf }, + now: asOf, + intakeInstants, + }); + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + const rows = reconstructDoseHistory(bands, intakes, asOf); + return rows.filter((r) => r.status === "missed").length; +} diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index cb816f44c..e06061609 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -19,6 +19,11 @@ * legacy system-local behaviour the W19e tests pin. */ +import type { SlotBand } from "./attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, +} from "./band-minter"; import { buildCadenceTimeline, startOfLocalDay, @@ -27,6 +32,14 @@ import { type PairedDose, type ScheduleLike, } from "./cadence"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "./dose-history"; +import { + type CanonicalSchedule, + type RecurrenceContext, +} from "./recurrence"; export interface ComplianceChips { /** 0-100, taken / (taken + missed). Skipped doses are excluded from @@ -142,6 +155,116 @@ export function streaksFromTimeline( return { current, longest }; } +/** + * v1.15.18 — band-ledger tally counts for the detail-page chips. + * + * When the route threads an engine context (RRULE / rolling / cyclic / + * one-shot / PRN / legacy) the adherence rate + missed count come from the + * SAME unified dose-history ledger the compliance % and the history view + * read — so the chips can never contradict either. The streak still walks + * the cadence timeline (its day-grain "every dose taken or skipped" rule is + * orthogonal to per-dose attribution). Returns null when there is no engine + * context (a pure-math caller) — the caller then falls back to the legacy + * timeline tally. + */ +function ledgerChipCounts( + schedules: ScheduleLike[], + events: IntakeEventLike[], + asOf: Date, + windowDays: number, + timeZone: string | undefined, + engineCtx: CadenceEngineContext | undefined, +): { taken: number; missed: number } | null { + if (!engineCtx) return null; + const DAY_MS = 24 * 60 * 60 * 1000; + const from = new Date(asOf.getTime() - windowDays * DAY_MS); + const userTz = engineCtx.timeZone || timeZone || "UTC"; + + const medication: BandMinterMedication = { + id: "chips-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }; + const recurrenceCtx: RecurrenceContext = { + medication: { + id: "chips-tally", + startsOn: engineCtx.startsOn, + endsOn: engineCtx.endsOn, + oneShot: engineCtx.oneShot, + createdAt: engineCtx.createdAt, + }, + timeZone: userTz, + lastIntakeAt: engineCtx.lastIntakeAt, + }; + const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { + const base: CanonicalSchedule = { + id: s.id ?? `chips-${i}`, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + timesOfDay: s.timesOfDay ?? [], + daysOfWeek: s.daysOfWeek ?? null, + windowStart: s.windowStart, + windowEnd: s.windowEnd, + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + scheduleType: s.scheduleType ?? "SCHEDULED", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + }; + // Surface windowStart as the single time-of-day for a legacy daily row + // so the band minter's cadence gate mints its daily band (see the + // analytics/compliance tally for the same normalisation). + if ( + base.timesOfDay.length === 0 && + base.rrule === null && + base.rollingIntervalDays === null && + base.scheduleType !== "PRN" && + !engineCtx.oneShot + ) { + return { ...base, timesOfDay: [base.windowStart] }; + } + return base; + }); + + const intakeInstants = events + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + + const groups = buildBandsForSchedules({ + medication, + schedules: canonicalSchedules, + ctx: recurrenceCtx, + userTz, + range: { from, to: asOf }, + now: asOf, + intakeInstants, + }); + const bands: SlotBand[] = []; + for (const g of groups) { + if (g.hasExpectedSlots) bands.push(...g.bands); + } + + const intakes: HistoryIntake[] = events + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf) + .map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + })); + + const rows = reconstructDoseHistory(bands, intakes, asOf); + let taken = 0; + let missed = 0; + for (const row of rows) { + if (row.status === "taken_on_time" || row.status === "taken_late") taken++; + else if (row.status === "missed") missed++; + } + return { taken, missed }; +} + export function complianceChips( schedules: ScheduleLike[], events: IntakeEventLike[], @@ -160,8 +283,23 @@ export function complianceChips( timeZone, engineCtx, ); - const taken = timeline.filter((d) => d.status === "taken").length; - const missed = timeline.filter((d) => d.status === "missed").length; + // v1.15.18 — adherence + missed come from the unified band ledger when an + // engine context is present (so the chips agree with the % and the history + // view); the legacy timeline tally is the fallback for pure-math callers. + const ledger = ledgerChipCounts( + schedules, + events, + asOf, + windowDays, + timeZone, + engineCtx, + ); + const taken = ledger + ? ledger.taken + : timeline.filter((d) => d.status === "taken").length; + const missed = ledger + ? ledger.missed + : timeline.filter((d) => d.status === "missed").length; const denom = taken + missed; const adherenceRate = denom === 0 ? null : Math.round((taken / denom) * 100); const { current, longest } = streaksFromTimeline( diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 8127197be..7abbec4cb 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -14,6 +14,25 @@ * coverage probe falls back to live + fires a boot backfill when a * user has intake events but zero rollup rows. * + * v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count, + * NOT the band-attributed compliance %. It aggregates, across ALL of a + * user's medications, "of the intake rows whose `scheduled_for` lands on + * this day, how many carry a non-null `taken_at`" — a coarse daily + * cross-medication coverage strip for the dashboard mini-tiles. The + * unified band-membership attribution (the `tallyComplianceFromLedger` + * keystone) is per-medication and per-cadence: it depends on the slot + * bands of each schedule and the user timezone, which cannot be expressed + * in a single cross-medication SQL `SUM(CASE …)`. Re-attributing every + * intake against its cadence's bands before the tally would mean running + * the band minter per `(medication, day)` inside the rollup, which defeats + * the rollup's whole purpose (bounded SQL, no per-row Node walk). The + * detail page + the medication card already read the band-attributed % via + * `calculateCompliance` / `complianceChips`; this tier deliberately remains + * the cheaper coverage signal and is documented as such so the two are not + * confused. The `scheduled` / `taken` column names reflect this: a day's + * `taken` is "intake rows logged taken", not "doses inside their on-time + * band". + * * Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4. */ import { prisma } from "@/lib/db"; From 7b7253b989d2028d9277c5a80dac44ab7cf879ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:07:31 +0200 Subject: [PATCH 06/31] feat(medications): band-model slot attribution for the intake write/edit paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a shared write-side attributor that runs the SAME cadence-aware band minter the read ledger and the compliance % consume, so a take is bound to a slot identically wherever the decision is made. `attributeTakenToSlot` maps a takenAt onto a slot by dose-window membership (on-time / late) or returns null for an off-window ad-hoc take; `resolveForcedSlotInstant` validates a client-pinned slot against the real band anchors. The DB-loading resolvers (`resolveSlotForWriteByBand`, `resolveForcedSlotForWrite`) load the medication's schedules + rolling anchors and run the pure core. This retires the wide ±6h nearest-snap (`snapToleranceMs`) that pulled an 11:29 take onto the 07:00 slot and a 13:02 take onto the 19:00 slot. --- .../__tests__/attribute-intake.test.ts | 178 ++++++++++++++ .../scheduling/attribute-intake.ts | 228 ++++++++++++++++++ src/lib/medications/scheduling/slot-upsert.ts | 187 ++++++++++++++ 3 files changed, 593 insertions(+) create mode 100644 src/lib/medications/scheduling/__tests__/attribute-intake.test.ts create mode 100644 src/lib/medications/scheduling/attribute-intake.ts diff --git a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts new file mode 100644 index 000000000..84f2e9ba9 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts @@ -0,0 +1,178 @@ +/** + * v1.15.18 — WRITE/EDIT-path band attribution. + * + * `attributeTakenToSlot` is the write-side counterpart to the read-side + * ledger: both run the SAME shared band minter so a take is bound to a slot + * identically wherever it is decided. These tests pin the behaviour the audit + * called out (HIGH-5: the ±6h snap fully gone) + the late-take force-attribute + * guard (Marc's "diesem Slot zuordnen?" nudge): + * + * - on-time take → its own slot anchor; + * - clearly off-window → ad-hoc (null), NOT the nearest snapped slot; + * - late-but-in-tail → the slot, status late; + * - PRN / no schedule → always ad-hoc (hasExpectedSlots false); + * - force-attribute → resolves only a REAL slot anchor, never an + * arbitrary instant. + */ +import { describe, expect, it } from "vitest"; + +import { + attributeTakenToSlot, + resolveForcedSlotInstant, + type AttributeIntakeMedication, +} from "../attribute-intake"; +import type { WorkerScheduleRow } from "../worker-helpers"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; + +function schedule(over: Partial = {}): WorkerScheduleRow { + return { + id: "sched-1", + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: [], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...over, + }; +} + +function med( + schedules: WorkerScheduleRow[], + over: Partial = {}, +): AttributeIntakeMedication { + return { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules, + ...over, + }; +} + +/** Berlin wall-clock HH:mm on the day implied by `dayRef`. */ +function at(dayRef: Date, h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); +} + +const DAY = new Date("2026-06-08T12:00:00Z"); // CEST + +describe("attributeTakenToSlot — twice-daily 07:00 / 19:00 (Marc's case)", () => { + const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]); + + it("snaps an on-time morning take to the 07:00 slot", () => { + const takenAt = at(DAY, 7, 5); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant?.getTime()).toBe(at(DAY, 7, 0).getTime()); + expect(out.status).toBe("on_time"); + }); + + it("treats the 11:29 take as ad-hoc, NOT a snapped 07:00 dose (HIGH-5)", () => { + // The retired ±6h snap pulled 11:29 onto 07:00. Band membership orphans it. + const takenAt = at(DAY, 11, 29); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant).toBeNull(); + expect(out.status).toBeNull(); + expect(out.hasExpectedSlots).toBe(true); + }); + + it("treats a 13:02 take as ad-hoc, never the 19:00 slot (HIGH-5)", () => { + const takenAt = at(DAY, 13, 2); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant).toBeNull(); + }); + + it("snaps a slightly-late evening take to the 19:00 slot as late", () => { + // 19:00 + 90min is inside the daily late tail (180min default). + const takenAt = at(DAY, 20, 30); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant?.getTime()).toBe(at(DAY, 19, 0).getTime()); + expect(out.status).toBe("late"); + }); +}); + +describe("attributeTakenToSlot — PRN / unscheduled", () => { + it("returns ad-hoc with hasExpectedSlots false for a PRN schedule", () => { + const m = med([schedule({ scheduleType: "PRN", timesOfDay: [] })]); + const out = attributeTakenToSlot({ + medication: m, + userTz: TZ, + takenAt: at(DAY, 14, 0), + }); + expect(out.slotInstant).toBeNull(); + expect(out.hasExpectedSlots).toBe(false); + }); + + it("returns ad-hoc for a medication with no schedules", () => { + const m = med([]); + const out = attributeTakenToSlot({ + medication: m, + userTz: TZ, + takenAt: at(DAY, 7, 0), + }); + expect(out.slotInstant).toBeNull(); + expect(out.hasExpectedSlots).toBe(false); + }); +}); + +describe("attributeTakenToSlot — DST correctness", () => { + it("snaps a winter (CET) on-time take to its slot", () => { + const winterDay = new Date("2026-01-15T12:00:00Z"); // CET + const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]); + const takenAt = at(winterDay, 19, 10); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant?.getTime()).toBe(at(winterDay, 19, 0).getTime()); + expect(out.status).toBe("on_time"); + }); +}); + +describe("resolveForcedSlotInstant — the late-take nudge guard", () => { + const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]); + + it("resolves a real slot anchor the client pins onto", () => { + const slot = at(DAY, 7, 0); + const out = resolveForcedSlotInstant({ + medication: m, + userTz: TZ, + slotInstant: slot, + }); + expect(out?.getTime()).toBe(slot.getTime()); + }); + + it("tolerates sub-minute client drift on the pinned anchor", () => { + const slot = new Date(at(DAY, 19, 0).getTime() + 30_000); + const out = resolveForcedSlotInstant({ + medication: m, + userTz: TZ, + slotInstant: slot, + }); + expect(out?.getTime()).toBe(at(DAY, 19, 0).getTime()); + }); + + it("refuses an arbitrary off-slot instant (cannot pin a non-slot)", () => { + const out = resolveForcedSlotInstant({ + medication: m, + userTz: TZ, + slotInstant: at(DAY, 11, 29), + }); + expect(out).toBeNull(); + }); + + it("refuses any pin for a PRN medication (no slots)", () => { + const prn = med([schedule({ scheduleType: "PRN", timesOfDay: [] })]); + const out = resolveForcedSlotInstant({ + medication: prn, + userTz: TZ, + slotInstant: at(DAY, 7, 0), + }); + expect(out).toBeNull(); + }); +}); diff --git a/src/lib/medications/scheduling/attribute-intake.ts b/src/lib/medications/scheduling/attribute-intake.ts new file mode 100644 index 000000000..9d329562e --- /dev/null +++ b/src/lib/medications/scheduling/attribute-intake.ts @@ -0,0 +1,228 @@ +/** + * v1.15.18 — band-model intake→slot attribution for the WRITE + EDIT paths. + * + * The write-side counterpart to `tallyComplianceFromLedger` (the read-side + * ledger): both consume the SAME shared band minter so a take is bound to a + * slot identically wherever it is decided. This retires the wide ±6h + * nearest-snap in `resolve-slot-instant.ts` (`snapToleranceMs`) that pulled a + * 13:02 take onto the 19:00 slot and a 15:46 take onto the 07:00 slot. + * + * Given a medication's schedules, the user's timezone, a `takenAt`, and the + * pre-fetched rolling-anchor instants, `attributeTakenToSlot` mints the day's + * `SlotBand[]` (per schedule, never pooled into one minter call — audit + * MEDIUM-9) and runs `attributeIntakeToSlot`: + * + * - the take lands in a slot's band → that slot's canonical anchor instant + * (`band.at`), which the caller writes as `scheduledFor` (snap to slot); + * - the take lands in no band → `null`: an ad-hoc / PRN take the + * caller inserts standalone with `scheduledFor = takenAt`. + * + * PRN / empty / malformed schedules mint no bands (`hasExpectedSlots:false`) + * and therefore always return `null` — every PRN intake is a standalone row. + * + * Pure / synchronous: no DB access. The caller `select`s the schedule columns + * + threads `lastIntakeAt` / `intakeInstants` in (mirrors + * `resolve-slot-instant.ts`). + */ +import { + attributeIntakeToSlot, + type SlotBand, +} from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, + type DoseWindowConfig, +} from "@/lib/medications/scheduling/band-minter"; +import { + buildCanonicalSchedule, + buildRecurrenceContext, + type WorkerMedicationRow, + type WorkerScheduleRow, +} from "@/lib/medications/scheduling/worker-helpers"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** The medication projection the attributor reads (matches the write `select`). */ +export interface AttributeIntakeMedication extends WorkerMedicationRow { + schedules: WorkerScheduleRow[]; +} + +export interface AttributeTakenInput { + medication: AttributeIntakeMedication; + userTz: string; + /** The real intake instant whose slot we resolve. */ + takenAt: Date; + /** + * Latest non-tombstoned `takenAt` for the medication — anchors the rolling + * retrospective grid's next-due. Pass `null` when unknown / not rolling. + */ + lastIntakeAt?: Date | null; + /** + * Non-skipped intake instants for the medication, ascending. REQUIRED for a + * rolling cadence (the retrospective grid anchors AT each intake); ignored + * otherwise. + */ + intakeInstants?: Date[]; + /** Per-dose window override; defaults to `DOSE_WINDOW_DEFAULTS`. */ + windowConfig?: DoseWindowConfig; + /** Wall-clock reference; defaults to `takenAt`. Injectable for tests. */ + now?: Date; +} + +export interface AttributeTakenResult { + /** + * The canonical slot anchor the take attributes to, or `null` for an ad-hoc + * / PRN take (the caller inserts it standalone with `scheduledFor=takenAt`). + */ + slotInstant: Date | null; + /** on_time / late when matched, null when ad-hoc. */ + status: "on_time" | "late" | null; + /** False for PRN / empty / malformed medications (no slot machinery). */ + hasExpectedSlots: boolean; +} + +/** + * Build the day's bands for every schedule of a medication, pooled into one + * `SlotBand[]`. Bands are minted PER SCHEDULE (no tail clipping across + * schedules), then pooled — each slot is claimed by ≤1 intake downstream, so + * the union is safe (mirrors `tallyComplianceFromLedger`). + */ +export function buildMedicationDayBands(input: { + medication: AttributeIntakeMedication; + userTz: string; + /** The instant whose local day's slots are minted (the take's own time). */ + around: Date; + lastIntakeAt?: Date | null; + intakeInstants?: Date[]; + windowConfig?: DoseWindowConfig; + now?: Date; +}): { bands: SlotBand[]; hasExpectedSlots: boolean } { + const schedules = input.medication.schedules ?? []; + if (schedules.length === 0) { + return { bands: [], hasExpectedSlots: false }; + } + + const bandMinterMedication: BandMinterMedication = { + id: input.medication.id, + startsOn: input.medication.startsOn, + endsOn: input.medication.endsOn, + oneShot: input.medication.oneShot, + createdAt: input.medication.createdAt, + }; + const ctx = buildRecurrenceContext({ + medication: input.medication, + userTz: input.userTz, + lastIntakeAt: input.lastIntakeAt ?? null, + }); + const canonicalSchedules = schedules.map(buildCanonicalSchedule); + + // Mint over the take's local day padded a day each side so a slot near the + // local-midnight boundary (and any DST shift) is still captured — mirrors + // `resolve-slot-instant.ts`. A weekly/rolling band reaches further; its + // minter widens the probe internally, so the padded day window is enough to + // surface the anchor that the take could pair to. + const from = new Date(input.around.getTime() - ONE_DAY_MS); + const to = new Date(input.around.getTime() + ONE_DAY_MS); + + const groups = buildBandsForSchedules({ + medication: bandMinterMedication, + schedules: canonicalSchedules, + ctx, + userTz: input.userTz, + range: { from, to }, + now: input.now ?? input.around, + windowConfig: input.windowConfig, + intakeInstants: input.intakeInstants, + }); + + const bands: SlotBand[] = []; + let hasExpectedSlots = false; + for (const g of groups) { + if (g.hasExpectedSlots) { + hasExpectedSlots = true; + bands.push(...g.bands); + } + } + return { bands, hasExpectedSlots }; +} + +/** + * Attribute a TAKEN intake to its scheduled slot via band membership, or + * `null` when it falls in no band (ad-hoc / PRN). + */ +export function attributeTakenToSlot( + input: AttributeTakenInput, +): AttributeTakenResult { + const { bands, hasExpectedSlots } = buildMedicationDayBands({ + medication: input.medication, + userTz: input.userTz, + around: input.takenAt, + lastIntakeAt: input.lastIntakeAt, + intakeInstants: input.intakeInstants, + windowConfig: input.windowConfig, + now: input.now ?? input.takenAt, + }); + + if (!hasExpectedSlots) { + return { slotInstant: null, status: null, hasExpectedSlots: false }; + } + + const matched = attributeIntakeToSlot(input.takenAt, bands); + if (!matched) { + return { slotInstant: null, status: null, hasExpectedSlots: true }; + } + return { + slotInstant: matched.band.at, + status: matched.status, + hasExpectedSlots: true, + }; +} + +/** + * Validate that `slotInstant` is a real scheduled slot of `medication` on its + * own local day — the server-side guard behind the "diesem Slot zuordnen?" + * late-take nudge. A client may pin an off-window take onto a chosen slot, but + * only onto a slot the schedule actually mints (never an arbitrary instant). + * Returns the canonical band anchor (which the caller writes as + * `scheduledFor`) when the instant matches a band anchor within a minute, else + * `null`. + */ +export function resolveForcedSlotInstant(input: { + medication: AttributeIntakeMedication; + userTz: string; + /** The slot instant the client asks to pin the take onto. */ + slotInstant: Date; + lastIntakeAt?: Date | null; + intakeInstants?: Date[]; + windowConfig?: DoseWindowConfig; + now?: Date; +}): Date | null { + const { bands, hasExpectedSlots } = buildMedicationDayBands({ + medication: input.medication, + userTz: input.userTz, + around: input.slotInstant, + lastIntakeAt: input.lastIntakeAt, + intakeInstants: input.intakeInstants, + windowConfig: input.windowConfig, + now: input.now ?? input.slotInstant, + }); + if (!hasExpectedSlots) return null; + + const target = input.slotInstant.getTime(); + let best: { at: Date; dist: number } | null = null; + for (const band of bands) { + const dist = Math.abs(band.at.getTime() - target); + if (dist <= ANCHOR_MATCH_EPSILON_MS && (best === null || dist < best.dist)) { + best = { at: band.at, dist }; + } + } + return best?.at ?? null; +} + +/** + * Sub-minute slop for matching a client-supplied force-attribute instant to a + * real band anchor. The client echoes a slot anchor it read from the + * dose-history / today endpoints; sub-minute iOS-vs-server drift must still + * resolve, but an arbitrary off-slot instant must not. + */ +const ANCHOR_MATCH_EPSILON_MS = 60_000; diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 85a135037..88d0d6d4b 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -19,6 +19,11 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; +import { + attributeTakenToSlot, + resolveForcedSlotInstant, + type AttributeIntakeMedication, +} from "@/lib/medications/scheduling/attribute-intake"; import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; @@ -387,3 +392,185 @@ export async function resolveSlotInstantForWrite( now: input.now, }); } + +/** + * v1.15.18 — load a medication's schedules + rolling anchors and adapt them to + * the shared band attributor's projection. Mirrors `resolveSlotInstantForWrite` + * but returns the full medication so the caller can run band attribution AND + * the force-attribute guard from one load. + */ +async function loadAttributeMedication(input: { + userId: string; + medicationId: string; + client: PrismaLike; +}): Promise<{ + medication: AttributeIntakeMedication; + lastIntakeAt: Date | null; +} | null> { + const medication = await input.client.medication.findFirst({ + where: { id: input.medicationId, userId: input.userId }, + select: MEDICATION_SELECT, + }); + if (!medication || medication.schedules.length === 0) return null; + + // Rolling cadence anchors off the last logged intake — fetch it only when a + // schedule actually needs it (mirrors the projector's gate). + let lastIntakeAt: Date | null = null; + if (medication.schedules.some((s) => s.rollingIntervalDays !== null)) { + const lastIntake = await input.client.medicationIntakeEvent.findFirst({ + where: { + userId: input.userId, + medicationId: input.medicationId, + deletedAt: null, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }); + lastIntakeAt = lastIntake?.takenAt ?? null; + } + + return { + medication: { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + schedules: medication.schedules as WorkerScheduleRow[], + }, + lastIntakeAt, + }; +} + +export interface ResolveSlotByBandInput { + userId: string; + medicationId: string; + userTz: string; + /** The real intake instant the slot is resolved from. */ + takenAt: Date; + /** Reference "now"; defaults to the current time. */ + now?: Date; + /** Inject a Prisma client/tx in tests; defaults to the app client. */ + client?: PrismaLike; +} + +export interface ResolveSlotByBandResult { + /** Canonical slot anchor the take attributes to, or null (ad-hoc / PRN). */ + slotInstant: Date | null; + /** on_time / late when matched, null when ad-hoc. */ + status: "on_time" | "late" | null; + /** False for PRN / empty / malformed medications (no slot machinery). */ + hasExpectedSlots: boolean; +} + +/** + * v1.15.18 — band-model slot resolution for the intake WRITE/EDIT paths, + * replacing the wide ±6h `snapToleranceMs` nearest-snap. The take is bound to + * a slot by band membership against the SAME minter the read ledger + the + * compliance % consume, so the three surfaces can never disagree. `null` + * means ad-hoc / PRN → the caller inserts a standalone row with + * `scheduledFor = takenAt`. + */ +export async function resolveSlotForWriteByBand( + input: ResolveSlotByBandInput, +): Promise { + const client = input.client ?? defaultPrisma; + const loaded = await loadAttributeMedication({ + userId: input.userId, + medicationId: input.medicationId, + client, + }); + if (!loaded) { + return { slotInstant: null, status: null, hasExpectedSlots: false }; + } + + return attributeTakenToSlot({ + medication: loaded.medication, + userTz: input.userTz, + takenAt: input.takenAt, + lastIntakeAt: loaded.lastIntakeAt, + intakeInstants: await rollingIntakeInstantsIfNeeded( + loaded.medication, + input.userId, + input.medicationId, + input.takenAt, + client, + ), + now: input.now, + }); +} + +export interface ResolveForcedSlotInput { + userId: string; + medicationId: string; + userTz: string; + /** The slot instant the client asks to pin an off-window take onto. */ + slotInstant: Date; + now?: Date; + client?: PrismaLike; +} + +/** + * v1.15.18 — validate the client's "diesem Slot zuordnen?" pin: the supplied + * instant must be a REAL scheduled slot of this medication on its day (within + * a minute of a band anchor). Returns the canonical anchor on a match, or + * `null` when the instant is not a slot — the route then rejects the pin. + */ +export async function resolveForcedSlotForWrite( + input: ResolveForcedSlotInput, +): Promise { + const client = input.client ?? defaultPrisma; + const loaded = await loadAttributeMedication({ + userId: input.userId, + medicationId: input.medicationId, + client, + }); + if (!loaded) return null; + + return resolveForcedSlotInstant({ + medication: loaded.medication, + userTz: input.userTz, + slotInstant: input.slotInstant, + lastIntakeAt: loaded.lastIntakeAt, + intakeInstants: await rollingIntakeInstantsIfNeeded( + loaded.medication, + input.userId, + input.medicationId, + input.slotInstant, + client, + ), + now: input.now, + }); +} + +/** + * Fetch the non-skipped `takenAt` instants (≤ `around`) a rolling cadence + * anchors its retrospective grid on. Returns `undefined` for non-rolling + * medications so the band minter skips the (unused) work. + */ +async function rollingIntakeInstantsIfNeeded( + medication: AttributeIntakeMedication, + userId: string, + medicationId: string, + around: Date, + client: PrismaLike, +): Promise { + if (!medication.schedules.some((s) => s.rollingIntervalDays !== null)) { + return undefined; + } + const rows = await client.medicationIntakeEvent.findMany({ + where: { + userId, + medicationId, + deletedAt: null, + skipped: false, + takenAt: { not: null, lte: around }, + }, + orderBy: { takenAt: "asc" }, + select: { takenAt: true }, + }); + return rows + .map((r) => r.takenAt) + .filter((d): d is Date => d !== null); +} From c2453d6b8cdeae5e22902f78f699b96711d047b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:07:47 +0200 Subject: [PATCH 07/31] feat(medications): attribute the intake write path by dose-window band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-medication intake POST resolves a taken dose's slot through the band attributor instead of the ±6h snap: a take inside a slot's configurable dose window snaps to that slot (canonical upsert), a take outside every window records as a standalone ad-hoc row, and a PRN medication always inserts standalone. A skip still binds to its slot by the canonical scheduledFor snap. Add an optional forceSlotInstant to the intake body so the UI's late-take "diesem Slot zuordnen?" nudge can pin an off-window take onto a chosen slot; the server validates the instant is a real slot of the medication on its day and rejects a non-slot with 422. The dose-safety future-slot guard stays. --- src/app/api/medications/[id]/intake/route.ts | 91 ++++++++++++++------ src/lib/validations/medication.ts | 59 ++++++++++--- 2 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index dc4d03b66..1373fe569 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -24,6 +24,8 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, resolveSlotInstantForWrite, } from "@/lib/medications/scheduling/slot-upsert"; import { NextRequest } from "next/server"; @@ -79,8 +81,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { return returnAllZodIssues(parsed.error, 422); } - const { scheduledFor, takenAt, skipped, idempotencyKey, injectionSite } = - parsed.data; + const { + scheduledFor, + takenAt, + skipped, + idempotencyKey, + injectionSite, + forceSlotInstant, + } = parsed.data; // v1.8.5 — resolve + server-validate the optional injection site. Load // the medication's delivery form + tracking opt-in + per-medication @@ -137,32 +145,61 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { const isExplicitTaken = !skipped; const isExplicitSkip = skipped === true; - // v1.8.2 — source-agnostic slot snap. A twice-daily med carries a - // pending REMINDER row the projector/worker minted at the canonical - // `localHmAsUtc` slot instant. Without this, a manual "Genommen" write - // (source WEB) inserted a SECOND row for the slot because the unique - // key includes `source` and the iOS-vs-server `scheduledFor` can drift - // by a minute — inflating compliance to 100% and suppressing the - // "take now" prompt for a dose the user hadn't taken. Snap the write - // to the canonical slot and update the existing row in place. + // v1.15.18 — window-band slot attribution (replaces the wide ±6h + // `snapToleranceMs` nearest-snap). The take is bound to a slot by + // membership in that slot's configurable dose window — the SAME bands the + // read ledger + the compliance % consume, so the three surfaces can never + // disagree. A take that lands in no window is ad-hoc (`canonicalSlot` null) + // and records as a standalone "taken now" row; PRN meds always insert + // standalone. Resolved BEFORE the idempotency/dedup window so a scheduled + // dose routes through the slot upsert (which is itself the dedup). // - // Resolved BEFORE the idempotency/dedup window so a scheduled dose - // routes through the slot upsert (which is itself the dedup): the - // legacy 60-second window would otherwise short-circuit by returning - // the slot's pending REMINDER row WITHOUT applying the user's takenAt. - const canonicalSlot = await resolveSlotInstantForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - incoming: incomingScheduledFor, - // Only a client-supplied `scheduledFor` names a real slot. A - // `takenAt`-only / defaulted-now write must not snap across the wide - // ±halfGap window onto a far slot (phantom morning dose). - instantIsExplicit: scheduledFor !== undefined, - // Dose-safety: a taken write (any non-skip on this route) must never - // snap forward onto a future slot — see resolve-slot-instant.ts. - isTakenWrite: isExplicitTaken, - }); + // - SKIP: a skip is logged against a slot deliberately (it carries no + // `takenAt` to attribute by), so it keeps the canonical `scheduledFor` + // snap that binds it to the slot's pending REMINDER row. + // - TAKEN: attribute by `takenAt` band membership. The optional + // `forceSlotInstant` pins an off-window take onto a chosen real slot + // ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422. + let canonicalSlot: Date | null = null; + if (skipped) { + canonicalSlot = await resolveSlotInstantForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + incoming: incomingScheduledFor, + instantIsExplicit: scheduledFor !== undefined, + isTakenWrite: false, + }); + } else if (forceSlotInstant !== undefined) { + canonicalSlot = await resolveForcedSlotForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + slotInstant: forceSlotInstant, + }); + if (canonicalSlot === null) { + annotate({ + action: { name: "medication.intake.force_slot.invalid" }, + meta: { medication_id: id }, + }); + return apiError( + "forceSlotInstant is not a scheduled slot of this medication", + 422, + { errorCode: "medications.intake.force_slot.invalid" }, + ); + } + } else { + // A non-skip write on this route always carries a `takenAt` (defaulted to + // now), so `resolvedTakenAt` is non-null here; the fallback only guards the + // type. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: resolvedTakenAt ?? incomingScheduledFor, + }); + canonicalSlot = attribution.slotInstant; + } // Idempotency check (explicit key or server-side dedup window) if (idempotencyKey) { diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 3f560b1f0..a4d6d0640 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -550,6 +550,21 @@ export const intakeSchema = z .describe( "Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site.", ), + /** + * v1.15.18 — late-take "attribute anyway" pin. An off-window take that + * band attribution would otherwise orphan to an ad-hoc row can be pinned + * onto a chosen scheduled slot via the UI's "diesem Slot zuordnen?" nudge. + * The instant MUST be a real slot of this medication on its day (the + * server validates it against the band anchors); an arbitrary instant is + * rejected with 422. Absent → default band attribution. + */ + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution.", + ), }) .meta({ id: "MedicationIntakeRequest", @@ -594,18 +609,38 @@ export const listIntakeEventsSchema = z.object({ .default("all"), }); -export const updateIntakeEventSchema = z.object({ - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - skipped: z.boolean().optional(), - scheduledFor: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), -}); +export const updateIntakeEventSchema = z + .object({ + takenAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + skipped: z.boolean().optional(), + scheduledFor: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + /** + * v1.15.18 — late-take "attribute anyway" pin on the edit path. When the + * edited `takenAt` lands outside every window the UI can offer to pin the + * take onto a chosen slot; the server validates the instant is a real + * scheduled slot (422 otherwise). Absent → the edit re-runs band + * attribution on the new `takenAt`. + */ + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Absent re-runs the default window-band attribution on the edited `takenAt`.", + ), + }) + .meta({ + id: "UpdateMedicationIntakeEventRequest", + description: + "Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly.", + }); /** * v1.5.5 — bulk-delete request body. The detail-page intake-history From 9532033277ae2a60ec3349ab92feb6678573247b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:07:56 +0200 Subject: [PATCH 08/31] fix(medications): re-attribute a dose's slot when an intake edit moves it Editing a takenAt or a status toggle now re-runs window-band attribution and sets scheduledFor to the matched slot (the take's own time on a miss), instead of leaving the slot stale. A slot move tombstones the source row and routes the corrected dose through the shared canonical-slot upsert so the write is P2002-safe rather than bare-updating into an occupied slot. Both edit surfaces (the per-event PUT and the status-toggle POST) accept forceSlotInstant to pin an off-window edit onto a named real slot, rejecting a non-slot with 422. --- .../intake/[eventId]/__tests__/route.test.ts | 131 ++++++++++++++++++ .../[id]/intake/[eventId]/route.ts | 131 +++++++++++++++--- src/app/api/medications/intake/route.ts | 119 +++++++++++++--- 3 files changed, 341 insertions(+), 40 deletions(-) diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 971cebb19..8337a3b27 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -12,10 +12,15 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), delete: vi.fn(), findFirst: vi.fn(), + // v1.15.18 — the band resolver + the canonical slot upsert read these. + findMany: vi.fn(), + create: vi.fn(), }, medication: { findUnique: vi.fn(), + findFirst: vi.fn(), updateMany: vi.fn(), + update: vi.fn(), }, auditLog: { create: vi.fn() }, }, @@ -250,3 +255,129 @@ describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on s }); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.15.18 — PUT re-runs window-band slot attribution on a takenAt change +// ──────────────────────────────────────────────────────────────────── + +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +const SESSION_TZ = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const, timezone: TZ }, +}; +const ATTR_DAY = new Date("2026-06-05T12:00:00Z"); +function at(h: number, m: number): Date { + return localHmAsUtc(ATTR_DAY, TZ, h, m); +} + +/** Stub the band resolver's medication load (07:00 / 19:00 twice-daily). */ +function stubTwiceDailyMed() { + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); +} + +describe("PUT — v1.15.18 band re-attribution", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_TZ as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + oneShot: false, + active: true, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({} as never); + stubTwiceDailyMed(); + }); + + it("re-snaps the dose onto the matched slot when the edited takenAt moves it (slot move)", async () => { + // The row currently sits on the 07:00 slot; the edit moves takenAt to + // 19:05 (on-time for the 19:00 slot). The route tombstones the row + routes + // the dose through the canonical slot upsert onto the 19:00 anchor. + vi.mocked(prisma.medicationIntakeEvent.findFirst) + // PUT lookup of the edited event + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + // lifecycle liveIntake probe + .mockResolvedValue(null as never); + // applyCanonicalSlotWrite: no existing row at the 19:00 slot → create. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(19, 0), + takenAt: at(19, 5), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: at(19, 5).toISOString() }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + + // The original row was tombstoned (deletedAt set). + const tombstone = vi + .mocked(prisma.medicationIntakeEvent.update) + .mock.calls.find((c) => "deletedAt" in (c[0]?.data ?? {})); + expect(tombstone).toBeTruthy(); + // The corrected dose was written onto the 19:00 anchor. + const created = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0]?.[0]?.data; + expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime()); + }); + + it("422s a forceSlotInstant that is not a real slot", async () => { + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + + const res = await PUT( + putReq({ + takenAt: at(11, 29).toISOString(), + forceSlotInstant: at(11, 29).toISOString(), // not a slot + }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 7468eaeee..5a8a76f19 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -14,6 +14,11 @@ import { updateIntakeEventSchema } from "@/lib/validations/medication"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string; eventId: string }> }; @@ -70,20 +75,105 @@ export const PUT = apiHandler( } const data = parsed.data; - const updated = await prisma.medicationIntakeEvent.update({ - where: { id: eventId }, - data: { - ...(data.takenAt !== undefined && { takenAt: data.takenAt }), - ...(data.skipped !== undefined && { skipped: data.skipped }), - ...(data.scheduledFor !== undefined && { - scheduledFor: data.scheduledFor, - }), - // v1.7.0 sync — bump the reconciliation counter on every - // server-side mutation so the `/api/sync/changes` feed echoes a - // monotonic value to paired clients. - syncVersion: { increment: 1 }, - }, - }); + + // The post-edit state of the row (current values where the body omits a + // field) — drives the re-attribution decision. + const nextTakenAt = + data.takenAt !== undefined ? data.takenAt : event.takenAt; + const nextSkipped = + data.skipped !== undefined ? data.skipped : event.skipped; + const takenOrSkippedChanged = + (data.takenAt !== undefined && + data.takenAt?.getTime() !== event.takenAt?.getTime()) || + (data.skipped !== undefined && data.skipped !== event.skipped); + + // v1.15.18 — re-run window-band attribution when the edit moves `takenAt` + // or `skipped`, so an edited time re-binds to the correct slot instead of + // leaving `scheduledFor` stale (audit HIGH-4). Precedence: + // 1. an explicit `scheduledFor` in the body wins (deliberate override); + // 2. else `forceSlotInstant` pins onto a named real slot (422 if it is + // not a slot of this med); + // 3. else a taken edit attributes by band (slot anchor on match, the + // take's own time on a miss → ad-hoc); + // 4. a skip / pending edit keeps the existing `scheduledFor` anchor. + let resolvedScheduledFor: Date | undefined = data.scheduledFor; + if (resolvedScheduledFor === undefined && takenOrSkippedChanged) { + if (data.forceSlotInstant !== undefined) { + const forced = await resolveForcedSlotForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + slotInstant: data.forceSlotInstant, + }); + if (forced === null) { + annotate({ + action: { name: "medication.intake.force_slot.invalid" }, + meta: { medication_id: id, event_id: eventId }, + }); + return apiError( + "forceSlotInstant is not a scheduled slot of this medication", + 422, + { errorCode: "medications.intake.force_slot.invalid" }, + ); + } + resolvedScheduledFor = forced; + } else if (nextTakenAt !== null && !nextSkipped) { + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: nextTakenAt, + }); + // band.at on match; the take's own time on a miss (ad-hoc). + resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt; + } + // skip / pending edits leave `scheduledFor` on the existing anchor. + } + + const targetScheduledFor = resolvedScheduledFor ?? event.scheduledFor; + const slotMoved = + targetScheduledFor.getTime() !== event.scheduledFor.getTime(); + + let updated; + if (!slotMoved) { + // The slot is unchanged — a bare in-place update can never collide with + // a sibling row (it is its own slot). + updated = await prisma.medicationIntakeEvent.update({ + where: { id: eventId }, + data: { + ...(data.takenAt !== undefined && { takenAt: data.takenAt }), + ...(data.skipped !== undefined && { skipped: data.skipped }), + // v1.7.0 sync — bump the reconciliation counter on every + // server-side mutation so the `/api/sync/changes` feed echoes a + // monotonic value to paired clients. + syncVersion: { increment: 1 }, + }, + }); + } else { + // The edit moved the dose to a different slot. Tombstone the original + // row (the iOS "a correction is a tombstone + re-insert" model the + // DELETE handler documents) and route the corrected dose through the + // shared canonical-slot upsert, which converges onto any row already at + // the target slot (a pending REMINDER row) rather than bare-updating + // into an occupied slot and risking a P2002 (audit HIGH-4). + await prisma.medicationIntakeEvent.update({ + where: { id: eventId }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId: user.id, + medicationId: id, + canonicalSlot: targetScheduledFor, + takenAt: nextSkipped ? null : nextTakenAt, + skipped: nextSkipped, + isExplicitTaken: !nextSkipped && nextTakenAt !== null, + isExplicitSkip: nextSkipped, + idempotencyKey: null, + createSource: "WEB", + }); + updated = applied.row; + } await auditLog("medication.intake.update", { userId: user.id, @@ -105,23 +195,20 @@ export const PUT = apiHandler( invalidateUserMedications(user.id); // v1.4.39 W-MED — refresh the rollup row for both the old + new - // scheduledFor day-keys when the timestamp moved across a day - // boundary; if only takenAt / skipped changed the second call - // collapses to a no-op upsert. + // scheduledFor day-keys when the dose moved across a day boundary + // (band re-attribution can move it as well as an explicit edit); if the + // slot is unchanged the second call collapses to a no-op upsert. await recomputeMedicationComplianceForEvent({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, tz: user.timezone, }); - if ( - data.scheduledFor !== undefined && - data.scheduledFor.getTime() !== event.scheduledFor.getTime() - ) { + if (slotMoved) { await recomputeMedicationComplianceForEvent({ userId: user.id, medicationId: id, - scheduledFor: data.scheduledFor, + scheduledFor: targetScheduledFor, tz: user.timezone, }); } diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 0bd2d72fc..b9f35fd7e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -36,6 +36,11 @@ import { } from "@/lib/analytics/compliance"; import { getUserTodayBounds } from "@/lib/timezone"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; +import { + applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { injectionSiteEnum, @@ -62,6 +67,14 @@ const updateSchema = z.object({ // status toggle. Validated server-side against the medication's // effective allowed set; ignored for skipped / snoozed. injectionSite: injectionSiteEnum.optional(), + // v1.15.18 — late-take "attribute anyway" pin. When a "taken" toggle lands + // outside every dose window the UI can offer to pin the take onto a chosen + // real slot ("diesem Slot zuordnen?"); the server validates the instant is + // a real slot of the medication (422 otherwise). Ignored for skip / snooze. + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), }); function startOfDayInTz(date: Date, tz: string): Date { @@ -380,7 +393,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { intakeId, status, takenAt, snoozedUntil, injectionSite } = + const { intakeId, status, takenAt, snoozedUntil, injectionSite, forceSlotInstant } = parsed.data; // v1.7.0 sync — a tombstoned intake 404s on a status toggle; the @@ -436,26 +449,96 @@ export const POST = apiHandler(async (request: NextRequest) => { let updated; if (status === "taken") { - [updated] = await prisma.$transaction([ - prisma.medicationIntakeEvent.update({ + const resolvedTakenAt = takenAt ?? new Date(); + + // v1.15.18 — re-run window-band attribution on a taken toggle so an + // edited / off-window take re-binds to the right slot instead of leaving + // `scheduledFor` stale (audit HIGH-4). `forceSlotInstant` pins onto a + // named real slot (422 if it is not one); otherwise band membership picks + // the slot (the take's own time on a miss → ad-hoc). Mirrors the + // per-event PUT route. + let targetScheduledFor: Date; + if (forceSlotInstant !== undefined) { + const forced = await resolveForcedSlotForWrite({ + userId: user.id, + medicationId: existing.medicationId, + userTz: userTzForHook, + slotInstant: forceSlotInstant, + }); + if (forced === null) { + annotate({ + action: { name: "medication.intake.force_slot.invalid" }, + meta: { medication_id: existing.medicationId, intake_id: intakeId }, + }); + return apiError( + "forceSlotInstant is not a scheduled slot of this medication", + 422, + { errorCode: "medications.intake.force_slot.invalid" }, + ); + } + targetScheduledFor = forced; + } else { + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: existing.medicationId, + userTz: userTzForHook, + takenAt: resolvedTakenAt, + }); + targetScheduledFor = attribution.slotInstant ?? resolvedTakenAt; + } + + const slotMoved = + targetScheduledFor.getTime() !== existing.scheduledFor.getTime(); + + if (!slotMoved) { + [updated] = await prisma.$transaction([ + prisma.medicationIntakeEvent.update({ + where: { id: intakeId }, + // v1.7.0 sync — bump the reconciliation counter on every + // server-side mutation so the delta feed echoes a monotonic value. + data: { + takenAt: resolvedTakenAt, + skipped: false, + syncVersion: { increment: 1 }, + // v1.8.5 — persist the resolved site on the taken branch. + ...(resolvedInjectionSite !== null && { + injectionSite: resolvedInjectionSite, + }), + }, + }), + prisma.medication.update({ + where: { id: existing.medicationId }, + data: { snoozedUntil: null }, + }), + ]); + } else { + // The take re-attributed to a different slot. Tombstone the source row + // and route the dose through the shared canonical-slot upsert, which + // converges onto any row already at the target slot rather than + // bare-updating into an occupied slot (P2002-safe). + await prisma.medicationIntakeEvent.update({ where: { id: intakeId }, - // v1.7.0 sync — bump the reconciliation counter on every - // server-side mutation so the delta feed echoes a monotonic value. - data: { - takenAt: takenAt ?? new Date(), - skipped: false, - syncVersion: { increment: 1 }, - // v1.8.5 — persist the resolved site on the taken branch. - ...(resolvedInjectionSite !== null && { - injectionSite: resolvedInjectionSite, - }), - }, - }), - prisma.medication.update({ + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId: user.id, + medicationId: existing.medicationId, + canonicalSlot: targetScheduledFor, + takenAt: resolvedTakenAt, + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey: null, + createSource: "WEB", + injectionSite: resolvedInjectionSite, + }); + updated = applied.row; + await prisma.medication.update({ where: { id: existing.medicationId }, data: { snoozedUntil: null }, - }), - ]); + }); + } } else if (status === "skipped") { updated = await prisma.medicationIntakeEvent.update({ where: { id: intakeId }, From 4cd49e9fa2ecba9ac859a9e2cc682bbf54a8a0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:08:03 +0200 Subject: [PATCH 09/31] feat(medications): add the dose-history read endpoint for the history view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/medications/[id]/dose-history?from=&to= returns the full per-slot ledger over the window: every expected slot with a status (taken on-time / late / skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc, built from the same band minter the compliance % consumes so the history view and the rate can never disagree. Ownership-scoped, rate-limited, reads only non-tombstoned rows, instants serialised as ISO strings. Additive — iOS-safe. This is what the medication Verlauf tab renders. --- .../[id]/dose-history/__tests__/route.test.ts | 211 ++++++++++++++ .../medications/[id]/dose-history/route.ts | 266 ++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 src/app/api/medications/[id]/dose-history/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/dose-history/route.ts diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts new file mode 100644 index 000000000..967a62714 --- /dev/null +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -0,0 +1,211 @@ +/** + * v1.15.18 — GET /api/medications/[id]/dose-history. + * + * The endpoint the medication "Verlauf" tab consumes. Pins the contract: + * - ownership-scoped (404 from the shared helper) + rate-limited (429); + * - reads only `deletedAt: null` rows; + * - returns the unified dose-history ledger (every expected slot with a + * status + ad-hoc takes), built from the SAME bands the compliance % uses, + * so the history view can never disagree with the rate; + * - serialises instants as ISO strings (iOS-safe additive shape). + * + * The band attribution itself is covered exhaustively by the pure-engine + * suites (`band-minter` / `attribution` / `dose-history` / `attribute-intake`); + * this file pins the route's wiring + envelope. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findUnique: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/medications/route-guards", () => ({ + assertMedicationOwnership: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "marc", + role: "USER" as const, + timezone: TZ, + }, +}; + +const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) }; + +function getReq(query = ""): NextRequest { + return new NextRequest( + `http://localhost/api/medications/med-1/dose-history${query}`, + { method: "GET" }, + ); +} + +function at(dayRef: Date, h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); +} + +const DAY = new Date("2026-06-05T12:00:00Z"); // a couple days in the past + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(assertMedicationOwnership).mockResolvedValue(null); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "sched-1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/medications/[id]/dose-history", () => { + it("returns the 404 from the shared ownership helper", async () => { + vi.mocked(assertMedicationOwnership).mockResolvedValueOnce( + new Response(null, { status: 404 }) as never, + ); + const res = await GET(getReq(), ROUTE_PARAMS); + expect(res.status).toBe(404); + }); + + it("returns 429 when the per-user cap is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await GET(getReq(), ROUTE_PARAMS); + expect(res.status).toBe(429); + }); + + it("reads only deletedAt:null rows", async () => { + await GET(getReq(), ROUTE_PARAMS); + const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0]?.where; + expect(where).toMatchObject({ + medicationId: "med-1", + userId: "user-1", + deletedAt: null, + }); + }); + + it("attributes an on-time take to its slot and orphans an off-window take", async () => { + const from = at(DAY, 0, 0).toISOString(); + const to = at(DAY, 23, 59).toISOString(); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + // on-time morning take + { + id: "evt-morning", + scheduledFor: at(DAY, 7, 0), + takenAt: at(DAY, 7, 5), + skipped: false, + autoMissed: false, + }, + // off-window midday take (was the ±6h-snapped "07:00 dose") + { + id: "evt-adhoc", + scheduledFor: at(DAY, 11, 29), + takenAt: at(DAY, 11, 29), + skipped: false, + autoMissed: false, + }, + ] as never); + + const res = await GET( + getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const json = await res.json(); + const rows = json.data.rows as Array<{ + kind: string; + timeOfDay: string | null; + status: string; + intake: { id: string | null } | null; + }>; + + // 07:00 slot taken on-time; 19:00 slot missed (it is in the past); the + // midday take is ad-hoc, NOT snapped onto a slot. + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("taken_on_time"); + expect(morning?.intake?.id).toBe("evt-morning"); + + const evening = rows.find((r) => r.timeOfDay === "19:00"); + expect(evening?.status).toBe("missed"); + + const adHoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adHoc?.status).toBe("ad_hoc"); + expect(adHoc?.intake?.id).toBe("evt-adhoc"); + }); + + it("422 on a reversed from/to window", async () => { + const from = at(DAY, 12, 0).toISOString(); + const to = at(DAY, 6, 0).toISOString(); + const res = await GET( + getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`), + ROUTE_PARAMS, + ); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts new file mode 100644 index 000000000..9045525ea --- /dev/null +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -0,0 +1,266 @@ +/** + * v1.15.18 — traceable dose-history read endpoint (spec B; audit deliverable). + * + * GET /api/medications/[id]/dose-history?from=&to= + * → the full per-slot ledger for the medication over [from, to]: every + * expected slot with a status (taken on-time / taken late / skipped / + * missed / upcoming) plus every off-schedule intake tagged ad-hoc. + * + * This is the read-model the medication "Verlauf" tab renders. It is built + * from the SAME shared band minter + `reconstructDoseHistory` that the + * compliance % and the write/edit attribution consume, so the history view and + * the rate can never contradict each other. Additive (a new GET) → iOS-safe. + * + * Ownership-scoped via `assertMedicationOwnership`; rate-limited; reads only + * `deletedAt: null` rows. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiError, + apiSuccess, + returnAllZodIssues, +} from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance"; +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandFamily, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type DoseHistoryRow, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import { + buildCanonicalSchedule, + buildRecurrenceContext, + type WorkerScheduleRow, +} from "@/lib/medications/scheduling/worker-helpers"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_WINDOW_DAYS = 366; + +// Match the glp1 POST / bulk-delete cap so a caller can't pin the per-request +// band expansion. 60/min/user is generous for a hand-driven history view. +const RATE_LIMIT = 60; +const RATE_WINDOW_MS = 60_000; + +const querySchema = z + .object({ + from: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + to: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + }) + .refine((q) => !q.from || !q.to || q.to.getTime() >= q.from.getTime(), { + message: "`to` must be on or after `from`", + path: ["to"], + }); + +/** The serialised shape the Verlauf tab consumes (instants as ISO strings). */ +interface SerializedDoseHistoryRow { + kind: "slot" | "ad_hoc"; + at: string; + timeOfDay: string | null; + status: DoseHistoryRow["status"]; + intake: { + id: string | null; + scheduledFor: string; + takenAt: string | null; + skipped: boolean; + autoMissed: boolean; + } | null; +} + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const rl = await checkRateLimit( + `medication-dose-history:${user.id}`, + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const now = new Date(); + const userTz = user.timezone || "Europe/Berlin"; + + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + if (!medication) { + return apiError("Medication not found", 404); + } + + // Resolve the window. Default to the trailing 90 days; clamp the floor to + // the medication's creation so pre-existence days never mint phantom + // slots, and cap the span so a pathological `from=1970` can't expand a + // year-plus of bands. `to` defaults to `now`. + const to = parsed.data.to ?? now; + const requestedFrom = + parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS); + const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS); + const from = new Date( + Math.max( + requestedFrom.getTime(), + medication.createdAt.getTime(), + spanFloor.getTime(), + ), + ); + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { medicationId: id, userId: user.id, deletedAt: null }, + orderBy: { scheduledFor: "desc" }, + select: { + id: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + }, + }); + + const mapped = events.map((e) => ({ + id: e.id, + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + })); + + const lastIntakeAt = lastNonSkippedTakenAt(mapped); + + const bandMedication: BandMinterMedication = { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + }; + const ctx = buildRecurrenceContext({ + medication: bandMedication, + userTz, + lastIntakeAt, + }); + // A legacy daily schedule carrying only `windowStart` surfaces it as the + // single time-of-day so the minter mints its daily band (mirrors the + // compliance route + the ledger tally). + const canonicalSchedules = medication.schedules.map((s) => { + const canonical = buildCanonicalSchedule(s as WorkerScheduleRow); + if ( + canonical.timesOfDay.length === 0 && + canonical.rrule === null && + canonical.rollingIntervalDays === null && + canonical.scheduleType !== "PRN" && + !medication.oneShot + ) { + return { ...canonical, timesOfDay: [canonical.windowStart] }; + } + return canonical; + }); + + // Rolling cadences anchor their retrospective grid AT each logged intake. + const intakeInstants = mapped + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + + const groups = buildBandsForSchedules({ + medication: bandMedication, + schedules: canonicalSchedules, + ctx, + userTz, + range: { from, to }, + now, + intakeInstants, + }); + const bands: SlotBand[] = []; + let family: BandFamily = "none"; + for (const g of groups) { + if (g.hasExpectedSlots) { + bands.push(...g.bands); + if (family === "none") family = g.family; + } + } + + // Intakes whose slot anchor falls in the window. An ad-hoc take buckets on + // its real time, but the ledger reads `scheduledFor` for the membership + // partition, so filter on the stored anchor (a take snapped to an + // out-of-window slot is excluded, matching the compliance read). + const historyIntakes: HistoryIntake[] = mapped + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .map((e) => ({ + id: e.id, + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + })); + + const rows = reconstructDoseHistory(bands, historyIntakes, now); + + const serialized: SerializedDoseHistoryRow[] = rows.map((row) => ({ + kind: row.kind, + at: row.at.toISOString(), + timeOfDay: row.timeOfDay, + status: row.status, + intake: row.intake + ? { + id: row.intake.id ?? null, + scheduledFor: row.intake.scheduledFor.toISOString(), + takenAt: row.intake.takenAt?.toISOString() ?? null, + skipped: row.intake.skipped, + autoMissed: row.intake.autoMissed ?? false, + } + : null, + })); + + annotate({ + action: { + name: "medication.dose_history", + entity_type: "medication", + entity_id: id, + }, + meta: { row_count: serialized.length, family }, + }); + + return apiSuccess({ + from: from.toISOString(), + to: to.toISOString(), + family, + hasExpectedSlots: bands.length > 0, + rows: serialized, + }); + }, +); From 0aa5cb5ea1c0ebb671922d8f26128f45e1b6fe92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:08:10 +0200 Subject: [PATCH 10/31] fix(medications): soft-delete bulk intake removals for the tombstone feed Convert the bulk-delete hard deleteMany to a soft-delete updateMany that sets deletedAt and bumps syncVersion, matching the single-event DELETE handler so a multi-select removal surfaces each row as a tombstone on the sync delta feed to clients offline at delete time. The where clause now scopes deletedAt:null so a re-post of already-removed ids counts zero. --- .../bulk-delete/__tests__/route.test.ts | 31 ++++++++++++------- .../[id]/intake/bulk-delete/route.ts | 15 ++++++++- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index b06e008f3..1c3fef93e 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -25,7 +25,8 @@ vi.mock("@/lib/db", () => ({ medication: { findUnique: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn(), - deleteMany: vi.fn(), + // v1.15.18 LOW-6 — bulk delete is now a soft-delete updateMany. + updateMany: vi.fn(), }, auditLog: { create: vi.fn() }, }, @@ -115,7 +116,7 @@ beforeEach(() => { timezone: "Europe/Berlin", } as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValue({ + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ count: 0, } as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); @@ -125,7 +126,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { it("422 on missing body", async () => { const res = await POST(postWithBody({}), ROUTE_PARAMS); expect(res.status).toBe(422); - expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); }); it("422 on empty eventIds", async () => { @@ -156,7 +157,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.data).toEqual({ deleted: 0 }); - expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled(); }); @@ -168,7 +169,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { { id: "evt-b", scheduledFor: day1 }, { id: "evt-c", scheduledFor: day2 }, ] as never); - vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({ + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({ count: 3, } as never); @@ -185,23 +186,31 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); }); - it("scopes deleteMany by both userId and medicationId", async () => { + it("soft-deletes scoped by userId + medicationId + deletedAt:null", async () => { const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0)); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ { id: "evt-a", scheduledFor: day1 }, ] as never); - vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({ + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({ count: 1, } as never); await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS); - const deleteArg = vi.mocked(prisma.medicationIntakeEvent.deleteMany).mock - .calls[0][0]?.where; - expect(deleteArg).toEqual({ + // v1.15.18 LOW-6 — a bulk delete is a soft-delete updateMany: scoped by + // (id IN, user, medication, deletedAt:null) so a re-post counts zero, and + // the data sets deletedAt + bumps syncVersion for the iOS tombstone feed. + const call = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0]; + expect(call?.where).toEqual({ id: { in: ["evt-a"] }, userId: "user-1", medicationId: "med-1", + deletedAt: null, + }); + expect(call?.data).toMatchObject({ + deletedAt: expect.any(Date), + syncVersion: { increment: 1 }, }); }); }); @@ -220,7 +229,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit", expect(res.status).toBe(429); expect(res.headers.get("X-RateLimit-Remaining")).toBe("0"); expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); - expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); }); it("scopes the bucket key to the calling user", async () => { diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 938508447..87bdf8c83 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -116,11 +116,24 @@ export const POST = apiHandler( dayKeysToRefresh.add(dayKeyForScheduledFor(row.scheduledFor, tz)); } - const { count } = await prisma.medicationIntakeEvent.deleteMany({ + // v1.15.18 LOW-6 — soft-delete to match the single-event DELETE handler + // and the iOS tombstone contract: an intake is an immutable fact, so a + // bulk "delete" sets `deletedAt` (+ bumps `syncVersion`) so the + // `/api/sync/changes` feed surfaces each removal as a tombstone keyed on + // the server id to clients offline at delete time. Every today / + // compliance / list read filters `deletedAt: null`, so the rows are + // invisible to normal reads from here on. Scope `deletedAt: null` so a + // re-post of already-tombstoned ids counts zero rather than re-bumping. + const { count } = await prisma.medicationIntakeEvent.updateMany({ where: { id: { in: targetRows.map((r) => r.id) }, userId: user.id, medicationId: id, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + syncVersion: { increment: 1 }, }, }); From 15f648588b254ec3d3d980ba34b57d2ca795cc22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:08:14 +0200 Subject: [PATCH 11/31] chore(openapi): regenerate for the intake forceSlotInstant field --- docs/api/openapi.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 926ccfe89..02da19549 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4489,6 +4489,14 @@ components: - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT + forceSlotInstant: + description: "Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc + row. Must be a real scheduled slot of this medication on its day (validated server-side against the + dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band + attribution." + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - medicationId description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second From 9bb68ade8a452c77bf44b7952b0e8192a29090da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:29:05 +0200 Subject: [PATCH 12/31] feat(medications): persist a per-dose configurable intake window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `doseWindows` (Json?) to MedicationSchedule: a nullable array of `{ timeOfDay, start, end }` HH:mm triples naming an explicit on-time range for a dose time (e.g. 07:00–09:00). A time with no entry keeps the symmetric ±1h default; a NULL column leaves every slot on the default derivation, so the migration is additive and changes no existing behaviour. Migration 0137 adds the column with no backfill. --- .../0137_v11518_dose_windows/migration.sql | 18 ++++++++++++++++++ prisma/schema.prisma | 10 ++++++++++ 2 files changed, 28 insertions(+) create mode 100644 prisma/migrations/0137_v11518_dose_windows/migration.sql diff --git a/prisma/migrations/0137_v11518_dose_windows/migration.sql b/prisma/migrations/0137_v11518_dose_windows/migration.sql new file mode 100644 index 000000000..17c551b20 --- /dev/null +++ b/prisma/migrations/0137_v11518_dose_windows/migration.sql @@ -0,0 +1,18 @@ +-- v1.15.18 — per-dose configurable intake WINDOW. +-- +-- Marc's "07:00–09:00" lever: a dose time can carry an explicit on-time RANGE +-- instead of the symmetric ±1h default the band minter derives. The column is +-- a nullable JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per +-- dose time the user gave an explicit window for, e.g. +-- [{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }] +-- +-- The band minter reads it to build the on-time band from `[start, end]` for +-- the matching `timeOfDay`; a time with no entry keeps the default ±1h +-- derivation, and a NULL column leaves every slot on the default (unchanged +-- behaviour). The late tail stays cadence-derived. +-- +-- Additive + non-destructive: a new nullable column with no backfill. Existing +-- rows read NULL and behave exactly as before; the iOS contract gains an +-- optional field. +ALTER TABLE "medication_schedules" + ADD COLUMN "dose_windows" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c97ed5da..4ced5f92e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1388,6 +1388,16 @@ model MedicationSchedule { /// v1.7.0 — cyclic "off" weeks. Only meaningful when /// `scheduleType = CYCLIC`. cyclicOffWeeks Int? @map("cyclic_off_weeks") + /// v1.15.18 — per-dose on-time intake WINDOW (the configurable lever). + /// A JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per + /// dose time the user wants an explicit range for (e.g. + /// `[{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]`). + /// The band minter reads this to build the on-time band from the + /// explicit `[start, end]` range for the matching `timeOfDay`; a + /// `timeOfDay` with no entry falls back to the symmetric ±1h default. + /// The late tail stays cadence-derived. NULL = every slot uses the + /// default derivation (unchanged behaviour). Additive + iOS-safe. + doseWindows Json? @map("dose_windows") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) telegramMessages TelegramReminderMessage[] From 6526780f3bef3e882a1b2104aad70fbc295485c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:29:17 +0200 Subject: [PATCH 13/31] feat(medications): build the on-time band from the persisted dose window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the persisted `doseWindows` through the shared band minter so the on-time band for a dose time uses its explicit `[start, end]` range (minted DST-correctly on the slot's local day) instead of the symmetric ±1h default; the late tail stays cadence-derived. A `normaliseDoseWindows` helper coerces the stored JSON into a clean entry list, dropping anything malformed so the read/write paths never defend against an arbitrary shape. Every band consumer reads the same field: the dose-history endpoint, the compliance %, the cadence timeline (card last/next dose), and the write/edit slot attribution all resolve through one minter, so the history view and the rate can never disagree. The write/edit and health-score schedule selects now pull the column. --- .../api/medications/[id]/compliance/route.ts | 4 + .../analytics/__tests__/compliance.test.ts | 47 ++++++ src/lib/analytics/compliance.ts | 11 ++ src/lib/analytics/health-score-fast-path.ts | 3 + .../scheduling/__tests__/band-minter.test.ts | 100 +++++++++++++ .../__tests__/worker-helpers.test.ts | 78 ++++++++++ src/lib/medications/scheduling/band-minter.ts | 134 +++++++++++++++--- src/lib/medications/scheduling/cadence.ts | 10 ++ src/lib/medications/scheduling/compliance.ts | 2 + src/lib/medications/scheduling/recurrence.ts | 19 +++ src/lib/medications/scheduling/slot-upsert.ts | 3 + .../medications/scheduling/worker-helpers.ts | 46 ++++++ 12 files changed, 438 insertions(+), 19 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/worker-helpers.test.ts diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index ef845778a..7f75efd1a 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -23,6 +23,7 @@ import { type CanonicalSchedule, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; +import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { userDayKey } from "@/lib/tz/format"; @@ -40,6 +41,8 @@ type MedicationScheduleRow = { scheduleType: "SCHEDULED" | "PRN" | "CYCLIC"; cyclicOnWeeks: number | null; cyclicOffWeeks: number | null; + /** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */ + doseWindows: unknown; }; /** @@ -63,6 +66,7 @@ function toCanonicalForBands( scheduleType: s.scheduleType, cyclicOnWeeks: s.cyclicOnWeeks, cyclicOffWeeks: s.cyclicOffWeeks, + doseWindows: normaliseDoseWindows(s.doseWindows), }; if ( base.timesOfDay.length === 0 && diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 95ee18abd..a802f37b9 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -2819,4 +2819,51 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { expect(tally.taken).toBeGreaterThanOrEqual(3); expect(tally.rate).toBeGreaterThanOrEqual(90); }); + + // v1.15.18 W7 — the persisted per-dose window reaches the % through the + // SAME minter the history view + write path use. An 11:29 take that the + // default ±1h leaves ad-hoc (slot missed) is counted on-time once the + // morning slot carries an explicit 07:00–12:00 window. + it("the persisted doseWindows widens the on-time band feeding the %", () => { + const events = [ + { scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, + { scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false }, + ]; + // DEFAULT (no doseWindows): 11:29 is past the 11:00 cutoff → ad-hoc, + // morning slot missed. + const dflt = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(dflt.missed).toBe(1); + expect(dflt.adHoc).toBe(1); + + // WITH the persisted 07:00–12:00 window: 11:29 lands on-time, nothing + // missed, nothing orphaned. + const windowed: ComplianceSchedule[] = [ + { + windowStart: "07:00", + windowEnd: "19:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }], + }, + ]; + const wt = tallyComplianceFromLedger( + events, + windowed, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(wt.missed).toBe(0); + expect(wt.adHoc).toBe(0); + expect(wt.takenOnTime).toBe(2); + expect(wt.rate).toBe(100); + }); }); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index d6f5c0d23..6d6723dd4 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -32,6 +32,7 @@ import { type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; +import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { reconstructDoseHistory, type HistoryIntake, @@ -371,6 +372,15 @@ export interface ComplianceSchedule { scheduleType?: ScheduleType | null; cyclicOnWeeks?: number | null; cyclicOffWeeks?: number | null; + /** + * v1.15.18 — per-dose configurable on-time windows. Threaded onto the + * canonical schedule so `tallyComplianceFromLedger` builds the on-time band + * from the explicit `[start, end]` range — keeping the % exactly aligned with + * the dose-history view and the write/edit attribution (all consume the same + * minter). Accepts the raw persisted JSON (a full Prisma row drops straight + * in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`. + */ + doseWindows?: unknown; } /** @@ -499,6 +509,7 @@ function toCanonicalSchedule( scheduleType: s.scheduleType ?? "SCHEDULED", cyclicOnWeeks: s.cyclicOnWeeks ?? null, cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: normaliseDoseWindows(s.doseWindows), }; } diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index b20902e20..c746367eb 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -332,6 +332,9 @@ export async function computeUserHealthScoreFastPath( scheduleType: true, cyclicOnWeeks: true, cyclicOffWeeks: true, + // v1.15.18 — per-dose window so the score's compliance honours the + // same on-time band the detail % + history use. + doseWindows: true, }, }, }, diff --git a/src/lib/medications/scheduling/__tests__/band-minter.test.ts b/src/lib/medications/scheduling/__tests__/band-minter.test.ts index e2975302f..bdbb16447 100644 --- a/src/lib/medications/scheduling/__tests__/band-minter.test.ts +++ b/src/lib/medications/scheduling/__tests__/band-minter.test.ts @@ -27,6 +27,7 @@ import { type CanonicalSchedule, type RecurrenceContext, } from "../recurrence"; +import { attributeIntakeToSlot } from "../attribution"; import { localHmAsUtc } from "@/lib/timezone"; const TZ = "Europe/Berlin"; @@ -468,3 +469,102 @@ describe("multi-schedule — bands built PER SCHEDULE (no cross-clip)", () => { expect(span).toBe(2 * 60 * MIN); // ±60min }); }); + +// ──────────────────────────────────────────────────────────────────── +// 9. Per-dose configurable window (Marc's "07:00–09:00" lever, v1.15.18 W7) +// ──────────────────────────────────────────────────────────────────── + +describe("explicit per-dose window from schedule.doseWindows", () => { + const m = med(); + const day = new Date("2026-06-08T12:00:00Z"); + // Morning dose carries an explicit 07:00–12:00 window; the evening dose + // none, so it keeps the symmetric ±60min default. + const sched = schedule({ + timesOfDay: ["07:00", "19:00"], + doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }], + }); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(day, 0, 0), to: at(day, 23, 59) }, + now: at(day, 23, 59), + }); + const morning = out.bands.find((b) => b.timeOfDay === "07:00")!; + const evening = out.bands.find((b) => b.timeOfDay === "19:00")!; + + it("builds the on-time band from the explicit [start,end] range", () => { + expect(morning.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime()); + expect(morning.onTimeEnd.getTime()).toBe(at(day, 12, 0).getTime()); + }); + + it("an 11:29 take lands inside the widened on-time window", () => { + expect(attributeIntakeToSlot(at(day, 11, 29), out.bands)).toMatchObject({ + band: { timeOfDay: "07:00" }, + status: "on_time", + }); + }); + + it("a dose with no entry keeps the symmetric ±60min default", () => { + expect(evening.onTimeStart.getTime()).toBe(at(day, 19, 0).getTime() - 60 * MIN); + expect(evening.onTimeEnd.getTime()).toBe(at(day, 19, 0).getTime() + 60 * MIN); + }); + + it("under the DEFAULT (no doseWindows) the same 11:29 take is ad-hoc", () => { + const dflt = buildBandsForMedication({ + medication: m, + schedule: schedule({ timesOfDay: ["07:00", "19:00"] }), + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(day, 0, 0), to: at(day, 23, 59) }, + now: at(day, 23, 59), + }); + // 11:29 is > 07:00 + on-time(60) + tail(180) = 11:00 cutoff → no band. + expect(attributeIntakeToSlot(at(day, 11, 29), dflt.bands)).toBeNull(); + }); + + it("a malformed doseWindows shape falls back to the default derivation", () => { + const out2 = buildBandsForMedication({ + medication: m, + // start > end is dropped by the normaliser; an unrelated timeOfDay is + // ignored. Neither widens the 07:00 band. + schedule: schedule({ + timesOfDay: ["07:00", "19:00"], + doseWindows: [ + { timeOfDay: "07:00", start: "12:00", end: "07:00" }, + ] as never, + }), + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(day, 0, 0), to: at(day, 23, 59) }, + now: at(day, 23, 59), + }); + const morn = out2.bands.find((b) => b.timeOfDay === "07:00")!; + expect(morn.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime() - 60 * MIN); + expect(morn.onTimeEnd.getTime()).toBe(at(day, 7, 0).getTime() + 60 * MIN); + }); +}); + +describe("explicit per-dose window is DST-correct (winter)", () => { + const m = med(); + const winterDay = new Date("2026-01-15T12:00:00Z"); + const sched = schedule({ + timesOfDay: ["07:00"], + doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }], + }); + const out = buildBandsForMedication({ + medication: m, + schedule: sched, + ctx: ctxFor(m), + userTz: TZ, + range: { from: at(winterDay, 0, 0), to: at(winterDay, 23, 59) }, + now: at(winterDay, 23, 59), + }); + + it("mints the [start,end] bounds on the slot's local winter day (CET = +1)", () => { + const band = out.bands[0]; + expect(band.onTimeStart.toISOString()).toBe("2026-01-15T06:00:00.000Z"); + expect(band.onTimeEnd.toISOString()).toBe("2026-01-15T08:00:00.000Z"); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts b/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts new file mode 100644 index 000000000..648c4baf2 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts @@ -0,0 +1,78 @@ +/** + * v1.15.18 — `normaliseDoseWindows` coerces the persisted `dose_windows` JSON + * into a clean `DoseWindowEntry[]`, dropping anything malformed so the band + * paths never have to defend against an arbitrary JSON shape. + */ +import { describe, expect, it } from "vitest"; + +import { + buildCanonicalSchedule, + normaliseDoseWindows, + type WorkerScheduleRow, +} from "../worker-helpers"; + +describe("normaliseDoseWindows", () => { + it("returns null for null / non-array input", () => { + expect(normaliseDoseWindows(null)).toBeNull(); + expect(normaliseDoseWindows(undefined)).toBeNull(); + expect(normaliseDoseWindows("nope")).toBeNull(); + expect(normaliseDoseWindows({ timeOfDay: "07:00" })).toBeNull(); + }); + + it("keeps a well-formed entry", () => { + expect( + normaliseDoseWindows([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]), + ).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]); + }); + + it("drops entries with start > end, bad HH:mm, or missing keys", () => { + expect( + normaliseDoseWindows([ + { timeOfDay: "07:00", start: "12:00", end: "07:00" }, // start > end + { timeOfDay: "7:00", start: "07:00", end: "09:00" }, // bad HH:mm + { timeOfDay: "19:00", start: "19:00" }, // missing end + { timeOfDay: "08:00", start: "25:00", end: "26:00" }, // out of range + ]), + ).toBeNull(); + }); + + it("keeps the good entries and drops the bad ones in a mixed array", () => { + expect( + normaliseDoseWindows([ + { timeOfDay: "07:00", start: "07:00", end: "09:00" }, // good + { timeOfDay: "19:00", start: "20:00", end: "19:00" }, // start > end + ]), + ).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]); + }); + + it("threads through buildCanonicalSchedule onto the canonical schedule", () => { + const row: WorkerScheduleRow = { + id: "s1", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["07:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }], + }; + expect(buildCanonicalSchedule(row).doseWindows).toEqual([ + { timeOfDay: "07:00", start: "07:00", end: "09:00" }, + ]); + }); + + it("a row with no doseWindows yields null on the canonical schedule", () => { + const row: WorkerScheduleRow = { + id: "s1", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["07:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }; + expect(buildCanonicalSchedule(row).doseWindows).toBeNull(); + }); +}); diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index 4d62ba424..90326471a 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -47,6 +47,7 @@ import { expandRollingRetrospective, occurrencesBetween, type CanonicalSchedule, + type DoseWindowEntry, type Occurrence, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; @@ -172,7 +173,7 @@ export function buildBandsForMedication( // here (intakes drive the anchors), so the interval is the right signal. const family: "daily" | "weekly" = schedule.rollingIntervalDays >= 2 ? "weekly" : "daily"; - const bands = mintBands(occ, family, input.windowConfig, userTz); + const bands = mintBands(occ, family, input.windowConfig, userTz, schedule); return { bands, hasExpectedSlots: true, family }; } @@ -182,7 +183,7 @@ export function buildBandsForMedication( // field-shape `doseCadenceFamily` heuristic. const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx); const family = realisedFamily(schedule, ctx, range); - const bands = mintBands(occurrences, family, input.windowConfig, userTz); + const bands = mintBands(occurrences, family, input.windowConfig, userTz, schedule); return { bands, hasExpectedSlots: true, family }; } @@ -231,30 +232,106 @@ function mintBands( family: "daily" | "weekly", windowConfig: DoseWindowConfig | undefined, userTz: string, + schedule: CanonicalSchedule, ): SlotBand[] { const w = resolveWindows(windowConfig); + // The persisted per-dose windows (Marc's "07:00–09:00" lever). When a slot's + // `timeOfDay` matches an entry, the on-time band is the explicit `[start, + // end]` range; otherwise the default symmetric ±width applies. The late tail + // stays cadence-derived for every slot. + const explicit = indexDoseWindows(schedule.doseWindows ?? null); const inputs: SlotWindowInput[] = occurrences.map((occ) => family === "weekly" - ? weeklyWindow(occ, w, userTz) - : dailyWindow(occ, w), + ? weeklyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null) + : dailyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null), ); return buildSlotBands(inputs); } -/** Minute-scale band around a slot instant (daily / intraday cadences). */ +/** + * Index the schedule's `doseWindows` by `timeOfDay` for O(1) per-slot lookup. + * A duplicate `timeOfDay` keeps the first entry (the write path Zod-validates + * uniqueness, so this is purely defensive). + */ +function indexDoseWindows( + windows: DoseWindowEntry[] | null, +): Map { + const map = new Map(); + if (!windows) return map; + for (const w of windows) { + // Defence-in-depth: only index well-formed `start <= end` HH:mm entries. + // `buildCanonicalSchedule` already normalises the persisted JSON, but a + // directly-constructed `CanonicalSchedule` (tests, compliance helpers) + // must never widen a band from a malformed range. + if (!isUsableDoseWindow(w)) continue; + if (!map.has(w.timeOfDay)) map.set(w.timeOfDay, w); + } + return map; +} + +/** True when a dose window is well-formed HH:mm with `start <= end`. */ +function isUsableDoseWindow(w: DoseWindowEntry): boolean { + const start = localHourMinute(w.start); + const end = localHourMinute(w.end); + const startMin = start.hour * 60 + start.minute; + const endMin = end.hour * 60 + end.minute; + return ( + HHMM_BAND_RE.test(w.timeOfDay) && + HHMM_BAND_RE.test(w.start) && + HHMM_BAND_RE.test(w.end) && + startMin <= endMin + ); +} + +const HHMM_BAND_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** + * Minute-scale band around a slot instant (daily / intraday cadences). When an + * explicit per-dose window is configured for this `timeOfDay`, the on-time band + * is the literal `[start, end]` range minted on the slot's local day (DST-correct + * via `localHmAsUtc`); otherwise the symmetric ±`dailyOnTimeMinutes` default. + */ function dailyWindow( occ: Occurrence, w: ResolvedWindows, + userTz: string, + explicit: DoseWindowEntry | null, ): SlotWindowInput { + const onTime = explicit + ? explicitOnTimeBounds(occ.at, explicit, userTz) + : { + onTimeStart: new Date( + occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS, + ), + onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS), + }; return { at: occ.at, timeOfDay: occ.timeOfDay, - onTimeStart: new Date(occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS), - onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS), + onTimeStart: onTime.onTimeStart, + onTimeEnd: onTime.onTimeEnd, lateGraceMs: w.dailyOverdueMinutes * MINUTE_MS, }; } +/** + * Mint an explicit `[start, end]` on-time range on the slot's local calendar + * day. `localHmAsUtc` keys off the local day of its first argument (`occ.at`), + * so the bounds land on the same day as the slot, DST-correct. + */ +function explicitOnTimeBounds( + occAt: Date, + window: DoseWindowEntry, + userTz: string, +): { onTimeStart: Date; onTimeEnd: Date } { + const start = localHourMinute(window.start); + const end = localHourMinute(window.end); + return { + onTimeStart: localHmAsUtc(occAt, userTz, start.hour, start.minute), + onTimeEnd: localHmAsUtc(occAt, userTz, end.hour, end.minute), + }; +} + /** * Day-scale band around a slot instant (weekly / rolling / sparse cadences). * The ±N-day bounds are minted via `localHmAsUtc` on the calendar days N days @@ -265,20 +342,39 @@ function weeklyWindow( occ: Occurrence, w: ResolvedWindows, userTz: string, + explicit: DoseWindowEntry | null, ): SlotWindowInput { const { hour, minute } = localHourMinute(occ.timeOfDay); - const onTimeStart = localHmAsUtc( - new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS), - userTz, - hour, - minute, - ); - const onTimeEnd = localHmAsUtc( - new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS), - userTz, - hour, - minute, - ); + // An explicit per-dose window pins the on-time band to the literal `[start, + // end]` range on the slot's own local day (a within-day window the user set); + // otherwise the default day-scale ±`weeklyOnTimeDays` envelope. Either way the + // late tail below stays day-scale + DST-correct. + const onTimeStart = explicit + ? localHmAsUtc( + occ.at, + userTz, + localHourMinute(explicit.start).hour, + localHourMinute(explicit.start).minute, + ) + : localHmAsUtc( + new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS), + userTz, + hour, + minute, + ); + const onTimeEnd = explicit + ? localHmAsUtc( + occ.at, + userTz, + localHourMinute(explicit.end).hour, + localHourMinute(explicit.end).minute, + ) + : localHmAsUtc( + new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS), + userTz, + hour, + minute, + ); // The late tail is also day-scale: the instant N overdue-days past the // on-time end, minted on the calendar day for DST-correctness, then expressed // as a millisecond grace `buildSlotBands` consumes. diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 63b6bff73..e608c23b7 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -44,6 +44,7 @@ import { expandRollingRetrospective, occurrencesBetween, } from "@/lib/medications/scheduling/recurrence"; +import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { wallClockInTz } from "@/lib/tz/wall-clock"; /** @@ -96,6 +97,14 @@ export interface ScheduleLike { scheduleType?: ScheduleType | null; cyclicOnWeeks?: number | null; cyclicOffWeeks?: number | null; + /** + * v1.15.18 — per-dose configurable on-time windows. Carried onto the + * canonical schedule so the cadence-timeline bands (the card last/next dose) + * honour the same explicit window the % and the history view use. Accepts the + * raw persisted JSON (a full Prisma row's `Json?` column drops straight in); + * `toCanonical` normalises it via `normaliseDoseWindows`. + */ + doseWindows?: unknown; /** Stable id for the engine occurrence; defaults to a synthetic value. */ id?: string; } @@ -135,6 +144,7 @@ function toCanonical( scheduleType: schedule.scheduleType ?? "SCHEDULED", cyclicOnWeeks: schedule.cyclicOnWeeks ?? null, cyclicOffWeeks: schedule.cyclicOffWeeks ?? null, + doseWindows: normaliseDoseWindows(schedule.doseWindows), }; } diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index e06061609..cc8e9ccff 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -40,6 +40,7 @@ import { type CanonicalSchedule, type RecurrenceContext, } from "./recurrence"; +import { normaliseDoseWindows } from "./worker-helpers"; export interface ComplianceChips { /** 0-100, taken / (taken + missed). Skipped doses are excluded from @@ -211,6 +212,7 @@ function ledgerChipCounts( scheduleType: s.scheduleType ?? "SCHEDULED", cyclicOnWeeks: s.cyclicOnWeeks ?? null, cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: normaliseDoseWindows(s.doseWindows), }; // Surface windowStart as the single time-of-day for a legacy daily row // so the band minter's cadence gate mints its daily band (see the diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 600835ee5..527026b53 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -81,6 +81,25 @@ export interface CanonicalSchedule { cyclicOnWeeks: number | null; /** v1.7.0 — cyclic "off" weeks. Only read when `scheduleType === "CYCLIC"`. */ cyclicOffWeeks: number | null; + /** + * v1.15.18 — per-dose configurable on-time intake windows. The recurrence + * engine itself never reads these (slot timing is unchanged); they ride on + * the canonical schedule purely so the band minter can build a slot's + * on-time band from an explicit `[start, end]` range for the matching + * `timeOfDay`. NULL / empty = every slot uses the default ±1h derivation. + */ + doseWindows?: DoseWindowEntry[] | null; +} + +/** + * v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose + * the window applies to (matching a `timesOfDay` entry); `start`/`end` are the + * HH:mm on-time bounds in the user's wall clock (`start <= end`, same local day). + */ +export interface DoseWindowEntry { + timeOfDay: string; + start: string; + end: string; } export interface RecurrenceContext { diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 88d0d6d4b..cdd81e3e0 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -299,6 +299,9 @@ const SCHEDULE_SELECT = { scheduleType: true, cyclicOnWeeks: true, cyclicOffWeeks: true, + // v1.15.18 — the persisted per-dose window the band attribution honours so + // the write/edit slot binding uses the SAME on-time window as the % + history. + doseWindows: true, } as const; const MEDICATION_SELECT = { diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index 6ae899a31..fa66b78ed 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -18,6 +18,7 @@ */ import { type CanonicalSchedule, + type DoseWindowEntry, type RecurrenceContext, type ScheduleType, occurrencesBetween, @@ -48,6 +49,13 @@ export interface WorkerScheduleRow { scheduleType?: ScheduleType | null; cyclicOnWeeks?: number | null; cyclicOffWeeks?: number | null; + /** + * v1.15.18 — per-dose on-time windows. The Prisma column is `Json?`, so a + * selected row surfaces it as `Prisma.JsonValue` (or `null`). The adapter + * normalises it to a `DoseWindowEntry[]` (dropping malformed entries) so the + * band minter never has to defend against an arbitrary JSON shape. + */ + doseWindows?: unknown; } /** Minimal `Medication` projection used by the worker. */ @@ -78,9 +86,47 @@ export function buildCanonicalSchedule( scheduleType: schedule.scheduleType ?? "SCHEDULED", cyclicOnWeeks: schedule.cyclicOnWeeks ?? null, cyclicOffWeeks: schedule.cyclicOffWeeks ?? null, + doseWindows: normaliseDoseWindows(schedule.doseWindows), }; } +const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** + * v1.15.18 — coerce the persisted `dose_windows` JSON into a clean + * `DoseWindowEntry[]`. Drops anything that isn't an `{ timeOfDay, start, end }` + * triple of well-formed HH:mm strings with `start <= end` — the column is + * Zod-validated on write, but a hand-edited or legacy row must never crash the + * read/write band paths. Returns `null` (the default-derivation signal) when + * nothing usable survives. + */ +export function normaliseDoseWindows(raw: unknown): DoseWindowEntry[] | null { + if (!Array.isArray(raw)) return null; + const out: DoseWindowEntry[] = []; + for (const item of raw) { + if (typeof item !== "object" || item === null) continue; + const { timeOfDay, start, end } = item as Record; + if ( + typeof timeOfDay !== "string" || + typeof start !== "string" || + typeof end !== "string" || + !HHMM_RE.test(timeOfDay) || + !HHMM_RE.test(start) || + !HHMM_RE.test(end) || + hhmmToMinutes(start) > hhmmToMinutes(end) + ) { + continue; + } + out.push({ timeOfDay, start, end }); + } + return out.length > 0 ? out : null; +} + +function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + /** Build the canonical engine context from worker-loop state. */ export function buildRecurrenceContext(input: { medication: WorkerMedicationRow; From e523a3ae006d582575f45af13aa9bd2c7b0b0805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:34:37 +0200 Subject: [PATCH 14/31] feat(medications): validate per-dose dose windows on schedule writes Accept an optional doseWindows array on the schedule create/update routes: each entry pins an explicit on-time range [start,end] to one of the schedule's times-of-day, defaulting to the symmetric grace band when absent. Reject malformed ranges (bad HH:mm, a timeOfDay outside the schedule, an inverted span) with a 422 so the persisted window can never feed the band minter a degenerate range. Regenerates the OpenAPI contract for the additive field (iOS-safe). --- docs/api/openapi.yaml | 31 +++++ src/app/api/medications/[id]/route.ts | 4 + src/app/api/medications/route.ts | 4 + .../__tests__/medication-dose-windows.test.ts | 125 ++++++++++++++++++ src/lib/validations/medication.ts | 84 ++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 src/lib/validations/__tests__/medication-dose-windows.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 02da19549..c7fd6600b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4307,6 +4307,15 @@ components: type: integer minimum: 0 maximum: 52 + doseWindows: + description: Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time + band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must + match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on + the default derivation. The late tail stays cadence-derived. + maxItems: 8 + type: array + items: + $ref: "#/components/schemas/DoseWindowEntry" required: - windowStart - windowEnd @@ -4315,6 +4324,28 @@ components: preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK constraint. + DoseWindowEntry: + type: object + properties: + timeOfDay: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: The dose time this window applies to (matches a `timesOfDay` entry). + start: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band lower bound (HH:mm, user local). + end: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band upper bound (HH:mm, user local). Must be >= `start`. + required: + - timeOfDay + - start + - end + description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` + (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then + ad-hoc. UpdateMedicationRequest: type: object properties: diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 8c33a61fb..2817baf87 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -318,6 +318,10 @@ export const PUT = apiHandler( ...(s.cyclicOffWeeks !== undefined && { cyclicOffWeeks: s.cyclicOffWeeks, }), + // v1.15.18 — per-dose configurable on-time windows. A `schedules` + // replace re-creates the rows, so the column is re-written each + // time; absent leaves it NULL (default ±1h derivation). + ...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }), }; }), }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 4a470f920..8e4f77835 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -326,6 +326,10 @@ export const POST = apiHandler(async (request: NextRequest) => { ...(s.cyclicOffWeeks !== undefined && { cyclicOffWeeks: s.cyclicOffWeeks, }), + // v1.15.18 — per-dose configurable on-time windows. Stored as the + // validated `{ timeOfDay, start, end }[]` JSON; absent leaves the + // column NULL (every slot on the default ±1h derivation). + ...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }), }; }), }, diff --git a/src/lib/validations/__tests__/medication-dose-windows.test.ts b/src/lib/validations/__tests__/medication-dose-windows.test.ts new file mode 100644 index 000000000..239aa5fd2 --- /dev/null +++ b/src/lib/validations/__tests__/medication-dose-windows.test.ts @@ -0,0 +1,125 @@ +/** + * v1.15.18 — per-dose configurable on-time window (`doseWindows`) validation. + * + * The schedule schema accepts an optional `[{ timeOfDay, start, end }]` array. + * Each entry must be well-formed HH:mm with `start <= end`, name a real dose + * time of the schedule, and no `timeOfDay` may repeat. Absent leaves the + * default ±1h behaviour unchanged. + */ +import { describe, it, expect } from "vitest"; + +import { doseWindowEntrySchema, scheduleSchema } from "../medication"; + +function baseSchedule(over: Record = {}) { + return { + windowStart: "07:00", + windowEnd: "09:00", + timesOfDay: ["07:00", "19:00"], + rrule: "FREQ=DAILY", + ...over, + }; +} + +describe("doseWindowEntrySchema", () => { + it("accepts a well-formed entry", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "07:00", + end: "09:00", + }).success, + ).toBe(true); + }); + + it("rejects start > end", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "12:00", + end: "07:00", + }).success, + ).toBe(false); + }); + + it("rejects a malformed HH:mm", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "7:00", + start: "07:00", + end: "09:00", + }).success, + ).toBe(false); + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "07:00", + end: "25:00", + }).success, + ).toBe(false); + }); + + it("allows a zero-width window (start === end)", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "08:00", + end: "08:00", + }).success, + ).toBe(true); + }); +}); + +describe("scheduleSchema doseWindows", () => { + it("is optional — a schedule without it validates", () => { + expect(scheduleSchema.safeParse(baseSchedule()).success).toBe(true); + }); + + it("accepts a window for a real dose time", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }], + }), + ); + expect(r.success).toBe(true); + }); + + it("rejects a window whose timeOfDay is not a scheduled dose time", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [{ timeOfDay: "13:00", start: "13:00", end: "14:00" }], + }), + ); + expect(r.success).toBe(false); + }); + + it("rejects a duplicate timeOfDay", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [ + { timeOfDay: "07:00", start: "07:00", end: "08:00" }, + { timeOfDay: "07:00", start: "08:00", end: "09:00" }, + ], + }), + ); + expect(r.success).toBe(false); + }); + + it("rejects a per-entry start > end", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [{ timeOfDay: "07:00", start: "12:00", end: "07:00" }], + }), + ); + expect(r.success).toBe(false); + }); + + it("matches a legacy single-time schedule against windowStart", () => { + const r = scheduleSchema.safeParse({ + windowStart: "08:00", + windowEnd: "10:00", + // no timesOfDay → effective dose time is windowStart + doseWindows: [{ timeOfDay: "08:00", start: "08:00", end: "11:00" }], + }); + expect(r.success).toBe(true); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index a4d6d0640..fb7721321 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -126,6 +126,43 @@ export type MedicationDeliveryForm = const RRULE_PROPS = /^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/; +/** + * v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose + * the window applies to; `start`/`end` are the HH:mm on-time bounds in the + * user's wall clock. `start <= end` within the day (an overnight window is not + * a configurable on-time band — the late tail owns the cross-midnight tail). + */ +export const doseWindowEntrySchema = z + .object({ + timeOfDay: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe("The dose time this window applies to (matches a `timesOfDay` entry)."), + start: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe("On-time band lower bound (HH:mm, user local)."), + end: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe("On-time band upper bound (HH:mm, user local). Must be >= `start`."), + }) + .refine((w) => hhmmToMinutes(w.start) <= hhmmToMinutes(w.end), { + message: "Window start must be on or before end (same day)", + path: ["end"], + }) + .meta({ + id: "DoseWindowEntry", + description: + "One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.", + }); + +/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */ +function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + export const scheduleSchema = z .object({ windowStart: z @@ -251,6 +288,21 @@ export const scheduleSchema = z .describe( "Cyclic \"off\" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.", ), + /** + * v1.15.18 — per-dose configurable on-time intake window (Marc's + * "07:00–09:00" lever). One entry per dose time the user wants an explicit + * range for; a `timeOfDay` with no entry keeps the symmetric ±1h default. + * Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the + * legacy `windowStart`), and `start <= end` within the day. Absent → every + * slot uses the default derivation (unchanged behaviour). + */ + doseWindows: z + .array(doseWindowEntrySchema) + .max(8) + .optional() + .describe( + "Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived.", + ), }) .refine( (s) => !(s.rrule && s.rollingIntervalDays), @@ -290,6 +342,38 @@ export const scheduleSchema = z path: ["timesOfDay"], }, ) + .refine( + (s) => { + // Every per-dose window must name a real dose time. The effective dose + // times are `timesOfDay` when set, else the single legacy `windowStart` + // (mirrors the engine's `effectiveTimesOfDay`). + if (!s.doseWindows || s.doseWindows.length === 0) return true; + const times = new Set( + s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart], + ); + return s.doseWindows.every((w) => times.has(w.timeOfDay)); + }, + { + message: "Each doseWindows.timeOfDay must match one of the schedule's timesOfDay", + path: ["doseWindows"], + }, + ) + .refine( + (s) => { + // A dose time may carry at most one explicit window. + if (!s.doseWindows || s.doseWindows.length === 0) return true; + const seen = new Set(); + for (const w of s.doseWindows) { + if (seen.has(w.timeOfDay)) return false; + seen.add(w.timeOfDay); + } + return true; + }, + { + message: "doseWindows must not repeat a timeOfDay", + path: ["doseWindows"], + }, + ) .meta({ id: "MedicationScheduleInput", description: From 6f5f035b7858edf53437261b9a80ee52d7958e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:37:42 +0200 Subject: [PATCH 15/31] fix(medications): keep the card a constant neutral surface, never tint by dose status The v1.15.9 take-window highlight washed the whole medication card green (ring + tinted background) when a dose was in its on-time window. A card turning green reads as an imperative "take it now" and drowns the rest of the page once several doses fall due. Drop the surface tint entirely so the card stays a constant neutral surface; the take-now / overdue / very-overdue status remains, expressed only through the discreet top status line / pill (text + glyph), never as a background or border colour. Tests assert the neutral surface across every dose status plus the surviving status pill. --- .../medications/__tests__/card-parts.test.tsx | 37 +++++++++++++------ .../card-parts/medication-card-body.tsx | 26 ++++++------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index f39baaf64..07f351996 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -373,10 +373,11 @@ describe("streak-token parity — generic vs GLP-1 card", () => { }); /** - * v1.15.9 — the ONE shared `` both cards render. These - * tests pin the state-driven presentation (green take-window highlight, - * overdue / heavily-overdue top line) and the structural identity that makes - * the two variants impossible to diverge — they pass content into THIS body. + * v1.15.18 — the ONE shared `` both cards render. These + * tests pin that the card surface is a CONSTANT neutral surface — dose status + * is never expressed as a background / border tint (Marc, recurring) — while + * status is still surfaced via the discreet top line / pill, plus the + * structural identity that makes the two variants impossible to diverge. */ describe("medication card body — shared shell + dose-state presentation", () => { function renderBody(doseStatus: DoseStatus, active = true) { @@ -406,17 +407,30 @@ describe("medication card body — shared shell + dose-state presentation", () = ); } - it("highlights the card green when the dose is in its take-window", () => { + it("never tints the card surface when the dose is in its take-window", () => { + // Marc (recurring): the whole card washing green reads as "take it now" + // and is a permanent removal. The take-window status survives ONLY as the + // discreet "Take now" pill, never as a card background / ring tint. const html = renderBody("on_time_window"); - // Green ring + tinted background on the Card shell. - expect(html).toContain("ring-success/40"); - expect(html).toContain("bg-success/5"); + expect(html).not.toContain("ring-success/40"); + expect(html).not.toContain("bg-success/5"); + // The discreet status pill still communicates the state. + expect(html).toContain("Take now"); }); - it("stays calm (no green highlight) for upcoming / taken states", () => { - for (const status of ["upcoming", "taken_on_time", "taken_late", "skipped"] as const) { + it("never tints the card surface across any dose status", () => { + for (const status of [ + "on_time_window", + "upcoming", + "taken_on_time", + "taken_late", + "skipped", + "overdue", + "missed", + ] as const) { const html = renderBody(status); expect(html).not.toContain("ring-success/40"); + expect(html).not.toContain("bg-success/5"); } }); @@ -433,9 +447,10 @@ describe("medication card body — shared shell + dose-state presentation", () = expect(html).toContain("text-destructive"); }); - it("never highlights green on an inactive medication", () => { + it("keeps the inactive surface muted without any status tint", () => { const html = renderBody("on_time_window", false); expect(html).not.toContain("ring-success/40"); + expect(html).not.toContain("bg-success/5"); expect(html).toContain("opacity-60"); }); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 9d34fc9e1..6566c1c9a 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -35,11 +35,16 @@ import type { MedicationWindowStatus } from "@/lib/medications/window-status"; * the single `mt-auto pt-0` action wrapper — there is no per-variant spacing * left to diverge. * - * State-driven presentation (like the iOS app): - * - `on_time_window` → the card highlights green (`ring` + tinted bg): the - * actionable "take it now" state stands out at a glance. + * State-driven presentation: + * - The card surface itself is a CONSTANT neutral surface — dose status + * never tints the card background or border. A whole card washing green + * reads as "take it now everywhere" and drowns the page; status is shown + * ONLY through the discreet top line / pill below. + * - `on_time_window` → the discreet "Take now" pill (success-toned text + + * glyph), no card wash. * - `overdue` → a calm top status line; `missed` (at / past the miss - * cutoff) escalates to "Stark überfällig" in the destructive tone. + * cutoff) escalates to "Stark überfällig" in the destructive tone — both + * are a single text line with an icon, not a surface tint. * - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm. */ export interface MedicationCardBodyProps { @@ -123,10 +128,6 @@ export function MedicationCardBody({ }: MedicationCardBodyProps) { const { t } = useTranslations(); - // Green take-window highlight — the actionable "take it now" state, mirrored - // from the iOS card. Suppressed on an inactive med (it carries no actions). - const inTakeWindow = active && doseStatus === "on_time_window"; - // Overdue escalation line: a dose past its on-time window. `missed` (at / // past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable // `overdue` tail reads the calmer "Überfällig". Both use the destructive @@ -140,11 +141,10 @@ export function MedicationCardBody({ return ( Date: Mon, 8 Jun 2026 18:38:57 +0200 Subject: [PATCH 16/31] fix(insights): stop double-counting IN_BED in the sleep-phase chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IN_BED is the TOTAL time in bed (≈ CORE + DEEP + REM + AWAKE), not a sleep phase, but it was a stacked Bar segment alongside the phases. Stacking the total on top of its own constituents doubled every bar — a 7 h night drew as ~14 h dominated by the in-bed segment — and inflated the tooltip's per-night total, which sums the stacked series. Drop IN_BED from STAGE_ORDER so the bar height and the tooltip total are the real night. The STAGE_COLORS.IN_BED token and the inBed label stay for the last-night hypnogram, which still renders the in-bed span. STAGE_ORDER is now exported so the test pins that IN_BED is not a stack series and the total equals CORE + DEEP + REM + AWAKE. --- .../sleep-stage-stacked-bar.test.tsx | 50 +++++++++++++++++++ .../insights/sleep-stage-stacked-bar.tsx | 11 +++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 41a70be79..9aedff7a4 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { SleepStageStackedBar, + STAGE_ORDER, type SleepStageBreakdown, } from "../sleep-stage-stacked-bar"; @@ -92,6 +93,55 @@ describe("", () => { ).not.toThrow(); }); + /** + * v1.15.18 — IN_BED is the TOTAL time in bed (≈ CORE + DEEP + REM + + * AWAKE), not a sleep PHASE. Stacking it doubled every bar and inflated + * the tooltip per-night total. It must not be a stack segment. + */ + describe("IN_BED is not double-counted in the stack (v1.15.18)", () => { + it("excludes IN_BED from the stacked phase order", () => { + expect(STAGE_ORDER).not.toContain("IN_BED"); + // The genuine phases (+ the legacy ASLEEP bucket) still stack. + expect(STAGE_ORDER).toEqual(["DEEP", "REM", "CORE", "ASLEEP", "AWAKE"]); + }); + + it("the per-night stack total is CORE + DEEP + REM + AWAKE, not the inflated IN_BED-inclusive sum", () => { + // The tooltip sums `payload.reduce` over exactly the stacked series. + // With IN_BED out of STAGE_ORDER it is no longer a Bar, so it cannot + // enter the payload and the total self-corrects to the real night. + const night = { CORE: 240.7, DEEP: 70.6, REM: 90.6, AWAKE: 34.9 }; + const IN_BED = 436.7; // the reported total in bed + const stackedTotal = STAGE_ORDER.reduce( + (sum, stage) => sum + ((night as Record)[stage] ?? 0), + 0, + ); + // Sums to the true night (~6 h 56 m), close to the real IN_BED total, + // and crucially NOT IN_BED + phases (~14 h) the old stack produced. + expect(stackedTotal).toBeCloseTo(436.8, 1); + expect(stackedTotal).toBeLessThan(IN_BED + 1); + expect(stackedTotal).not.toBeCloseTo(IN_BED + stackedTotal, 1); + }); + + it("does not stack IN_BED even when the payload carries it", () => { + // A night whose breakdown includes the IN_BED total must render the + // phase bars only; the chart must still mount without IN_BED dominating. + const breakdown: SleepStageBreakdown = { + windowDays: 30, + nights: 1, + totalMinutes: 437, + stages: { DEEP: 71, REM: 91, CORE: 241, AWAKE: 35, IN_BED: 437 }, + perNight: [ + { + dayKey: "2026-06-08", + stages: { DEEP: 71, REM: 91, CORE: 241, AWAKE: 35, IN_BED: 437 }, + }, + ], + }; + const html = render(); + expect(html).toContain('data-slot="sleep-stage-stacked-bar"'); + }); + }); + /** * v1.4.25 W3f — per-night surface + window toggle. */ diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index fee161957..b70c4a8ba 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -72,14 +72,21 @@ type WindowSize = (typeof WINDOW_DAYS)[number]; /** * Order on the stack — deepest restorative stages first so a user * scanning left → right reads quality before context. + * + * IN_BED is deliberately NOT a stack segment. It is the TOTAL time in + * bed (≈ CORE + DEEP + REM + AWAKE), so stacking it on top of those + * phases doubled every bar (~14 h for a 7 h night) and inflated the + * tooltip's per-night total. With it out of the stack the bar height + * and the tooltip total are the real night. The `STAGE_COLORS.IN_BED` + * token + the `insights.sleep.stages.inBed` label survive for the + * last-night hypnogram, which still renders the in-bed span. */ -const STAGE_ORDER = [ +export const STAGE_ORDER = [ "DEEP", "REM", "CORE", "ASLEEP", "AWAKE", - "IN_BED", ] as const; /** From a58a73608ce687a6730991955b7827e3b0bd2f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:48:20 +0200 Subject: [PATCH 17/31] feat(settings): add an Insights section to arrange the overview and sort the nav pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights overview customise surface now has a home in Settings, analogous to the Dashboard section. It hosts two blocks: - "Arrange the overview" embeds the inline section show/hide + order editor, persisting to insightsLayoutJson.sections. - "Sort the navigation pills" is a dedicated drag-reorder control for the tab-strip pills, persisting to insightsLayoutJson.tiles[].order — a field already separate from the section order in layout v2, so the two concerns stay independent. Both write through the existing /api/insights/layout contract the tab strip and overview share. Wire the new slug into the settings registry, the section router, the mobile/desktop nav, and the placeholder icon map. Adds the section copy and the pill-sort + overview-arrange strings across all six locales. --- messages/de.json | 21 +- messages/en.json | 21 +- messages/es.json | 21 +- messages/fr.json | 21 +- messages/it.json | 21 +- messages/pl.json | 21 +- src/app/settings/[section]/page.tsx | 2 + .../__tests__/settings-shell.test.tsx | 5 +- .../insights-overview-arrange-section.tsx | 85 +++++ .../settings/insights-pill-order-section.tsx | 333 ++++++++++++++++++ src/components/settings/insights-section.tsx | 42 +++ .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 1 + src/components/settings/settings-shell.tsx | 6 + 14 files changed, 595 insertions(+), 7 deletions(-) create mode 100644 src/components/settings/insights-overview-arrange-section.tsx create mode 100644 src/components/settings/insights-pill-order-section.tsx create mode 100644 src/components/settings/insights-section.tsx diff --git a/messages/de.json b/messages/de.json index c09d62ccd..654e9d673 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3041,7 +3041,22 @@ }, "disclaimer": { "footer": "HealthLog beschreibt deine eigenen erfassten Daten — es ist keine klinische Bewertung und keine Diagnose. Sprich mit deiner Ärztin oder deinem Arzt über alles, was dich beunruhigt." - } + }, + "settings": { + "overviewTitle": "Übersicht anordnen", + "overviewDescription": "Abschnitte ein- oder ausblenden und ihre Reihenfolge auf der Insights-Übersicht festlegen." + }, + "pillOrder": { + "title": "Navigations-Pills sortieren", + "description": "Ziehe die Pills in die Reihenfolge, in der sie oben in den Insights erscheinen sollen.", + "dragHandle": "Verschieben", + "saveSuccess": "Reihenfolge der Pills gespeichert.", + "saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.", + "save": "Speichern", + "empty": "Noch keine Navigations-Pills verfügbar." + }, + "customize": "Insights anpassen", + "regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen." }, "thresholds": { "defaultLabel": "Default", @@ -3429,6 +3444,10 @@ "sharing": { "title": "Freigabe", "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen." + }, + "insights": { + "title": "Insights", + "description": "Reihenfolge der Übersicht und der Navigations-Pills." } }, "profile": "Profil", diff --git a/messages/en.json b/messages/en.json index a8d9cf9a4..d5bc6e7fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3041,7 +3041,22 @@ }, "disclaimer": { "footer": "HealthLog describes your own tracked data — it is not a clinical assessment or diagnosis. Talk to your doctor about anything that concerns you." - } + }, + "settings": { + "overviewTitle": "Arrange the overview", + "overviewDescription": "Show or hide sections and set their order on the Insights overview." + }, + "pillOrder": { + "title": "Sort the navigation pills", + "description": "Drag the pills into the order they should appear in along the top of Insights.", + "dragHandle": "Reorder", + "saveSuccess": "Pill order saved.", + "saveError": "Saving the pill order failed.", + "save": "Save", + "empty": "No navigation pills available yet." + }, + "customize": "Customize Insights", + "regenerateError": "Still refreshing — please try again in a moment." }, "thresholds": { "defaultLabel": "Default", @@ -3429,6 +3444,10 @@ "sharing": { "title": "Sharing", "description": "Create a read-only link to share your health record with a clinician." + }, + "insights": { + "title": "Insights", + "description": "Overview and navigation-pill order." } }, "profile": "Profile", diff --git a/messages/es.json b/messages/es.json index d11008a7e..acfecf876 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3041,7 +3041,22 @@ }, "disclaimer": { "footer": "HealthLog describe tus propios datos registrados — no es una evaluación clínica ni un diagnóstico. Habla con tu médico sobre cualquier cosa que te preocupe." - } + }, + "settings": { + "overviewTitle": "Organizar la vista general", + "overviewDescription": "Muestra u oculta secciones y define su orden en la vista general de Análisis." + }, + "pillOrder": { + "title": "Ordenar las pestañas de navegación", + "description": "Arrastra las pestañas al orden en que deben aparecer en la parte superior de Análisis.", + "dragHandle": "Reordenar", + "saveSuccess": "Orden de las pestañas guardado.", + "saveError": "No se pudo guardar el orden de las pestañas.", + "save": "Guardar", + "empty": "Aún no hay pestañas de navegación disponibles." + }, + "customize": "Personalizar Análisis", + "regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento." }, "thresholds": { "defaultLabel": "Por defecto", @@ -3429,6 +3444,10 @@ "sharing": { "title": "Compartir", "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico." + }, + "insights": { + "title": "Análisis", + "description": "Orden de la vista general y de las pestañas de navegación." } }, "profile": "Perfil", diff --git a/messages/fr.json b/messages/fr.json index ab8aaf052..0c3954a1b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3041,7 +3041,22 @@ }, "disclaimer": { "footer": "HealthLog décrit vos propres données suivies — il ne s'agit pas d'une évaluation clinique ni d'un diagnostic. Parlez à votre médecin de tout ce qui vous préoccupe." - } + }, + "settings": { + "overviewTitle": "Organiser la vue d'ensemble", + "overviewDescription": "Affichez ou masquez des sections et définissez leur ordre sur la vue d'ensemble des Analyses." + }, + "pillOrder": { + "title": "Trier les onglets de navigation", + "description": "Faites glisser les onglets dans l'ordre où ils doivent apparaître en haut des Analyses.", + "dragHandle": "Réorganiser", + "saveSuccess": "Ordre des onglets enregistré.", + "saveError": "L'enregistrement de l'ordre des onglets a échoué.", + "save": "Enregistrer", + "empty": "Aucun onglet de navigation disponible pour le moment." + }, + "customize": "Personnaliser les Analyses", + "regenerateError": "Actualisation en cours — réessayez dans un instant." }, "thresholds": { "defaultLabel": "Par défaut", @@ -3429,6 +3444,10 @@ "sharing": { "title": "Partage", "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé." + }, + "insights": { + "title": "Analyses", + "description": "Ordre de la vue d'ensemble et des onglets de navigation." } }, "profile": "Profil", diff --git a/messages/it.json b/messages/it.json index 1df6a9855..093d200f5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3041,7 +3041,22 @@ }, "disclaimer": { "footer": "HealthLog descrive i tuoi dati registrati — non è una valutazione clinica né una diagnosi. Parla con il tuo medico di qualsiasi cosa ti preoccupi." - } + }, + "settings": { + "overviewTitle": "Organizza la panoramica", + "overviewDescription": "Mostra o nascondi le sezioni e definisci il loro ordine nella panoramica degli Approfondimenti." + }, + "pillOrder": { + "title": "Ordina le schede di navigazione", + "description": "Trascina le schede nell'ordine in cui devono comparire in alto negli Approfondimenti.", + "dragHandle": "Riordina", + "saveSuccess": "Ordine delle schede salvato.", + "saveError": "Salvataggio dell'ordine delle schede non riuscito.", + "save": "Salva", + "empty": "Nessuna scheda di navigazione ancora disponibile." + }, + "customize": "Personalizza gli Approfondimenti", + "regenerateError": "Aggiornamento ancora in corso — riprova tra un momento." }, "thresholds": { "defaultLabel": "Predefinito", @@ -3429,6 +3444,10 @@ "sharing": { "title": "Condivisione", "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico." + }, + "insights": { + "title": "Approfondimenti", + "description": "Ordine della panoramica e delle schede di navigazione." } }, "profile": "Profilo", diff --git a/messages/pl.json b/messages/pl.json index bd5133174..4f43e1734 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3041,7 +3041,22 @@ }, "disclaimer": { "footer": "HealthLog opisuje Twoje własne zarejestrowane dane — nie jest oceną kliniczną ani diagnozą. Porozmawiaj z lekarzem o wszystkim, co Cię niepokoi." - } + }, + "settings": { + "overviewTitle": "Uporządkuj przegląd", + "overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz." + }, + "pillOrder": { + "title": "Sortuj pigułki nawigacyjne", + "description": "Przeciągnij pigułki w kolejności, w jakiej mają się pojawiać u góry Analiz.", + "dragHandle": "Zmień kolejność", + "saveSuccess": "Zapisano kolejność pigułek.", + "saveError": "Zapisanie kolejności pigułek nie powiodło się.", + "save": "Zapisz", + "empty": "Brak dostępnych pigułek nawigacyjnych." + }, + "customize": "Dostosuj Analizy", + "regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę." }, "thresholds": { "defaultLabel": "Domyślne", @@ -3429,6 +3444,10 @@ "sharing": { "title": "Udostępnianie", "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi." + }, + "insights": { + "title": "Analizy", + "description": "Kolejność przeglądu i pigułek nawigacyjnych." } }, "profile": "Profil", diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 38e5fadae..ad7eb5f22 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -8,6 +8,7 @@ import { AiSection } from "@/components/settings/ai-section"; import { ApiSection } from "@/components/settings/api-section"; import { DashboardSection } from "@/components/settings/dashboard-section"; import { ExportSection } from "@/components/settings/export-section"; +import { InsightsSection } from "@/components/settings/insights-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; @@ -49,6 +50,7 @@ const SECTION_COMPONENTS: Record< integrations: IntegrationsSection, notifications: NotificationsSection, dashboard: DashboardSection, + insights: InsightsSection, thresholds: ThresholdsSection, sources: () => , api: ApiSection, diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index dae7fd79a..b55afb91f 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -35,7 +35,7 @@ function renderShell(props: { } describe("SETTINGS_SECTION_SLUGS", () => { - it("declares the twelve sections", () => { + it("declares every section", () => { // Order matters — `generateStaticParams()` and the sidebar derive their // ordering from this constant, so a reorder is a behaviour change. // v1.4.3 split the dashboard panel: layout stays under `dashboard`, @@ -48,11 +48,14 @@ describe("SETTINGS_SECTION_SLUGS", () => { // IW-D merged it into `thresholds`. // v1.8.7.1 — `sources` (Sources) is its own slug again, sitting // between `thresholds` (Targets) and `ai`. + // v1.15.18 — `insights` sits between `dashboard` and `thresholds`: the + // overview-arrange + pill-sort customise surface for `/insights`. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "integrations", "notifications", "dashboard", + "insights", "thresholds", "sources", "ai", diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx new file mode 100644 index 000000000..35b3fd4b9 --- /dev/null +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useMemo } from "react"; + +import { Loader2 } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; +import { InsightsEditMode } from "@/components/insights/insights-edit-mode"; +import { type InsightsSectionId } from "@/lib/insights-layout"; + +/** + * v1.15.18 — overview-arrange block for the Insights settings section. + * + * Embeds the existing v1.15.11 `` (section show/hide + order, + * persisted to `insightsLayoutJson.sections`) as an always-open surface. There + * is no "Anpassen" toggle here — Settings IS the customise surface — so `onClose` + * is a no-op (after a "Fertig" save the layout query has already settled into the + * shared cache; the editor simply stays mounted). + * + * `gatedOffSectionIds` mirrors the mother page's gate logic so a section whose + * feature flag / data gate is off renders its row disabled-with-a-hint rather + * than offering a toggle that does nothing. + */ +export function InsightsOverviewArrangeSection({ id }: { id?: string }) { + const { t } = useTranslations(); + const { isAuthenticated, user } = useAuth(); + const flags = useFeatureFlags(); + const { layout, isLoading } = useInsightsLayoutQuery(isAuthenticated); + + const gatedOffSectionIds = useMemo(() => { + const gated = new Set(); + if (!flags.briefing) { + gated.add("daily-briefing"); + gated.add("period-review"); + } + if (!user?.cycleTrackingEnabled) { + gated.add("cycle-summary"); + } + return gated; + }, [flags.briefing, user?.cycleTrackingEnabled]); + + return ( +
+
+

+ {t("insights.settings.overviewTitle")} +

+

+ {t("insights.settings.overviewDescription")} +

+
+ + {/* Gate the editor mount until the layout GET settles. The editor seeds + its draft once from `layout` on mount, which is the canonical default + while in flight; mounting it early would let a "Fertig" save flush + defaults over the user's real saved layout (the same QA-L1 gate the + mother page applies to its "Anpassen" toggle). */} + {isLoading ? ( +
+ +
+ ) : ( + {}} + /> + )} +
+ ); +} diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx new file mode 100644 index 000000000..186042d10 --- /dev/null +++ b/src/components/settings/insights-pill-order-section.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { GripVertical, Loader2 } from "lucide-react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; +import { reorderById } from "@/lib/insights-layout-reorder"; +import { + type InsightsLayout, + type InsightsTileConfig, +} from "@/lib/insights-layout"; +import { + MANAGER_GROUP_ORDER, + SUB_PAGE_MANAGER_GROUP_SLUGS, + type ManagerGroup, +} from "@/lib/insights/sub-page-metric"; +import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; + +/** + * v1.15.18 — dedicated pill-sort control for the Insights settings section. + * + * The Insights nav PILLS persist to `insightsLayoutJson.tiles[].order`, a field + * that is ALREADY separate from the overview `sections[].order` in layout v2. + * Until now the pill order could only be changed implicitly through the + * overview "Anpassen" → "Kacheln verwalten" disclosure (the Vitals row), which + * the maintainer found unintuitive. This is the explicit surface: drag the + * pills, "Speichern" PUTs the merged `{ tiles }` blob to `/api/insights/layout`, + * which the tab strip + overview share via `queryKeys.insightsLayout()`. + * + * Drag mirrors `insights-edit-mode` — each manager group is its OWN + * `DndContext` + `SortableContext` so a drag never crosses a group boundary. + * The pills here are NOT visibility toggles; visibility lives on the overview + * "Übersicht anordnen" block so the two concerns stay distinct. + */ + +const MANAGER_GROUP_HEADER_KEYS: Record = { + vitals: "insights.tabStrip.vitalsParent.label", + body: "insights.tabStrip.bodyParent.label", + activity: "insights.tabStrip.activityParent.label", + sleep: "insights.editMode.groupSleep", + cardiovascular: "insights.tabStrip.cardiovascularParent.label", + hearing: "insights.tabStrip.hearingParent.label", + environment: "insights.tabStrip.environmentParent.label", + metabolic: "insights.tabStrip.metabolicParent.label", + mood: "insights.editMode.groupMood", + events: "insights.editMode.groupEvents", +}; + +/** Stable id-order fingerprint used to detect when the server copy changed. */ +function orderSignature(tiles: readonly InsightsTileConfig[]): string { + return [...tiles] + .sort((a, b) => a.order - b.order) + .map((tile) => tile.id) + .join(","); +} + +export function InsightsPillOrderSection({ id }: { id?: string }) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + const { layout, isLoading, isSuccess } = + useInsightsLayoutQuery(isAuthenticated); + + // Local draft of the tiles, seeded from the resolved layout and re-seeded in + // render (the React-sanctioned "adjust state on prop change" pattern, no + // effect) whenever the server copy's id-order changes — e.g. after a save or + // once the GET settles. Tracking the last-seeded signature in STATE (not a + // ref) keeps the reseed render-safe and idempotent while leaving in-flight + // drag edits untouched: the baseline only advances when the *server* order + // differs from what we last seeded from. + const serverSignature = orderSignature(layout.tiles); + const [seededSignature, setSeededSignature] = useState(serverSignature); + const [draftTiles, setDraftTiles] = useState( + () => [...layout.tiles].sort((a, b) => a.order - b.order), + ); + if (isSuccess && serverSignature !== seededSignature) { + setSeededSignature(serverSignature); + setDraftTiles([...layout.tiles].sort((a, b) => a.order - b.order)); + } + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const saveMutation = useMutation({ + mutationFn: async (tiles: InsightsTileConfig[]) => { + const res = await fetch("/api/insights/layout", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + version: 2, + // Preserve the overview section order/visibility verbatim — this + // surface owns the PILL order only. + sections: layout.sections, + tiles, + }), + }); + if (!res.ok) throw new Error("save failed"); + return (await res.json()).data as InsightsLayout; + }, + onSuccess: (saved) => { + queryClient.setQueryData(queryKeys.insightsLayout(), saved); + void queryClient.invalidateQueries({ + queryKey: queryKeys.insightsLayout(), + }); + toast.success(t("insights.pillOrder.saveSuccess")); + }, + onError: () => toast.error(t("insights.pillOrder.saveError")), + }); + + const busy = saveMutation.isPending; + + // Group the draft tiles the same way the tab strip + manager do, so each + // group's pills sort within their own `SortableContext`. + const tilesByGroup = useMemo(() => { + const byId = new Map( + draftTiles.map((tile) => [tile.id, tile]), + ); + return MANAGER_GROUP_ORDER.map((group) => { + const rows = SUB_PAGE_MANAGER_GROUP_SLUGS[group] + .map((slug) => { + const cfg = byId.get(slug); + return { + id: slug as string, + labelKey: SUB_PAGE_TABS[slug].labelKey, + order: cfg?.order ?? Number.MAX_SAFE_INTEGER, + }; + }) + .sort((a, b) => a.order - b.order); + return { group, rows }; + }).filter((g) => g.rows.length > 0); + }, [draftTiles]); + + function handleGroupDragEnd(group: ManagerGroup, event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const groupRows = tilesByGroup.find((g) => g.group === group)?.rows ?? []; + const groupMemberIds = new Set(groupRows.map((r) => r.id)); + const subset = groupRows.map((r) => ({ id: r.id, order: r.order })); + const reordered = reorderById(subset, String(active.id), String(over.id)); + const reorderedIds = reordered.map((r) => r.id); + + setDraftTiles((tiles) => { + // Substitute this group's slots in their new relative order, leaving + // every other tile (and its order) untouched. Walk the tile list and, + // at each group-member slot, take the next id from the reordered list. + let cursor = 0; + return tiles.map((tile) => { + if (!groupMemberIds.has(tile.id)) return tile; + const nextId = reorderedIds[cursor++]; + const replacement = tiles.find((tt) => tt.id === nextId); + // Keep this slot's `order` and `visible`; swap the id into position. + return replacement + ? { ...replacement, order: tile.order } + : tile; + }); + }); + } + + const dirty = useMemo(() => { + const original = [...layout.tiles].sort((a, b) => a.order - b.order); + if (original.length !== draftTiles.length) return true; + const draftByOrder = [...draftTiles].sort((a, b) => a.order - b.order); + return original.some((tt, i) => tt.id !== draftByOrder[i]?.id); + }, [layout.tiles, draftTiles]); + + return ( +
+
+
+

+ {t("insights.pillOrder.title")} +

+

+ {t("insights.pillOrder.description")} +

+
+ +
+ + {isLoading ? ( +

+ +

+ ) : tilesByGroup.length === 0 ? ( +

+ {t("insights.pillOrder.empty")} +

+ ) : ( +
+ {tilesByGroup.map(({ group, rows }) => ( +
+

+ {t(MANAGER_GROUP_HEADER_KEYS[group])} +

+ handleGroupDragEnd(group, event)} + > + r.id)} + strategy={verticalListSortingStrategy} + > +
+ {rows.map((tile) => ( + + ))} +
+
+
+
+ ))} +
+ )} +
+ ); +} + +const DRAG_HANDLE_CLASS = + "text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex h-11 w-11 shrink-0 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9 sm:before:absolute sm:before:inset-[-6px] sm:before:content-['']"; + +function SortablePillRow({ + id, + title, + disabled, + dragHandleLabel, +}: { + id: string; + title: string; + disabled: boolean; + dragHandleLabel: string; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: prefersReducedMotion() ? "none" : transition, + }; + + return ( +
+ + + {title} + +
+ ); +} diff --git a/src/components/settings/insights-section.tsx b/src/components/settings/insights-section.tsx new file mode 100644 index 000000000..355de8554 --- /dev/null +++ b/src/components/settings/insights-section.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section"; +import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.15.18 — the "Insights" settings section (analogous to the Dashboard + * section). Hosts two blocks: + * 1. Arrange the OVERVIEW — section show/hide + order, persisted to + * `insightsLayoutJson.sections` (the v1.15.11 inline edit mode). + * 2. Sort the navigation PILLS — pill order, persisted to + * `insightsLayoutJson.tiles[].order` (already a separate field in v2). + * + * Both write through the same `/api/insights/layout` contract the tab strip + * and the overview share, so a save here repaints both surfaces in lockstep. + */ +export function InsightsSection() { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("settings.sections.insights.title")} +

+

+ {t("settings.sections.insights.description")} +

+
+ + + +
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 3f05ea25d..d3c70a569 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -22,6 +22,7 @@ import { Settings2, SlidersHorizontal, Sparkles, + TrendingUp, User, type LucideIcon, } from "lucide-react"; @@ -35,6 +36,7 @@ const SLUG_ICON: Record = { integrations: Link2, notifications: Bell, dashboard: LayoutDashboard, + insights: TrendingUp, thresholds: SlidersHorizontal, sources: Layers, ai: Sparkles, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 76f010ef5..d476fa92d 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -18,6 +18,7 @@ export const SETTINGS_SECTION_SLUGS = [ "integrations", "notifications", "dashboard", + "insights", "thresholds", "sources", "ai", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 22043e34a..22a2c0ebf 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -27,6 +27,7 @@ import { Settings2, SlidersHorizontal, Sparkles, + TrendingUp, User, type LucideIcon, } from "lucide-react"; @@ -97,6 +98,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.dashboard.title", icon: LayoutDashboard, }, + { + slug: "insights", + titleKey: "settings.sections.insights.title", + icon: TrendingUp, + }, { slug: "thresholds", titleKey: "settings.sections.thresholds.title", From 580017f368cd34d25285777f1f9e8e096867c0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:49:25 +0200 Subject: [PATCH 18/31] feat(insights): add a customise cog to the overview, left of regenerate The tab strip gains a settings cog immediately left of the regenerate button, linking to the new Insights settings section (overview arrange + pill sort). It mirrors the Dashboard cog idiom and travels with the regenerate affordance so the two top-right controls sit together. --- .../insights/insights-tab-strip.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 91488705a..5550bc4f2 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -3,7 +3,7 @@ import { memo, useEffect, useMemo, useRef } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ChevronDown, Loader2, RefreshCw } from "lucide-react"; +import { ChevronDown, Loader2, RefreshCw, Settings2 } from "lucide-react"; import { toast } from "sonner"; import { @@ -475,6 +475,7 @@ function InsightsTabStripImpl({ }, [regenerating, t]); const regenerateLabel = t("insights.regenerateAnalysis"); + const customizeLabel = t("insights.customize"); return (