diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index 1c09be209..609ae58b3 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -1,7 +1,8 @@ # Medication intake repair -`scripts/repair-intake-anomalies.ts` repairs two known historic defects in -the medication intake ledger (`medication_intake_events`): +`scripts/repair-intake-anomalies.ts` repairs the known historic defects in +the medication intake ledger (`medication_intake_events`) and its schedule +metadata (`medication_schedules`): 1. **Duplicate dose-slot rows.** More than one live row on the same exact `(user, medication, scheduled_for)` tuple — cross-source duplicates the @@ -12,6 +13,20 @@ the medication intake ledger (`medication_intake_events`): 7 days before `scheduled_for` or more than 1 day in the future — typically the residue of a mis-attributed edit before the v1.15.19 `taken_at` validation landed. +3. **Window / times drift (v1.16.1).** Schedule rows whose legacy + `window_start` / `window_end` no longer contains the canonical + `times_of_day` — typically a degenerate `07:00 / 07:00` point window + left behind by an old write path while the dose times moved on (e.g. + to `09:00 / 21:00`). The dose-band model never reads the window once + `times_of_day` exists, but the stale pair kept feeding every legacy + read (the pre-v1.16.1 cards painted "next intake 07:00" from it). +4. **Stale-anchor pending rows (v1.16.1).** Live pending rows + (`taken_at IS NULL`, not skipped, not auto-missed) whose + `scheduled_for` wall-clock HH:mm — evaluated in the user's timezone — + matches no current dose anchor of the medication (any schedule's + `times_of_day` entry; `window_start` for legacy rows without times). + These are reminder-minted slots on retired schedule times that linger + as phantom open doses in the ledger. ## When to use it @@ -51,8 +66,10 @@ Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips `tsx`. The dry-run prints every duplicate group (which row would be kept, which -would be tombstoned) and a table of implausible rows (id, medication, -`scheduledFor`, `takenAt`, source, timestamps). +would be tombstoned), a table of implausible rows (id, medication, +`scheduledFor`, `takenAt`, source, timestamps), every schedule whose +window has drifted off its `times_of_day` (with the reconciled bounds it +would write), and a table of stale-anchor pending rows. ## Applying the fix @@ -60,7 +77,7 @@ would be tombstoned) and a table of implausible rows (id, medication, pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix ``` -`--fix` does three things: +`--fix` does five things: - **Duplicate groups** are collapsed to one winner per slot. Precedence: an actioned row (taken or skipped) beats a pending one; between two @@ -79,6 +96,14 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix ``` `--tombstone-implausible` is refused without `--fix`. +- **Drifted schedule windows are reconciled** to + `min/max(times_of_day)` so the legacy pair stops contradicting the + canonical dose times. Overnight windows (`window_end < window_start`) + encode a deliberate wrap and are never touched. +- **Stale-anchor pending rows are tombstoned** (same soft-delete shape + as the duplicate losers). A medication without any derivable anchor + (PRN-only / unscheduled) is skipped — there is nothing to compare + against. - **Affected compliance rollups are recomputed** for every touched `(user, medication, day)`. The script executes the same DISTINCT-slot aggregation SQL the shared helper runs (a verbatim twin of diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index f244c10ea..593a5d353 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -1,5 +1,5 @@ /** - * scripts/repair-intake-anomalies.ts — operator repair for the two known + * scripts/repair-intake-anomalies.ts — operator repair for the known * historic medication-ledger defects: * * 1. SLOT DUPLICATES — more than one live `medication_intake_events` row @@ -18,6 +18,26 @@ * `--fix --tombstone-implausible` the operator can explicitly opt * into soft-deleting them. * + * 3. WINDOW / TIMES DRIFT (v1.16.1) — `medication_schedules` rows whose + * legacy `window_start` / `window_end` no longer contains the + * canonical `times_of_day` (e.g. a degenerate 07:00 / 07:00 window + * left behind while the dose times moved to 09:00 / 21:00). The + * band model never reads the window when `times_of_day` exists, but + * the stale pair keeps confusing every legacy read. Repair: clamp + * `window_start` / `window_end` to `min/max(times_of_day)`. Report + * always; mutate with `--fix`. Overnight windows + * (`window_end < window_start`) are skipped — they encode a + * deliberate wrap and cannot drift in this shape. + * + * 4. STALE-ANCHOR PENDING ROWS (v1.16.1) — live pending rows + * (`taken_at IS NULL`, not skipped, not auto-missed) whose + * `scheduled_for` wall-clock HH:mm (user timezone) matches NO + * current dose anchor of the medication (any schedule's + * `times_of_day` entry; `window_start` for legacy rows without + * times). These are reminder-minted slots on retired schedule + * anchors — they linger as phantom open doses. Repair: tombstone + * with `--fix` (same soft-delete shape as defect 1). + * * After any mutation the affected `(user, medication, day)` compliance * rollups are recomputed with the SAME slot-level DISTINCT aggregation the * shared helper runs — see `recomputeComplianceDay` below, a verbatim @@ -350,9 +370,30 @@ interface Summary { rowsTombstoned: number; implausibleRows: number; implausibleTombstoned: number; + windowDriftSchedules: number; + windowsReconciled: number; + staleAnchorRows: number; + staleAnchorTombstoned: number; rollupsRecomputed: number; } +const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** Wall-clock `HH:mm` of `instant` in the user's timezone. */ +function hhmmInTz(instant: Date, tz: string | null | undefined): string { + const parts = new Intl.DateTimeFormat("en-GB", { + timeZone: safeTimezone(tz), + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).formatToParts(instant); + const get = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((p) => p.type === type)?.value ?? "00"; + // Some engines render midnight as 24:xx; normalise to 00. + const hour = String(Number(get("hour")) % 24).padStart(2, "0"); + return `${hour}:${get("minute")}`; +} + async function main(): Promise { if (!process.env.DATABASE_URL) { console.error( @@ -384,6 +425,10 @@ async function main(): Promise { rowsTombstoned: 0, implausibleRows: 0, implausibleTombstoned: 0, + windowDriftSchedules: 0, + windowsReconciled: 0, + staleAnchorRows: 0, + staleAnchorTombstoned: 0, rollupsRecomputed: 0, }; @@ -591,13 +636,184 @@ async function main(): Promise { } // ───────────────────────────────────────────────────────────────── - // 3. Recompute the affected compliance rollups so scheduled/taken + // 3. Window / times drift — schedules whose legacy window no longer + // contains the canonical times_of_day. Reconcile the window to + // min/max(times_of_day) with --fix. Overnight windows + // (window_end < window_start) are skipped by the predicate. + // ───────────────────────────────────────────────────────────────── + const schedUserFilter = opts.userId ? `AND m."user_id" = $1` : ""; + const driftRes = await client.query<{ + id: string; + medication_id: string; + user_id: string; + medication_name: string; + window_start: string; + window_end: string; + times_min: string; + times_max: string; + }>( + ` + SELECT s."id", s."medication_id", m."user_id", + m."name" AS medication_name, + s."window_start", s."window_end", + (SELECT MIN(t) FROM unnest(s."times_of_day") AS t) AS times_min, + (SELECT MAX(t) FROM unnest(s."times_of_day") AS t) AS times_max + FROM "medication_schedules" s + JOIN "medications" m ON m."id" = s."medication_id" + WHERE COALESCE(array_length(s."times_of_day", 1), 0) > 0 + AND s."window_start" <= s."window_end" + AND ( + (SELECT MIN(t) FROM unnest(s."times_of_day") AS t) < s."window_start" + OR (SELECT MAX(t) FROM unnest(s."times_of_day") AS t) > s."window_end" + ) + ${schedUserFilter} + ORDER BY m."user_id", s."medication_id", s."id" + `, + userParams, + ); + + summary.windowDriftSchedules = driftRes.rows.length; + console.log( + `\n${TAG} 3) schedules whose window does not contain times_of_day: ${driftRes.rows.length}`, + ); + for (const row of driftRes.rows) { + console.log( + ` - schedule=${row.id} medication=${row.medication_id} (${row.medication_name}) ` + + `window=${row.window_start}..${row.window_end} → ` + + `${row.times_min}..${row.times_max}`, + ); + if (!HHMM_RE.test(row.times_min) || !HHMM_RE.test(row.times_max)) { + console.log( + `${TAG} SKIP: malformed times_of_day bounds on schedule ${row.id}`, + ); + continue; + } + if (opts.fix) { + const res = await client.query( + ` + UPDATE "medication_schedules" + SET "window_start" = $2, + "window_end" = $3 + WHERE "id" = $1 + `, + [row.id, row.times_min, row.times_max], + ); + summary.windowsReconciled += res.rowCount ?? 0; + } + } + + // ───────────────────────────────────────────────────────────────── + // 4. Stale-anchor pending rows — live pending rows whose + // scheduled_for HH:mm (user tz) matches no current dose anchor of + // the medication. Tombstone with --fix. + // ───────────────────────────────────────────────────────────────── + const pendingRes = await client.query<{ + id: string; + user_id: string; + medication_id: string; + medication_name: string; + scheduled_for: Date; + source: string; + }>( + ` + SELECT e."id", e."user_id", e."medication_id", + m."name" AS medication_name, + e."scheduled_for", e."source"::text AS source + FROM "medication_intake_events" e + JOIN "medications" m ON m."id" = e."medication_id" + WHERE e."deleted_at" IS NULL + AND e."taken_at" IS NULL + AND e."skipped" = false + AND e."auto_missed" = false + ${userFilter} + ORDER BY e."user_id", e."scheduled_for" + `, + userParams, + ); + + /** `medicationId -> Set` of current dose anchors. */ + const anchorCache = new Map>(); + async function medicationAnchors( + medicationId: string, + ): Promise> { + const cached = anchorCache.get(medicationId); + if (cached) return cached; + const res = await client.query<{ + window_start: string; + times_of_day: string[]; + }>( + `SELECT "window_start", "times_of_day" + FROM "medication_schedules" WHERE "medication_id" = $1`, + [medicationId], + ); + const anchors = new Set(); + for (const s of res.rows) { + const times = (s.times_of_day ?? []).filter((t) => HHMM_RE.test(t)); + if (times.length > 0) { + for (const t of times) anchors.add(t); + } else if (HHMM_RE.test(s.window_start)) { + // Legacy row without times_of_day — its anchor is window_start. + anchors.add(s.window_start); + } + } + anchorCache.set(medicationId, anchors); + return anchors; + } + + const staleRows: typeof pendingRes.rows = []; + for (const row of pendingRes.rows) { + const anchors = await medicationAnchors(row.medication_id); + // No derivable anchors (PRN-only / unscheduled) — leave the row. + if (anchors.size === 0) continue; + const tz = await userTimezone(row.user_id); + if (!anchors.has(hhmmInTz(row.scheduled_for, tz))) { + staleRows.push(row); + } + } + + summary.staleAnchorRows = staleRows.length; + console.log( + `\n${TAG} 4) live pending rows on stale schedule anchors: ${staleRows.length}`, + ); + if (staleRows.length > 0) { + console.table( + staleRows.map((r) => ({ + id: r.id, + medication: r.medication_name, + scheduledFor: r.scheduled_for.toISOString(), + source: r.source, + })), + ); + } + + if (opts.fix && staleRows.length > 0) { + const res = await client.query( + ` + UPDATE "medication_intake_events" + SET "deleted_at" = NOW(), + "sync_version" = "sync_version" + 1, + "updated_at" = NOW() + WHERE "id" = ANY($1) AND "deleted_at" IS NULL + `, + [staleRows.map((r) => r.id)], + ); + summary.staleAnchorTombstoned = res.rowCount ?? 0; + + for (const row of staleRows) { + const tz = await userTimezone(row.user_id); + const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz); + daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`); + } + } + + // ───────────────────────────────────────────────────────────────── + // 5. Recompute the affected compliance rollups so scheduled/taken // counts self-correct. Same DISTINCT-slot SQL the shared helper // runs (see `recomputeComplianceDay` above). // ───────────────────────────────────────────────────────────────── if (opts.fix && daysToRecompute.size > 0) { console.log( - `\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`, + `\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`, ); for (const key of daysToRecompute) { const [userId, medicationId, dayKey] = key.split("|"); @@ -630,6 +846,10 @@ async function main(): Promise { console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`); console.log(` implausible taken_at rows: ${summary.implausibleRows}`); console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`); + console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`); + console.log(` windows reconciled: ${summary.windowsReconciled}`); + console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`); + console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`); console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`); } diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 34832172c..b7daa7577 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), }, medicationIntakeEvent: { + findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, apiToken: { @@ -32,6 +33,13 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); +// v1.16.1 — the schedule-replace branch recomputes the compliance +// rollups of the tombstoned slots; stub the rollup module so the route +// test never reaches `$executeRaw`. +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + dayKeyForScheduledFor: vi.fn(() => "2026-06-10"), + recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/medication-category", () => ({ deleteMedicationCategory: vi.fn().mockResolvedValue(undefined), getMedicationCategories: vi.fn().mockResolvedValue({}), @@ -55,6 +63,10 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { getMedicationCategories } from "@/lib/medication-category"; import { auditLog } from "@/lib/auth/audit"; +import { + dayKeyForScheduledFor, + recomputeMedicationComplianceForDay, +} from "@/lib/rollups/medication-compliance-rollups"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -78,6 +90,17 @@ beforeEach(() => { id: "m1", userId: "user-1", } as never); + // `resetAllMocks` clears the factory-level resolved values, so the + // schedule-replace reads need fresh defaults each test. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.medicationSchedule.deleteMany).mockResolvedValue({ + count: 0, + } as never); }); describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => { @@ -230,6 +253,40 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", ( expect(arg.data.syncVersion).toEqual({ increment: 1 }); }); + it("recomputes the compliance rollups of the tombstoned days (v1.16.1)", async () => { + // Two pendings on the same local day + one on another day → exactly + // two distinct day-key recomputes, not three. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { scheduledFor: new Date("2026-06-10T06:00:00Z") }, + { scheduledFor: new Date("2026-06-10T18:00:00Z") }, + { scheduledFor: new Date("2026-06-11T06:00:00Z") }, + ] as never); + vi.mocked(dayKeyForScheduledFor).mockImplementation( + (scheduledFor: Date) => scheduledFor.toISOString().slice(0, 10), + ); + + const res = await PUT( + putReq({ + schedules: [{ windowStart: "20:00", windowEnd: "20:30" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); + expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( + "user-1", + "m1", + "2026-06-10", + "Europe/Berlin", + ); + expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( + "user-1", + "m1", + "2026-06-11", + "Europe/Berlin", + ); + }); + it("does NOT touch intake rows when the body carries no schedules array", async () => { const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX); expect(res.status).toBe(200); 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 86dcc6585..cea060613 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -439,7 +439,9 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => { expect(vi.mocked(cached)).toHaveBeenCalledTimes(1); const [, key] = vi.mocked(cached).mock.calls[0]; // `${userId}|...` prefix is what `invalidateUserMedications` sweeps. - expect(key).toBe("user-1|med-1|compliance"); + // The trailing tz segment (v1.16.1) makes a timezone change miss the + // old entry instead of serving day buckets computed for the prior zone. + expect(key).toBe("user-1|med-1|compliance|UTC"); }); it("bounds the intake-event read to a trailing window and the consumed columns", async () => { diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 905bdd490..404fe8590 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -258,10 +258,14 @@ export const GET = apiHandler( // Read-through the per-user compliance cache (15 min TTL). Every // intake / medication write flushes the `${userId}|` prefix via // `invalidateUserMedications`, so a warm entry can only be stale - // relative to wall-clock drift, never to a user action. + // relative to wall-clock drift, never to a user action. The key + // carries the timezone the payload was bucketed in: day keys, the + // heatmap, and the overdue cut all derive from `userTz`, so a + // timezone change must miss the old entry instead of serving day + // buckets computed for the previous zone until the TTL lapses. const payload = await cached( caches.medicationCompliance as ServerCache, - `${user.id}|${id}|compliance`, + `${user.id}|${id}|compliance|${userTz}`, () => buildCompliancePayload(medication, user.id, userTz), annotate, ); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts index 458098122..799378209 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -1,6 +1,10 @@ /** * v1.4.43 W6 — multi-issue 422 envelope on PATCH * /api/medications/[id]/inventory/[itemId]. + * + * v1.16.1 — stock-correction contract: `dosesRemaining` sets the count + * absolutely, clamps to `dosesTotal`, and the canonical state machine + * derives the next state (0 ⇒ USED_UP). */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -124,3 +128,55 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1 expect(body.details.issues.length).toBeGreaterThanOrEqual(3); }); }); + +describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => { + beforeEach(() => { + vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ + id: "i1", + medicationId: "m1", + userId: "user-1", + firstUseAt: null, + state: "IN_USE", + dosesTotal: 4, + dosesRemaining: 3, + printedExpiry: null, + notes: null, + } as never); + vi.mocked(prisma.medicationInventoryItem.update).mockImplementation( + (async (args: { data: Record }) => ({ + id: "i1", + ...args.data, + })) as never, + ); + }); + + it("sets the remaining count absolutely", async () => { + const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX); + expect(res.status).toBe(200); + const update = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0][0] as { data: { dosesRemaining: number } }; + expect(update.data.dosesRemaining).toBe(1); + }); + + it("derives USED_UP when corrected to zero", async () => { + const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX); + expect(res.status).toBe(200); + const update = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0][0] as { data: { dosesRemaining: number; state: string } }; + expect(update.data.dosesRemaining).toBe(0); + expect(update.data.state).toBe("USED_UP"); + }); + + it("clamps a raise above the item's capacity to dosesTotal", async () => { + const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX); + expect(res.status).toBe(200); + const update = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0][0] as { data: { dosesRemaining: number } }; + expect(update.data.dosesRemaining).toBe(4); + }); + + it("rejects a negative correction with 422", async () => { + const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 25b7aa3b7..684026b20 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -6,6 +6,9 @@ * - mark-as-used-up (terminal — operator override when a pen is * physically discarded but the dose ledger hasn't reached zero) * - update printed expiry (e.g. carton label correction) + * - stock correction via `dosesRemaining` (v1.16.1 — the Bestand + * tab's adjust / withdraw flow; clamped to `dosesTotal`, state + * re-derived by the canonical state machine) * - update notes * * DELETE /api/medications/[id]/inventory/[itemId] @@ -67,8 +70,13 @@ export const PATCH = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } = - parsed.data; + const { + markAsFirstUseAt, + markAsUsedUp, + printedExpiry, + dosesRemaining, + notes, + } = parsed.data; // Compose the next-row shape. Each mutation field is optional and // commutative — applying them in any order produces the same row. @@ -88,6 +96,14 @@ export const PATCH = apiHandler( nextPrintedExpiry = printedExpiry; } + if (dosesRemaining !== undefined) { + // v1.16.1 — stock correction (Bestand tab adjust / withdraw). + // Clamp to the item's capacity; the state-machine re-run below + // owns every state consequence (0 ⇒ USED_UP, a raise out of 0 + // re-evaluates against the expiry clocks). + nextDosesRemaining = Math.min(dosesRemaining, existing.dosesTotal); + } + if (markAsUsedUp === true) { nextDosesRemaining = 0; nextState = "USED_UP"; diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index eb2df8097..0a3920549 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -18,6 +18,10 @@ import { } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { computeNextDueAt } from "@/lib/medications/scheduling/next-due"; +import { + dayKeyForScheduledFor, + recomputeMedicationComplianceForDay, +} from "@/lib/rollups/medication-compliance-rollups"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { getUserTodayBounds } from "@/lib/timezone"; import { NextRequest } from "next/server"; @@ -287,22 +291,50 @@ export const PUT = apiHandler( // `syncVersion` so delta-sync clients drop them. The projector and // the reminder worker re-mint the anchors for the new times on their // next pass — the route does not pre-create them. - const { start: todayStart } = getUserTodayBounds( - new Date(), - user.timezone || "Europe/Berlin", - ); + const userTz = user.timezone || "Europe/Berlin"; + const { start: todayStart } = getUserTodayBounds(new Date(), userTz); + const tombstoneWhere = { + userId: user.id, + medicationId: id, + deletedAt: null, + takenAt: null, + skipped: false, + autoMissed: false, + scheduledFor: { gte: todayStart }, + } as const; + // Capture the affected slots BEFORE the tombstone so the compliance + // rollups for those days can be recomputed immediately below — + // `updateMany` does not return the touched rows. Without the + // recompute a tombstoned pending kept counting as `scheduled` in + // the rollup row until the next event-driven write for that day, + // depressing the rate the cards read in the meantime. + const tombstoned = await prisma.medicationIntakeEvent.findMany({ + where: tombstoneWhere, + select: { scheduledFor: true }, + }); await prisma.medicationIntakeEvent.updateMany({ - where: { - userId: user.id, - medicationId: id, - deletedAt: null, - takenAt: null, - skipped: false, - autoMissed: false, - scheduledFor: { gte: todayStart }, - }, + where: tombstoneWhere, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); + // Recompute each affected rollup day (deduped via the user-tz day + // key). Best-effort like every other rollup write-hook: a failure + // is annotated, never blocks the schedule replace. + const staleDayKeys = new Set( + tombstoned.map((row) => dayKeyForScheduledFor(row.scheduledFor, userTz)), + ); + for (const dayKey of staleDayKeys) { + try { + await recomputeMedicationComplianceForDay(user.id, id, dayKey, userTz); + } catch (err) { + annotate({ + meta: { + medication_compliance_rollup_failed: true, + medication_compliance_rollup_error: + err instanceof Error ? err.message : String(err), + }, + }); + } + } await prisma.medicationSchedule.deleteMany({ where: { medicationId: id }, }); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 53bbb9509..1d19ad058 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -171,15 +171,14 @@ export default function MedicationsPage() { queryClient.setQueryData(queryKeys.medicationDetail(med.id), med); } - // v1.15.20 — the kebab's Edit opens the medication wizard directly: - // it navigates to the detail page with `?edit=1`, which the tabbed - // shell reads synchronously to mount the wizard in edit mode (the same - // `editOpen` path the hero's "Vollständig bearbeiten" uses). History - // still lands on the Verlauf tab; the former Advanced item is retired — - // the Erweitert tab stays reachable through the card body. + // v1.16.1 — the kebab's Edit lands on the detail page's Übersicht tab, + // exactly like tapping the medication name. Every everyday edit lives + // inline in the tabs there; the structural wizard is reachable via + // Erweitert → Lebenszyklus (and still answers `?edit=1` deep-links). + // History still lands on the Verlauf tab. function openEdit(med: Medication) { seedDetail(med); - router.push(`/medications/${med.id}?edit=1`); + router.push(`/medications/${med.id}`); } function openHistory(med: Medication) { diff --git a/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts b/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts index 79433d9b3..4de375dc9 100644 --- a/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts +++ b/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts @@ -61,6 +61,26 @@ describe("resolveDisplayedSlotInstant", () => { expect(slot!.toISOString()).toBe("2026-06-05T17:00:00.000Z"); }); + it("anchors on the matched band's timeOfDay over the stale legacy windowStart (v1.16.1)", () => { + // The production regression fixture: degenerate 07:00 / 07:00 window + // left behind while the dose times moved to 09:00 / 21:00. The matched + // band carries timeOfDay 09:00 — the recorded slot must be 09:00, not + // the stale window's 07:00. + const inBandNow = new Date("2026-06-05T07:30:00.000Z"); // 09:30 Berlin + const slot = resolveDisplayedSlotInstant({ + currentWindowStatus: { + status: "in_window", + schedule: { windowStart: "07:00", windowEnd: "07:00" }, + window: { timeOfDay: "09:00" }, + }, + nextDueAt: null, + now: inBandNow, + timeZone: "Europe/Berlin", + }); + // 09:00 Berlin on 2026-06-05 is 07:00 UTC (CEST = +2). + expect(slot!.toISOString()).toBe("2026-06-05T07:00:00.000Z"); + }); + it("falls back to the server's next-due instant when no window is currently actionable", () => { const nextDueAt = "2026-06-06T05:00:00.000Z"; const slot = resolveDisplayedSlotInstant({ diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts index 8c0b8ff87..1f7d77f56 100644 --- a/src/components/medications/card-parts/displayed-slot-instant.ts +++ b/src/components/medications/card-parts/displayed-slot-instant.ts @@ -33,10 +33,17 @@ export interface ResolveDisplayedSlotInstantInput { * The card's current-window status. When its `status` is non-null the * card is surfacing that schedule's window today (in_window / late / * very_late), so the recorded dose is that window's slot today. + * + * v1.16.1 — `window` carries the matched dose band's anchor + * (`timeOfDay`, a `timesOfDay` entry). It is the canonical slot time; + * the schedule's `windowStart` stays only as the legacy fallback for + * rows without `timesOfDay`, so a stale / degenerate window can no + * longer mis-anchor the recorded dose. */ currentWindowStatus: { status: "in_window" | "late" | "very_late" | null; schedule: DisplayedSlotSchedule | null; + window?: { timeOfDay: string } | null; }; /** * The server-computed next-due instant (`computeNextDueAt`, the canonical @@ -130,10 +137,15 @@ export function resolveDisplayedSlotInstant( const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } = input; - // The card surfaces the current/overdue window — record that window's - // slot on today's calendar day. + // The card surfaces the current/overdue dose band — record that band's + // slot on today's calendar day. The matched band's `timeOfDay` is the + // canonical dose anchor; `windowStart` is only the legacy fallback for + // schedules without `timesOfDay`. if (currentWindowStatus.status && currentWindowStatus.schedule) { - const hm = parseHm(currentWindowStatus.schedule.windowStart); + const anchor = + currentWindowStatus.window?.timeOfDay ?? + currentWindowStatus.schedule.windowStart; + const hm = parseHm(anchor); if (hm) return localHmTodayAsUtc(now, timeZone, hm.hour, hm.minute); } diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index d0bf63541..c77419b99 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -151,6 +151,7 @@ const TAB_SLUGS = [ "bestand", "verlauf", "injektion", + "api", "erweitert", ] as const; type TabSlug = (typeof TAB_SLUGS)[number]; @@ -337,31 +338,19 @@ export function MedicationDetailTabs({ - {/* HERO — always-visible read-only summary + the structural-edit - jump into the wizard. Everyday levers live in the tabs below. */} -
- - -
+ {/* HERO — always-visible read-only summary. The structural edit + (wizard) moved out of the header: it lives as a row under + Erweitert → Lebenszyklus (the header button read as "edit this + page" and misled). `?edit=1` still opens the wizard directly. */} + + {/* Quiet cross-link: WHICH channels deliver the reminder + (push / Telegram / ntfy, quiet hours) is configured + globally, not per medication. */} +

+ + {t("medications.detail.zeitplan.manageChannels")} + +

- {/* BESTAND — inventory readout for all meds. */} + {/* BESTAND — inventory readout + register / correct flows. */} - + {/* VERLAUF — the dose-history ledger: every expected slot with its @@ -586,36 +590,14 @@ export function MedicationDetailTabs({ )} - {/* ERWEITERT — the dissolved advanced-settings sheet, now grouped - un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */} - - -
- -
- {isGlp1 && ( -
- setPhaseSheetOpen(true)} - /> -
- )} -
- + {/* API — the external-ingest surface in its own tab: per-medication + tokens + the drug-coding identifiers external systems key on. + Pulled out of Erweitert so the destructive zone and the token + management stop sharing one surface. */} +
+
+ + {/* ERWEITERT — Lifecycle → Daten → Gefahrenzone. The external-API + group moved to its own API tab. */} + + +
+ +
+ {/* The structural editor (the create/edit wizard) — name, + class, cadence kind, schedules. Replaces the former hero + "Vollständig bearbeiten" button, which read as "edit this + page". */} +
+
+

+ {t("medications.detail.erweitert.editor.title")} +

+

+ {t("medications.detail.erweitert.editor.helper")} +

+
+ +
+ {isGlp1 && ( +
+ setPhaseSheetOpen(true)} + /> +
+ )} +
-
- -
+
); } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index f151d14dd..c1adab774 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -52,6 +52,13 @@ interface ScheduleLite { label: string | null; dose: string | null; daysOfWeek: string | null; + /** + * v1.16.1 — first-class dose times + explicit per-dose bands; the + * window-status helper derives its bands from these (canonical) and + * only falls back to the legacy window when they are absent. + */ + timesOfDay?: string[]; + doseWindows?: { timeOfDay: string; start: string; end: string }[] | null; } interface DoseChangeLite { @@ -402,8 +409,10 @@ export function Glp1MedicationCard({ currentWindowStatus.status ? { status: currentWindowStatus.status, - windowStart: currentWindowStatus.schedule!.windowStart, - windowEnd: currentWindowStatus.schedule!.windowEnd, + // v1.16.1 — the pill shows the MATCHED dose band, not the + // legacy schedule window (which may be stale / degenerate). + windowStart: currentWindowStatus.window!.start, + windowEnd: currentWindowStatus.window!.end, } : null } diff --git a/src/components/medications/medication-card-menu.tsx b/src/components/medications/medication-card-menu.tsx index 86bb31b3d..f6a890894 100644 --- a/src/components/medications/medication-card-menu.tsx +++ b/src/components/medications/medication-card-menu.tsx @@ -9,9 +9,10 @@ * affordance instead of a cluster. The card body itself navigates to the * detail page (the former chevron target); this menu owns the edit and * history actions. v1.15.20 retires the "Advanced settings" item — the - * detail page's Erweitert tab is reachable through the card body, and - * Edit now opens the medication wizard directly instead of landing on - * the Zeitplan tab. + * detail page's Erweitert tab is reachable through the card body. + * v1.16.1 — Edit navigates to the detail page's Übersicht tab (the same + * target as the name tap); the structural wizard lives under the detail + * page's Erweitert → Lebenszyklus row. * * The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a * "Log side effect" item into the SAME menu, but it is NOT wired on the diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 861ea278a..d74f5ced3 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -34,6 +34,14 @@ interface Schedule { label: string | null; dose: string | null; daysOfWeek: string | null; + /** + * v1.16.1 — first-class dose times + explicit per-dose bands. The + * window-status helper derives its bands from these (canonical) and + * only falls back to `windowStart` / `windowEnd` when they are absent, + * so a stale window can no longer drive the pill or the recorded slot. + */ + timesOfDay?: string[]; + doseWindows?: { timeOfDay: string; start: string; end: string }[] | null; } interface Medication { @@ -318,10 +326,18 @@ export function MedicationCard({ } } + // v1.16.1 — the TIME is the canonical next-due instant (the + // recurrence engine's timesOfDay-anchored slot), not the legacy + // schedule window. A stale `windowStart` / `windowEnd` pair + // (e.g. 07:00 / 07:00 while the times moved to 09:00 / 21:00) + // used to print "today, 07:00" here. The window range stays as + // the fallback for rows the server has no next-due for. return ( <> {dayLabel && `${dayLabel}, `} - {formatTimeWindowRange(s.windowStart, s.windowEnd, locale)} + {nextAt + ? formatTime(new Date(nextAt).toISOString()) + : formatTimeWindowRange(s.windowStart, s.windowEnd, locale)} {s.label && ( ({s.label}) )} @@ -350,8 +366,10 @@ export function MedicationCard({ currentWindowStatus.status ? { status: currentWindowStatus.status, - windowStart: currentWindowStatus.schedule!.windowStart, - windowEnd: currentWindowStatus.schedule!.windowEnd, + // v1.16.1 — the pill shows the MATCHED dose band, not the + // legacy schedule window (which may be stale / degenerate). + windowStart: currentWindowStatus.window!.start, + windowEnd: currentWindowStatus.window!.end, } : null } diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index 6f3215aac..c686e9f04 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -43,7 +43,6 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -233,8 +232,10 @@ export function LifecycleManageBody({ /** * v1.7.0 — irreversible danger zone: purge intake history + delete the - * medication. Wrapped in a `border-destructive/40` card to keep the - * visual warning convention. + * medication. v1.16.1 — the body is bare rows (the caller's + * `SettingsGroup` supplies the neutral card chrome and dividers); the + * destructive signal lives ONLY in the red action buttons + the confirm + * dialogs, not in a tinted surface. */ export function DangerZoneBody({ medicationId, @@ -317,8 +318,8 @@ export function DangerZoneBody({ } return ( - {/* Tier 3a — Verlauf löschen. Matched pair with the delete row @@ -440,7 +441,7 @@ export function DangerZoneBody({ - + ); } diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index cc296344b..3e7e1c02f 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -3,23 +3,42 @@ /** * v1.15.18 — Bestand (supply) tab body. * - * Read-only inventory readout for the medication detail page's Supply - * tab. Reads `GET /api/medications/[id]/inventory` (the same per-item - * list the pen/vial CRUD route serves) and renders a calm summary — the - * doses remaining across every non-terminal item plus a per-item list - * with its state badge. Shown for ALL medications (pill packs count too); - * the richer pen math is implicit in the per-item doses figures. + * v1.16.1 — the tab grows its write affordances: * - * No write affordance here yet — registering a pen still rides the - * dedicated inventory route; the tab is the read surface the spec asks - * for. An empty inventory shows a quiet empty state, not a spinner. + * - REGISTER: a "+" flow (header button + the empty-state CTA) that + * records a new pen / pack via `POST /api/medications/[id]/inventory` + * — quantity in doses/units, optional printed expiry. The quantity + * prefills from the medication's `dosesPerUnit` when configured. + * - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with + * `dosesRemaining`) for stock corrections and manual withdrawals — + * the count is set absolutely, clamped server-side to the item's + * capacity, and the canonical state machine derives the next state + * (0 ⇒ used up). + * + * Reads stay on `GET /api/medications/[id]/inventory` (the same + * per-item list the pen/vial CRUD route serves): a calm summary — the + * doses remaining across every non-terminal item — plus a per-item list + * with its state badge. Shown for ALL medications (pill packs count + * too); the richer pen math is implicit in the per-item doses figures. */ -import { useQuery } from "@tanstack/react-query"; -import { Loader2, PackageOpen } from "lucide-react"; +import { useState, type FormEvent } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Loader2, PackageOpen, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; import { SettingsGroup } from "@/components/medications/settings-group"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -45,8 +64,17 @@ const STATE_BADGE: Record(null); const { data, isLoading } = useQuery({ queryKey: queryKeys.medicationInventory(medicationId), @@ -80,13 +108,42 @@ export function InventorySection({ medicationId }: { medicationId: string }) { const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0); const total = live.reduce((sum, i) => sum + i.dosesTotal, 0); + const dialogs = ( + <> + {addOpen && ( + setAddOpen(false)} + /> + )} + {adjustItem && ( + setAdjustItem(null)} + /> + )} + + ); + if (items.length === 0) { return ( - } - title={t("medications.detail.bestand.empty")} - description={t("medications.detail.bestand.emptyHelper")} - /> + <> + } + title={t("medications.detail.bestand.empty")} + description={t("medications.detail.bestand.emptyHelper")} + ctaSize="lg" + action={ + + } + /> + {dialogs} + ); } @@ -96,10 +153,20 @@ export function InventorySection({ medicationId }: { medicationId: string }) { label={t("medications.detail.bestand.title")} dataSlot="inventory-summary-group" > -
+

{t("medications.detail.bestand.summary", { remaining, total })}

+
@@ -119,12 +186,278 @@ export function InventorySection({ medicationId }: { medicationId: string }) { total: item.dosesTotal, })} - - {t(`medications.detail.bestand.state.${item.state}`)} - + + + {t(`medications.detail.bestand.state.${item.state}`)} + + +
))} + + {dialogs} ); } + +/** + * Register a new pen / pack. Two fields (quantity + optional printed + * expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from + * the medication's `dosesPerUnit` so the common case is one tap. + */ +function AddInventoryDialog({ + medicationId, + defaultDosesTotal, + onClose, +}: { + medicationId: string; + defaultDosesTotal: number | null; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [dosesTotal, setDosesTotal] = useState( + defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100 + ? String(defaultDosesTotal) + : "", + ); + const [expiry, setExpiry] = useState(""); + const [busy, setBusy] = useState(false); + + const parsed = Number(dosesTotal); + const quantityValid = + Number.isInteger(parsed) && parsed >= 1 && parsed <= 100; + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!quantityValid || busy) return; + setBusy(true); + try { + const res = await fetch(`/api/medications/${medicationId}/inventory`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dosesTotal: parsed, + printedExpiry: expiry + ? new Date(`${expiry}T00:00:00`).toISOString() + : null, + }), + }); + if (!res.ok) { + toast.error(t("medications.detail.bestand.addFailed")); + return; + } + await queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }); + toast.success(t("medications.detail.bestand.addSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.addFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()}> + + + {t("medications.detail.bestand.addTitle")} + + {t("medications.detail.bestand.addDescription")} + + +
+
+ + setDosesTotal(e.target.value)} + aria-describedby="inventory-add-quantity-helper" + /> +

+ {t("medications.detail.bestand.addQuantityHelper")} +

+
+
+ + setExpiry(e.target.value)} + /> +
+ + + + +
+
+
+ ); +} + +/** + * Stock correction for one item: sets the remaining-dose count + * absolutely (covers both "I miscounted" and a manual withdrawal). The + * server clamps to the item's capacity and re-derives the state; 0 + * marks the item used up. + */ +function AdjustInventoryDialog({ + medicationId, + item, + onClose, +}: { + medicationId: string; + item: InventoryItem; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [value, setValue] = useState(String(item.dosesRemaining)); + const [busy, setBusy] = useState(false); + + const parsed = Number(value); + const valid = + Number.isInteger(parsed) && parsed >= 0 && parsed <= item.dosesTotal; + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!valid || busy) return; + setBusy(true); + try { + const res = await fetch( + `/api/medications/${medicationId}/inventory/${item.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dosesRemaining: parsed }), + }, + ); + if (!res.ok) { + toast.error(t("medications.detail.bestand.adjustFailed")); + return; + } + await queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }); + toast.success(t("medications.detail.bestand.adjustSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.adjustFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()}> + + + + {t("medications.detail.bestand.adjustTitle")} + + + {t("medications.detail.bestand.adjustDescription")} + + +
+
+ + setValue(e.target.value)} + aria-describedby="inventory-adjust-helper" + /> +

+ {t("medications.detail.bestand.adjustHelper", { + total: item.dosesTotal, + })} +

+
+ + + + +
+
+
+ ); +} diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index 7f98454f8..dcc8ff19c 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -129,7 +129,9 @@ describe("reduceCurrentWindowStatus — degenerate point window", () => { }); describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () => { - // One schedule row, two dose times, window long past at 22:00. + // One schedule row, two dose times. v1.16.1: each dose carries its own + // ±60 min band (07:00–09:00 and 19:00–21:00); the legacy row window is + // ignored once timesOfDay exists. const twoDoseSchedule = [ { windowStart: "08:00", @@ -143,23 +145,40 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () = const res = reduceCurrentWindowStatus({ ...BASE, schedules: twoDoseSchedule, - nowBerlin: localClock(21, 0), + nowBerlin: localClock(21, 30), todayEventCount: 1, }); - // One event covers one dose; the second passed dose is uncovered → - // the late pill must stay up. (Pre-v1.15.20 the row counted as ONE - // expected dose and the pill vanished here.) + // 21:30 — both bands have passed (ends 09:00 + 21:00); one event + // covers one dose; the second passed dose is uncovered → the late + // pill must stay up. (Pre-v1.15.20 the row counted as ONE expected + // dose and the pill vanished here.) expect(res.status).toBe("late"); + expect(res.window).toEqual({ + timeOfDay: "20:00", + start: "19:00", + end: "21:00", + }); }); it("suppresses the pill once both doses are covered", () => { const res = reduceCurrentWindowStatus({ ...BASE, schedules: twoDoseSchedule, - nowBerlin: localClock(21, 0), + nowBerlin: localClock(21, 30), todayEventCount: 2, }); expect(res.status).toBeNull(); + expect(res.window).toBeNull(); + }); + + it("is in_window inside the second dose's own band", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: twoDoseSchedule, + nowBerlin: localClock(20, 30), + }); + expect(res.status).toBe("in_window"); + expect(res.window?.timeOfDay).toBe("20:00"); }); it("a schedule without timesOfDay keeps counting as one dose", () => { @@ -175,3 +194,73 @@ describe("reduceCurrentWindowStatus — timesOfDay-aware overdue coverage", () = expect(res.status).toBeNull(); }); }); + +describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1.16.1)", () => { + // The production regression: the schedule row still carries the historic + // degenerate 07:00 / 07:00 window while the dose times moved to + // 09:00 / 21:00. The 07:00 window must not paint any pill. + const staleSchedule = [ + { + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["09:00", "21:00"], + }, + ]; + + it("shows NO pill at 07:00 (the stale window used to read in_window)", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: staleSchedule, + nowBerlin: localClock(7, 0), + }); + expect(res.status).toBeNull(); + }); + + it("is in_window inside the 09:00 dose band, anchored on 09:00", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: staleSchedule, + nowBerlin: localClock(9, 15), + }); + expect(res.status).toBe("in_window"); + expect(res.window).toEqual({ + timeOfDay: "09:00", + start: "08:00", + end: "10:00", + }); + }); + + it("turns late after the 09:00 band end, still anchored on 09:00", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: staleSchedule, + nowBerlin: localClock(11, 0), + }); + expect(res.status).toBe("late"); + expect(res.window?.timeOfDay).toBe("09:00"); + }); + + it("honours an explicit doseWindows band over the default derivation", () => { + const withExplicit = [ + { + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + doseWindows: [{ timeOfDay: "09:00", start: "08:30", end: "11:30" }], + }, + ]; + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: withExplicit, + nowBerlin: localClock(11, 0), + }); + expect(res.status).toBe("in_window"); + expect(res.window).toEqual({ + timeOfDay: "09:00", + start: "08:30", + end: "11:30", + }); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 00cad4e89..b5e06713d 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -43,6 +43,7 @@ * unregistered queue silently never drains. */ import { prisma } from "@/lib/db"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { isP2002 } from "@/lib/prisma-errors"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -370,6 +371,21 @@ export async function dedupeUserIntakeSlots( summary.daysRecomputed += 1; } + // v1.16.1 — a collapse changed the rows the cached compliance / intake / + // summary payloads were built on; drop this user's server caches so the + // next read reflects the corrected slots instead of waiting out the TTL. + // Process-local like every server cache: in the default single-container + // deploy the worker shares the web process, so the eviction reaches the + // serving instance; a split web/worker deployment falls back to the TTL + // bound (see `server-cache.ts`). + if ( + summary.slotsCollapsed > 0 || + summary.rowsSoftDeleted > 0 || + summary.rowsNormalised > 0 + ) { + invalidateUserMedications(userId); + } + annotate({ action: { name: "medication.intake.slot_dedup", diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index fcfd6420d..cc6abed16 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -192,3 +192,57 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00 }); }); + +/** + * v1.16.1 — a stale / degenerate legacy window must NEVER determine + * next-due once `timesOfDay` exists. Regression fixture: a twice-daily + * med whose schedule row still carries the historic `07:00 / 07:00` + * point window while the canonical dose times moved to 09:00 / 21:00. + * The card read "today, 07:00" off the window; the engine must anchor on + * the timesOfDay slots exclusively. + */ +describe("computeNextDueAt — stale degenerate window never wins over timesOfDay", () => { + function staleWindowTwiceDaily(): WorkerScheduleRow { + return { + id: "sched-stale", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["09:00", "21:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + } + + it("returns today's 09:00 slot before the morning dose", () => { + const now = d("2026-06-10T06:00:00Z"); // 08:00 Berlin + const next = computeNextDueAt({ + medication: makeMedication(), + schedules: [staleWindowTwiceDaily()], + now, + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(next).not.toBeNull(); + // 09:00 Berlin (CEST, UTC+2) = 07:00 UTC — never the stale 07:00 window. + expect(next!.toISOString()).toBe("2026-06-10T07:00:00.000Z"); + }); + + it("returns today's 21:00 slot between the doses", () => { + const now = d("2026-06-10T08:00:00Z"); // 10:00 Berlin + const next = computeNextDueAt({ + medication: makeMedication(), + schedules: [staleWindowTwiceDaily()], + now, + userTz: BERLIN, + lastIntakeAt: null, + }); + expect(next).not.toBeNull(); + // 21:00 Berlin = 19:00 UTC. + expect(next!.toISOString()).toBe("2026-06-10T19:00:00.000Z"); + }); +}); diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index 804b6086e..21f9f70b8 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -17,7 +17,19 @@ * schedule, so the helper accepts any schedule that satisfies the * minimal `ScheduleWindowInput` shape. * - * v1.15.20 — three model fixes: + * v1.16.1 — the dose-band model becomes the canonical source. When a + * schedule carries `timesOfDay`, every status decision derives from + * per-dose on-time bands — the explicit `doseWindows` entry for the + * matching dose when present, else `timeOfDay ±` the default daily + * on-time half-width. The legacy `windowStart` / `windowEnd` pair is + * consulted ONLY for rows without `timesOfDay`; a stale or degenerate + * window (e.g. `07:00 / 07:00` left behind by an old write path while + * the times moved to `09:00 / 21:00`) can no longer paint a take-now + * pill at 07:00 or mis-anchor the recorded slot. The matched band rides + * on the result (`window`) so the pill text and the displayed-slot + * resolution read the SAME band the status came from. + * + * v1.15.20 — three model fixes (kept): * - the wall-clock conversion takes the user's IANA timezone (default * `Europe/Berlin` so existing call sites compile + behave unchanged; * threading the real user timezone through the cards is the follow-up); @@ -25,13 +37,20 @@ * time echoed into both fields) is a POINT window widened by the * default daily on-time half-width — it previously fell into the * overnight branch and read as a 24 h always-in-window band; - * - `countPassedSchedules` counts the schedule's `timesOfDay` doses, not + * - passed-dose counting counts the schedule's `timesOfDay` doses, not * schedule rows, so a two-dose row ("08:00" + "20:00") expects two * intake events before the overdue pill is suppressed. */ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +/** One explicit per-dose on-time window as the API serialises it. */ +export interface DoseWindowEntryInput { + timeOfDay: string; + start: string; + end: string; +} + export interface ScheduleWindowInput { windowStart: string; windowEnd: string; @@ -39,16 +58,35 @@ export interface ScheduleWindowInput { /** * v1.15.20 — first-class dose times. Optional so legacy fixtures and the * existing card call sites compile unchanged; when absent the schedule - * row counts as ONE expected dose (the pre-v1.15.20 behaviour). + * row falls back to the single legacy window (the pre-v1.16.1 behaviour). */ timesOfDay?: string[]; + /** + * v1.16.1 — explicit per-dose on-time windows (the detail page's + * Zeitplan editor writes them). Only read for doses listed in + * `timesOfDay`; malformed entries fall back to the default derivation. + */ + doseWindows?: DoseWindowEntryInput[] | null; } export type MedicationWindowStatus = "in_window" | "late" | "very_late" | null; +/** + * The dose band that produced a non-null status. `timeOfDay` is the dose + * anchor (a `timesOfDay` entry, or `windowStart` on a legacy row); `start` + * / `end` are the HH:mm band bounds for display. + */ +export interface MatchedDoseWindow { + timeOfDay: string; + start: string; + end: string; +} + export interface CurrentWindowStatus { status: MedicationWindowStatus; schedule: Schedule | null; + /** Non-null exactly when `status` is non-null. */ + window: MatchedDoseWindow | null; } /** Fallback timezone until every call site threads the user's own. */ @@ -88,15 +126,29 @@ export function parseTimeToMinutes(value: string): number { return h * 60 + m; } -interface WindowBounds { +function minutesToHm(mins: number): string { + const clamped = Math.min(Math.max(mins, 0), 24 * 60 - 1); + const h = Math.floor(clamped / 60); + const m = clamped % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +const HHMM_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/; + +interface DoseWindowBounds { + /** Dose anchor for slot resolution + UI labels. */ + timeOfDay: string; startMins: number; - /** May exceed 24 h × 60 for an overnight window (end wrapped past midnight). */ + /** May exceed 24 h × 60 for a legacy overnight window (end wrapped past midnight). */ endMins: number; + /** HH:mm display strings (the legacy branch keeps the raw schedule strings). */ + startHm: string; + endHm: string; } /** - * Resolve a schedule's window into minute-of-day bounds with two special - * shapes handled: + * Resolve a legacy (no `timesOfDay`) schedule window into minute-of-day + * bounds with two special shapes handled: * * - `end === start` — a degenerate POINT window (a single dose time echoed * into both fields). Previously this fell into the overnight branch and @@ -106,7 +158,10 @@ interface WindowBounds { * - `end < start` — a genuine overnight window; the end wraps past * midnight (`endMins > 1440`), matching the historical behaviour. */ -function resolveWindowBounds(schedule: ScheduleWindowInput): WindowBounds { +function resolveLegacyWindowBounds(schedule: ScheduleWindowInput): { + startMins: number; + endMins: number; +} { let startMins = parseTimeToMinutes(schedule.windowStart); let endMins = parseTimeToMinutes(schedule.windowEnd); @@ -121,34 +176,119 @@ function resolveWindowBounds(schedule: ScheduleWindowInput): WindowBounds { return { startMins, endMins }; } -function getWindowStatus( - schedule: Schedule, - nowLocal: Date, +/** + * v1.16.1 — the canonical band resolution. A schedule with `timesOfDay` + * yields ONE band per dose: the explicit `doseWindows` entry when present + * and well-formed, else `timeOfDay ±` the default daily on-time + * half-width (clamped to the local day). A schedule without `timesOfDay` + * yields the single legacy window. The legacy `windowStart` / `windowEnd` + * NEVER shape a band once `timesOfDay` exists — stale or degenerate + * windows must not drive the status or the recorded slot. + */ +function resolveDoseWindows(schedule: ScheduleWindowInput): DoseWindowBounds[] { + const times = (schedule.timesOfDay ?? []).filter((t) => HHMM_RE.test(t)); + if (times.length > 0) { + const half = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes; + return times.map((timeOfDay) => { + const explicit = Array.isArray(schedule.doseWindows) + ? schedule.doseWindows.find( + (w) => + w && + w.timeOfDay === timeOfDay && + HHMM_RE.test(w.start) && + HHMM_RE.test(w.end) && + parseTimeToMinutes(w.start) <= parseTimeToMinutes(w.end), + ) + : undefined; + if (explicit) { + return { + timeOfDay, + startMins: parseTimeToMinutes(explicit.start), + endMins: parseTimeToMinutes(explicit.end), + startHm: explicit.start, + endHm: explicit.end, + }; + } + const anchor = parseTimeToMinutes(timeOfDay); + const startMins = Math.max(0, anchor - half); + const endMins = Math.min(24 * 60 - 1, anchor + half); + return { + timeOfDay, + startMins, + endMins, + startHm: minutesToHm(startMins), + endHm: minutesToHm(endMins), + }; + }); + } + + const { startMins, endMins } = resolveLegacyWindowBounds(schedule); + return [ + { + timeOfDay: schedule.windowStart, + startMins, + endMins, + // Keep the raw schedule strings for display so legacy pills render + // byte-identically to the pre-band model. + startHm: schedule.windowStart, + endHm: schedule.windowEnd, + }, + ]; +} + +const STATUS_PRIORITY = { in_window: 3, late: 2, very_late: 1 } as const; + +function bandStatus( + band: DoseWindowBounds, + nowMins: number, lateMinutes: number, missedMinutes: number, -): MedicationWindowStatus { - const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes(); - const { startMins, endMins } = resolveWindowBounds(schedule); - - // Handle overnight windows +): Exclude | null { + // Handle legacy overnight windows (wrapped end past midnight). const adjustedNow = - nowMins < startMins && endMins > 24 * 60 ? nowMins + 24 * 60 : nowMins; + nowMins < band.startMins && band.endMins > 24 * 60 + ? nowMins + 24 * 60 + : nowMins; - // Currently in window - if (adjustedNow >= startMins && adjustedNow <= endMins) return "in_window"; + if (adjustedNow >= band.startMins && adjustedNow <= band.endMins) { + return "in_window"; + } - // Past window end: check late thresholds - const minutesPastEnd = adjustedNow - endMins; + const minutesPastEnd = adjustedNow - band.endMins; if (minutesPastEnd > 0 && minutesPastEnd <= lateMinutes) return "late"; - if (minutesPastEnd > lateMinutes && minutesPastEnd <= missedMinutes) + if (minutesPastEnd > lateMinutes && minutesPastEnd <= missedMinutes) { return "very_late"; - + } return null; } -function isLastIntakeInCurrentWindow( +function getScheduleStatus( + schedule: ScheduleWindowInput, + nowLocal: Date, + lateMinutes: number, + missedMinutes: number, +): { + status: Exclude; + band: DoseWindowBounds; +} | null { + const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes(); + let best: { + status: Exclude; + band: DoseWindowBounds; + } | null = null; + for (const band of resolveDoseWindows(schedule)) { + const status = bandStatus(band, nowMins, lateMinutes, missedMinutes); + if (!status) continue; + if (!best || STATUS_PRIORITY[status] > STATUS_PRIORITY[best.status]) { + best = { status, band }; + } + } + return best; +} + +function isLastIntakeInBand( lastTakenAt: string | null, - schedule: Schedule, + band: DoseWindowBounds, nowLocal: Date, tz: string, ): boolean { @@ -166,35 +306,35 @@ function isLastIntakeInCurrentWindow( } const intakeMins = intake.getHours() * 60 + intake.getMinutes(); - const { startMins, endMins } = resolveWindowBounds(schedule); - // Handle overnight windows + // Handle legacy overnight windows const adjustedIntake = - intakeMins < startMins && endMins > 24 * 60 + intakeMins < band.startMins && band.endMins > 24 * 60 ? intakeMins + 24 * 60 : intakeMins; - return adjustedIntake >= startMins && adjustedIntake <= endMins; + return adjustedIntake >= band.startMins && adjustedIntake <= band.endMins; } /** - * Count how many doses are past their window today, honouring the + * Count how many doses are past their band today, honouring the * recurrence's allowed days-of-week. Used by both cards to suppress * "overdue" pills once the user has already covered every passed dose * with intake events for the day. * - * v1.15.20 — counts `timesOfDay` entries, not schedule rows: a schedule - * row carrying two dose times expects TWO intake events once its window - * passes. A row without `timesOfDay` (legacy shape) keeps counting as one. - * The passed check stays anchored on the (degenerate-resolved) raw window - * end of the same local day, matching the historical comparison — an - * overnight window therefore never reads as "passed" within the same local - * day, exactly as before. + * v1.16.1 — counts per-dose BANDS: each `timesOfDay` dose passes when its + * own on-time band end has passed, so a 09:00 / 21:00 row reads ONE + * passed dose at noon (not two). A row without `timesOfDay` (legacy + * shape) keeps the single-window behaviour, including the degenerate + * point-window resolution; a wrapped overnight end (> 1440) keeps the raw + * minute comparison so an overnight window never reads as "passed" + * within the same local day, exactly as before. */ -function countPassedSchedules( +function countPassedDoses( schedules: Schedule[], nowLocal: Date, ): number { + const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes(); return schedules.reduce((sum, s) => { const recurrence = parseScheduleRecurrence(s.daysOfWeek); if ( @@ -203,32 +343,27 @@ function countPassedSchedules( ) { return sum; } - // Same-day end bound: a wrapped overnight end (> 1440) keeps the raw - // minute value so the comparison below matches the historical - // behaviour; the degenerate point window resolves to its widened end. - const rawStart = parseTimeToMinutes(s.windowStart); - const rawEnd = parseTimeToMinutes(s.windowEnd); - const endMins = - rawEnd === rawStart - ? Math.min(24 * 60 - 1, rawEnd + DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes) - : rawEnd; - const nowMins = nowLocal.getHours() * 60 + nowLocal.getMinutes(); - if (nowMins <= endMins) return sum; - const doseCount = - s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1; - return sum + doseCount; + const passed = resolveDoseWindows(s).filter((band) => { + // Same-day end bound: a wrapped overnight end (> 1440) keeps the + // raw minute value so the comparison matches the historical + // behaviour (it never passes within the same local day). + const endMins = + band.endMins > 24 * 60 ? band.endMins - 24 * 60 : band.endMins; + return nowMins > endMins; + }).length; + return sum + passed; }, 0); } /** * Reduce a list of schedules into the most actionable window status * for the card header pill. Returns `null` status when the medication - * is paused, no schedule is currently in/past its window, or every - * overdue schedule is already covered by today's intake events. + * is paused, no dose band is currently in/past its window, or every + * overdue dose is already covered by today's intake events. * * Priority: in_window > late > very_late. Suppresses an `in_window` - * status when the last intake already falls inside the window so the - * pill doesn't nag after the user took the dose. + * status when the last intake already falls inside the matched band so + * the pill doesn't nag after the user took the dose. * * `nowBerlin` is the wall-clock "now" already shifted into the user's * timezone (via `toZonedDate`); `tz` must name the same timezone so the @@ -260,32 +395,41 @@ export function reduceCurrentWindowStatus( tz = DEFAULT_TZ, } = options; - if (!active) return { status: null, schedule: null }; + const none: CurrentWindowStatus = { + status: null, + schedule: null, + window: null, + }; + if (!active) return none; - const passedScheduleCount = countPassedSchedules(schedules, nowBerlin); - const hasUncoveredOverdue = todayEventCount < passedScheduleCount; + const passedDoseCount = countPassedDoses(schedules, nowBerlin); + const hasUncoveredOverdue = todayEventCount < passedDoseCount; - return schedules.reduce>( - (best, s) => { - const status = getWindowStatus(s, nowBerlin, lateMinutes, missedMinutes); - if (!status) return best; - // Don't show late/very_late if all overdue schedules are covered - // by intake events for the day. - if (status !== "in_window" && !hasUncoveredOverdue) return best; - // Don't show in_window if last intake is already within this - // window today. - if ( - status === "in_window" && - isLastIntakeInCurrentWindow(lastTakenAt, s, nowBerlin, tz) - ) { - return best; - } - const priority = { in_window: 3, late: 2, very_late: 1 }; - if (!best.status || priority[status] > priority[best.status]) { - return { status, schedule: s }; - } + return schedules.reduce>((best, s) => { + const hit = getScheduleStatus(s, nowBerlin, lateMinutes, missedMinutes); + if (!hit) return best; + // Don't show late/very_late if all overdue doses are covered by + // intake events for the day. + if (hit.status !== "in_window" && !hasUncoveredOverdue) return best; + // Don't show in_window if last intake is already within this band + // today. + if ( + hit.status === "in_window" && + isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz) + ) { return best; - }, - { status: null, schedule: null }, - ); + } + if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) { + return { + status: hit.status, + schedule: s, + window: { + timeOfDay: hit.band.timeOfDay, + start: hit.band.startHm, + end: hit.band.endHm, + }, + }; + } + return best; + }, none); } diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index cdfb37b3d..a88a28eff 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -803,6 +803,13 @@ export const updateInventoryItemSchema = z.object({ .transform((s) => new Date(s)) .nullable() .optional(), + /** + * v1.16.1 — stock correction (the Bestand tab's adjust / withdraw + * flow). Sets the remaining-dose count directly; the route clamps to + * `dosesTotal` and re-runs the canonical state machine (0 ⇒ USED_UP, + * a raise out of 0 re-evaluates against the expiry clocks). + */ + dosesRemaining: z.number().int().min(0).max(100).optional(), notes: z.string().max(200).nullable().optional(), });