From a4cccc11ba2bc649ca737a48d68a2c3bddb0a24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:00:37 +0200 Subject: [PATCH 01/12] fix(medications): keep an auto-taken rolling cadence moving A rolling cadence yields only lastIntakeAt + N, so a slot the auto-take pass never records (before the switch-on floor, past the catch-up window, inside a pause, skipped or removed by the person) held it still for good. Step the cadence forward by N past every such slot, keeping its rhythm, and feed the stepped anchor to the card, dashboard and schedule-tool next due so they show the slot the pass will record. --- src/app/api/medications/[id]/route.ts | 15 +- src/lib/dashboard/meds-today.ts | 13 +- .../jobs/__tests__/intake-auto-take.test.ts | 182 ++++++++++ src/lib/jobs/intake-auto-take.ts | 163 +++------ src/lib/mcp/tools.ts | 15 +- src/lib/medications/auto-take.ts | 336 ++++++++++++++++++ src/lib/medications/list-read.ts | 12 +- src/lib/medications/scheduling/recurrence.ts | 19 + .../medication-auto-mark-taken.test.ts | 86 +++++ 9 files changed, 718 insertions(+), 123 deletions(-) create mode 100644 src/lib/medications/auto-take.ts diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index dc5d768f8..500bc360f 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -32,6 +32,7 @@ import { toRevisionPayloadEntry, } from "@/lib/medications/scheduling/schedule-eras"; import type { Prisma } from "@/generated/prisma/client"; +import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, @@ -179,6 +180,16 @@ export const GET = apiHandler( // v1.39.1 (#1033) — intake tracking off: nothing is due from the // stored rows, and no runway derives from them. const liveSchedules = dueSchedules(medication); + const userTz = user.timezone || DEFAULT_TIMEZONE; + // v1.42.2 — under auto-take a rolling cadence stands where the job will + // record next (see `auto-take.ts`). + const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { + userId: user.id, + medicationIds: medication.autoMarkTaken ? [id] : [], + now, + userTz, + lastIntakeAt: () => lastIntake?.takenAt ?? null, + }); const display = computeDisplayDue({ medication: { id: medication.id, @@ -189,8 +200,8 @@ export const GET = apiHandler( }, schedules: liveSchedules, now, - userTz: user.timezone || DEFAULT_TIMEZONE, - lastIntakeAt: lastIntake?.takenAt ?? null, + userTz, + lastIntakeAt: engineLastIntakeAt(id), resolvedSlots: resolvedRows.map(toResolvedSlotMark), eraStart: latestRevision?.validUntil ?? null, }); diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 017008912..24382dfe5 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -36,6 +36,7 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; +import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, @@ -203,6 +204,16 @@ export async function buildMedsTodayBlock( // The live era start per medication (see `live-era.ts`). const eraStartByMedId = liveEraStartsByMedication(eraFloors); + // v1.42.2 — under auto-take a rolling cadence stands where the job will + // record next (see `auto-take.ts`). + const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { + userId, + medicationIds: medications.filter((m) => m.autoMarkTaken).map((m) => m.id), + now, + userTz, + lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null, + }); + const dueCandidates: MedsTodayDueCandidate[] = []; for (const m of medications) { const display = computeDisplayDue({ @@ -216,7 +227,7 @@ export async function buildMedsTodayBlock( schedules: m.schedules, now, userTz, - lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null, + lastIntakeAt: engineLastIntakeAt(m.id), resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], eraStart: eraStartByMedId.get(m.id) ?? null, }); diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index 82198030f..969e84758 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -49,6 +49,8 @@ import { runIntakeAutoTakePass, } from "../intake-auto-take"; import { autoTakeOwnedFrom } from "../intake-auto-skip"; +import { autoTakeRollingAnchor } from "@/lib/medications/auto-take"; +import { computeDisplayDue } from "@/lib/medications/scheduling/next-due"; const TZ = "Europe/Berlin"; @@ -710,3 +712,183 @@ describe("runIntakeAutoTakePass — over a fake client", () => { ).toEqual(["2026-06-03T07:00:00.000Z", "2026-06-05T07:00:00.000Z"]); }); }); + +// ── a rolling cadence under auto-take never stalls ──────────────────── + +describe("runIntakeAutoTakePass — rolling cadence keeps moving", () => { + const weekly = () => + schedule({ rollingIntervalDays: 7, timesOfDay: ["09:00"] }); + const autoRecordedSlots = (rows: FakeRow[]) => + rows + .filter((r) => r.autoRecorded && r.deletedAt === null) + .map((r) => r.scheduledFor.toISOString()) + .sort(); + + it("a first dose before the floor is passed over, the next one recorded", async () => { + // Created at 12:00 CEST, after that day's 09:00 slot, no start date: + // the first rolling dose lies before the floor. + const med = medication({ + createdAt: new Date("2026-06-10T10:00:00Z"), + autoMarkTakenSince: new Date("2026-06-10T10:00:00Z"), + schedules: [weekly()], + }); + const rows: FakeRow[] = []; + const client = fakeClient([med], rows); + + const early = await runIntakeAutoTakePass(client, { + nowMs: Date.parse("2026-06-12T08:00:00Z"), + }); + expect(early.recordedCount).toBe(0); + + const due = await runIntakeAutoTakePass(client, { + nowMs: Date.parse("2026-06-17T07:20:00Z"), + }); + expect(due.recordedCount).toBe(1); + expect(autoRecordedSlots(rows)).toEqual(["2026-06-17T07:00:00.000Z"]); + }); + + it("switched on while the weekly dose is overdue: the overdue dose stays, the cadence moves on", async () => { + const med = medication({ + autoMarkTakenSince: new Date("2026-06-10T12:00:00Z"), + schedules: [weekly()], + }); + const rows: FakeRow[] = [ + row({ + id: "t0", + scheduledFor: new Date("2026-06-01T07:00:00Z"), + takenAt: new Date("2026-06-01T07:00:00Z"), + source: "WEB", + }), + ]; + const client = fakeClient([med], rows); + const result = await runIntakeAutoTakePass(client, { + nowMs: Date.parse("2026-06-15T08:00:00Z"), + }); + // 06-08 lies before the switch-on and is never backfilled. + expect(result.recordedCount).toBe(1); + expect(autoRecordedSlots(rows)).toEqual(["2026-06-15T07:00:00.000Z"]); + }); + + it("a skipped rolling auto dose counts as handled: the next one is recorded", async () => { + const med = medication({ + autoMarkTakenSince: new Date("2026-06-01T00:00:00Z"), + schedules: [weekly()], + }); + const rows: FakeRow[] = [ + row({ + id: "t0", + scheduledFor: new Date("2026-06-01T07:00:00Z"), + takenAt: new Date("2026-06-01T07:00:00Z"), + source: "WEB", + }), + row({ + id: "s1", + scheduledFor: new Date("2026-06-08T07:00:00Z"), + skipped: true, + source: "WEB", + }), + ]; + const client = fakeClient([med], rows); + const result = await runIntakeAutoTakePass(client, { + nowMs: Date.parse("2026-06-15T08:00:00Z"), + }); + expect(result.recordedCount).toBe(1); + expect(autoRecordedSlots(rows)).toEqual(["2026-06-15T07:00:00.000Z"]); + expect(rows.find((r) => r.id === "s1")).toMatchObject({ + skipped: true, + takenAt: null, + }); + }); + + it("a removed rolling auto dose stays removed and the next one is recorded", async () => { + const med = medication({ + autoMarkTakenSince: new Date("2026-06-01T00:00:00Z"), + schedules: [weekly()], + }); + const rows: FakeRow[] = [ + row({ + id: "t0", + scheduledFor: new Date("2026-06-01T07:00:00Z"), + takenAt: new Date("2026-06-01T07:00:00Z"), + source: "WEB", + }), + row({ + id: "gone", + scheduledFor: new Date("2026-06-08T07:00:00Z"), + takenAt: new Date("2026-06-08T07:00:00Z"), + autoRecorded: true, + deletedAt: new Date("2026-06-08T09:00:00Z"), + }), + ]; + const client = fakeClient([med], rows); + const result = await runIntakeAutoTakePass(client, { + nowMs: Date.parse("2026-06-15T08:00:00Z"), + }); + expect(result.recordedCount).toBe(1); + expect(autoRecordedSlots(rows)).toEqual(["2026-06-15T07:00:00.000Z"]); + expect( + rows.filter( + (r) => r.scheduledFor.toISOString() === "2026-06-08T07:00:00.000Z", + ), + ).toHaveLength(1); + }); + + it("a worker down for more than a week catches up on the rhythm, not the stale slot", async () => { + const med = medication({ + autoMarkTakenSince: new Date("2026-05-01T00:00:00Z"), + schedules: [weekly()], + }); + const rows: FakeRow[] = [ + row({ + id: "t0", + scheduledFor: new Date("2026-05-25T07:00:00Z"), + takenAt: new Date("2026-05-25T07:00:00Z"), + source: "WEB", + }), + ]; + const client = fakeClient([med], rows); + // Ten days after the 06-01 dose fell due: it is outside the catch-up + // window, 06-08 is inside it. + const result = await runIntakeAutoTakePass(client, { + nowMs: Date.parse("2026-06-11T08:00:00Z"), + }); + expect(result.recordedCount).toBe(1); + expect(autoRecordedSlots(rows)).toEqual(["2026-06-08T07:00:00.000Z"]); + }); + + it("the card's next due is the slot the job records", async () => { + const med = medication({ + createdAt: new Date("2026-06-10T10:00:00Z"), + autoMarkTakenSince: new Date("2026-06-10T10:00:00Z"), + schedules: [weekly()], + }); + const now = new Date("2026-06-12T08:00:00Z"); + const anchor = autoTakeRollingAnchor({ + medication: med, + userTz: TZ, + floor: autoTakeFloor({ + autoMarkTakenSince: med.autoMarkTakenSince!, + createdAt: med.createdAt, + now, + }), + now, + lastIntakeAt: null, + rows: [], + }); + const display = computeDisplayDue({ + medication: med, + schedules: med.schedules, + now, + userTz: TZ, + lastIntakeAt: anchor, + resolvedSlots: [], + }); + expect(display?.at.toISOString()).toBe("2026-06-17T07:00:00.000Z"); + + const rows: FakeRow[] = []; + await runIntakeAutoTakePass(fakeClient([med], rows), { + nowMs: Date.parse("2026-06-17T07:20:00Z"), + }); + expect(autoRecordedSlots(rows)).toEqual([display!.at.toISOString()]); + }); +}); diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 1f08876e9..e65338966 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -17,6 +17,14 @@ * 3. Records each remaining slot as taken AT its scheduled instant, marked * `autoRecorded`, unless the person already acted on it. * + * A rolling cadence ("every N days from the last dose") yields only its next + * slot, so a slot this pass never records would hold it still for good. Under + * auto-take the cadence therefore steps forward by N, keeping its rhythm, + * past every slot before the floor, inside a pause era, or already handled + * by the person (skipped, auto-missed, removed); see `autoTakeRollingAnchor` + * in `src/lib/medications/auto-take.ts`, which the medication card's next + * due applies too, so the card shows the slot this pass will record. + * * The floor is the latest of `autoMarkTakenSince` (the option is never * applied to history before it was switched on), the medication's creation, * and a 7-day catch-up window (a worker that was down for a weekend still @@ -47,7 +55,16 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + autoTakeEligibleWhere, + autoTakeFloor, + autoTakeRollingAnchor, + autoTakeRowWindow, + decideSlot, + isInPauseEra, + type SlotDecision, + type SlotRow, +} from "@/lib/medications/auto-take"; import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { @@ -71,6 +88,14 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +export { + autoTakeFloor, + decideSlot, + INTAKE_AUTO_TAKE_LOOKBACK_MS, + isInPauseEra, + type SlotDecision, +} from "@/lib/medications/auto-take"; + export const INTAKE_AUTO_TAKE_QUEUE = "intake-auto-take"; /** @@ -83,9 +108,6 @@ const MINUTE_MS = 60 * 1000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; -/** How far back one pass catches up on slots it has not filled yet. */ -export const INTAKE_AUTO_TAKE_LOOKBACK_MS = 7 * DAY_MS; - /** * A rolling cadence produces its next slot only once the previous one is * taken, so a pass that catches up re-expands after each recorded rolling @@ -131,95 +153,6 @@ const SCHEDULE_SELECT = { doseWindows: true, } as const; -/** One slot row as the occupancy check reads it. */ -interface SlotRow { - id: string; - scheduledFor: Date; - takenAt: Date | null; - skipped: boolean; - autoMissed: boolean; - deletedAt: Date | null; -} - -export type SlotDecision = - | { kind: "create" } - | { kind: "convert"; rowId: string } - | { - kind: "leave"; - reason: "actioned" | "removed" | "taken_nearby"; - }; - -/** - * Decide what the pass does with one slot, given every row of the - * medication around it (live and tombstoned). Pure, so the precedence rules - * are tested without a database. - * - * `band` is the slot's attribution band when the cadence has one (a take - * anywhere inside it belongs to this slot); `null` for a rolling slot, whose - * next occurrence is by definition later than every taken dose. - */ -export function decideSlot( - slotAt: Date, - rows: readonly SlotRow[], - band: Pick | null, -): SlotDecision { - const t = slotAt.getTime(); - const onSlot = rows.filter((r) => r.scheduledFor.getTime() === t); - const liveOnSlot = onSlot.filter((r) => r.deletedAt === null); - - if (liveOnSlot.some((r) => r.takenAt !== null || r.skipped || r.autoMissed)) { - return { kind: "leave", reason: "actioned" }; - } - if (liveOnSlot.length === 0 && onSlot.length > 0) { - return { kind: "leave", reason: "removed" }; - } - if (band) { - const from = band.earlyStart.getTime(); - const to = band.overdueEnd.getTime(); - const takenNearby = rows.some( - (r) => - r.deletedAt === null && - !r.skipped && - r.takenAt !== null && - r.takenAt.getTime() >= from && - r.takenAt.getTime() <= to, - ); - if (takenNearby) return { kind: "leave", reason: "taken_nearby" }; - } - const pending = [...liveOnSlot].sort( - (a, b) => a.id.localeCompare(b.id), // deterministic pick on duplicates - )[0]; - return pending ? { kind: "convert", rowId: pending.id } : { kind: "create" }; -} - -/** True when `at` falls inside a `[pausedAt, resumedAt ?? now)` pause era. */ -export function isInPauseEra( - at: Date, - eras: readonly { pausedAt: Date; resumedAt: Date | null }[], - now: Date, -): boolean { - const t = at.getTime(); - return eras.some( - (era) => - t >= era.pausedAt.getTime() && t < (era.resumedAt ?? now).getTime(), - ); -} - -/** The slot floor for one medication: see the module comment. */ -export function autoTakeFloor(input: { - autoMarkTakenSince: Date; - createdAt: Date; - now: Date; -}): Date { - return new Date( - Math.max( - input.autoMarkTakenSince.getTime(), - input.createdAt.getTime(), - input.now.getTime() - INTAKE_AUTO_TAKE_LOOKBACK_MS, - ), - ); -} - interface AutoTakeMedication { id: string; userId: string; @@ -327,15 +260,9 @@ export async function runIntakeAutoTakePass( const now = new Date(options.nowMs ?? Date.now()); const medications = (await prisma.medication.findMany({ - where: { - autoMarkTaken: true, - autoMarkTakenSince: { not: null, lte: now }, - active: true, - pausedAt: null, - asNeeded: false, - // A record-only medication expects no dose, so none is recorded. - ...TRACKED_INTAKE_WHERE, - }, + // Opted in, active, not paused, not as-needed, intake tracked (a + // record-only medication expects no dose, so none is recorded). + where: autoTakeEligibleWhere(now), select: { id: true, userId: true, @@ -451,20 +378,7 @@ async function autoTakeMedication( where: { userId: medication.userId, medicationId: medication.id, - OR: [ - { - scheduledFor: { - gte: new Date(floor.getTime() - DAY_MS), - lte: new Date(now.getTime() + DAY_MS), - }, - }, - { - takenAt: { - gte: new Date(floor.getTime() - DAY_MS), - lte: new Date(now.getTime() + DAY_MS), - }, - }, - ], + ...autoTakeRowWindow(floor, now), }, select: { id: true, @@ -492,12 +406,25 @@ async function autoTakeMedication( } for (let round = 0; round < MAX_EXPANSION_ROUNDS; round++) { + // A rolling cadence steps past the slots this pass will never record + // (before the floor, paused, skipped, removed), so it cannot stall on + // one; the card's next due reads the same anchor. + const anchor = rolling + ? autoTakeRollingAnchor({ + medication, + userTz, + floor, + now, + lastIntakeAt, + rows, + }) + : lastIntakeAt; const slots = dueAutoTakeSlots({ medication, userTz, floor, now, - lastIntakeAt, + lastIntakeAt: anchor, }).filter((slot) => !handled.has(slot.at.getTime())); if (slots.length === 0) break; const bands = bandsByAnchor({ @@ -505,7 +432,7 @@ async function autoTakeMedication( userTz, floor, now, - lastIntakeAt, + lastIntakeAt: anchor, }); let recordedRolling = false; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 771b10be5..d00699618 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -65,6 +65,7 @@ import { NUTRIENT_CODES } from "@/lib/nutrients/catalog"; import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination"; +import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, @@ -2010,6 +2011,18 @@ export const MCP_TOOLS: McpToolDefinition[] = [ // The live era start per medication (see `live-era.ts`). const eraStartByMedId = liveEraStartsByMedication(eraFloors); + // v1.42.2 — under auto-take a rolling cadence stands where the job + // will record next (see `auto-take.ts`). + const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { + userId: ctx.userId, + medicationIds: medications + .filter((m) => m.autoMarkTaken) + .map((m) => m.id), + now, + userTz, + lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null, + }); + const rows = medications.map((m) => { const display = computeDisplayDue({ medication: { @@ -2024,7 +2037,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ schedules: dueSchedules(m), now, userTz, - lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null, + lastIntakeAt: engineLastIntakeAt(m.id), resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], eraStart: eraStartByMedId.get(m.id) ?? null, }); diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts new file mode 100644 index 000000000..d34a0e3ad --- /dev/null +++ b/src/lib/medications/auto-take.ts @@ -0,0 +1,336 @@ +/** + * v1.42.2 — the pure rules of auto-mark-taken that both the auto-take job + * and the read paths (the medication card's next due, the dashboard, the + * assistant tools) apply, so the card shows the slot the job will record. + * + * - `autoTakeFloor`: the earliest slot the job may record. + * - `decideSlot`: what the job does with one slot given its rows. + * - `autoTakeRollingAnchor`: where a rolling cadence stands for an + * auto-taken medication. The engine yields only `lastIntakeAt + N`; a + * slot the job will never record (before the floor, inside a pause era, + * skipped, auto-missed, or removed by the person) would otherwise hold + * the cadence still forever. The anchor steps forward by N past every + * such slot, keeping the rhythm, and is fed back to the engine as the + * effective `lastIntakeAt`. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + advanceRollingOccurrence, + nextOccurrenceAfter, + rollingIntakeAnchorFor, + type Occurrence, +} from "@/lib/medications/scheduling/recurrence"; +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildCanonicalSchedule, + buildRecurrenceContext, + type WorkerMedicationRow, + type WorkerScheduleRow, +} from "@/lib/medications/scheduling/worker-helpers"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** How far back one pass catches up on slots it has not filled yet. */ +export const INTAKE_AUTO_TAKE_LOOKBACK_MS = 7 * DAY_MS; + +/** + * Upper bound on single steps past handled rolling slots. Slots before the + * floor are crossed in one stride; only slots inside the catch-up window are + * stepped one by one, and there are at most a handful. + */ +const MAX_ROLLING_STEPS = 64; + +/** The slot floor for one medication: see `intake-auto-take.ts`. */ +export function autoTakeFloor(input: { + autoMarkTakenSince: Date; + createdAt: Date; + now: Date; +}): Date { + return new Date( + Math.max( + input.autoMarkTakenSince.getTime(), + input.createdAt.getTime(), + input.now.getTime() - INTAKE_AUTO_TAKE_LOOKBACK_MS, + ), + ); +} + +/** True when `at` falls inside a `[pausedAt, resumedAt ?? now)` pause era. */ +export function isInPauseEra( + at: Date, + eras: readonly { pausedAt: Date; resumedAt: Date | null }[], + now: Date, +): boolean { + const t = at.getTime(); + return eras.some( + (era) => + t >= era.pausedAt.getTime() && t < (era.resumedAt ?? now).getTime(), + ); +} + +/** One slot row as the occupancy check reads it. */ +export interface SlotRow { + id: string; + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; + autoMissed: boolean; + deletedAt: Date | null; +} + +export type SlotDecision = + | { kind: "create" } + | { kind: "convert"; rowId: string } + | { + kind: "leave"; + reason: "actioned" | "removed" | "taken_nearby"; + }; + +/** + * Decide what the pass does with one slot, given every row of the + * medication around it (live and tombstoned). Pure, so the precedence rules + * are tested without a database. + * + * `band` is the slot's attribution band when the cadence has one (a take + * anywhere inside it belongs to this slot); `null` for a rolling slot, whose + * next occurrence is by definition later than every taken dose. + */ +export function decideSlot( + slotAt: Date, + rows: readonly SlotRow[], + band: Pick | null, +): SlotDecision { + const t = slotAt.getTime(); + const onSlot = rows.filter((r) => r.scheduledFor.getTime() === t); + const liveOnSlot = onSlot.filter((r) => r.deletedAt === null); + + if (liveOnSlot.some((r) => r.takenAt !== null || r.skipped || r.autoMissed)) { + return { kind: "leave", reason: "actioned" }; + } + if (liveOnSlot.length === 0 && onSlot.length > 0) { + return { kind: "leave", reason: "removed" }; + } + if (band) { + const from = band.earlyStart.getTime(); + const to = band.overdueEnd.getTime(); + const takenNearby = rows.some( + (r) => + r.deletedAt === null && + !r.skipped && + r.takenAt !== null && + r.takenAt.getTime() >= from && + r.takenAt.getTime() <= to, + ); + if (takenNearby) return { kind: "leave", reason: "taken_nearby" }; + } + const pending = [...liveOnSlot].sort( + (a, b) => a.id.localeCompare(b.id), // deterministic pick on duplicates + )[0]; + return pending ? { kind: "convert", rowId: pending.id } : { kind: "create" }; +} + +export interface AutoTakeRollingMedication extends WorkerMedicationRow { + schedules: WorkerScheduleRow[]; + pauseEras: { pausedAt: Date; resumedAt: Date | null }[]; +} + +/** + * The effective `lastIntakeAt` for a rolling cadence under auto-take: the + * real one when its next slot is one the job will record, otherwise an + * anchor stepped forward by N past every slot it will not record. `null` + * input stays `null` only when the course-start slot is recordable. + * + * A slot is passed over when it lies before `floor` (the option is never + * applied to history before it was switched on, and the catch-up window is + * bounded), inside a pause era, or, for a slot whose time has come, when the + * person already handled it: skipped, auto-missed, taken, or removed. + * + * Returns the input unchanged for a medication without a rolling schedule. + * When the course ends before a recordable slot, returns the last passed + * slot as the anchor, so the engine yields nothing. + */ +export function autoTakeRollingAnchor(input: { + medication: AutoTakeRollingMedication; + userTz: string; + floor: Date; + now: Date; + lastIntakeAt: Date | null; + rows: readonly SlotRow[]; +}): Date | null { + const { medication, userTz, floor, now, rows } = input; + const scheduleRow = medication.schedules.find( + (s) => s.rollingIntervalDays !== null && s.rollingIntervalDays > 0, + ); + if (!scheduleRow || medication.oneShot) return input.lastIntakeAt; + const schedule = buildCanonicalSchedule(scheduleRow); + const n = schedule.rollingIntervalDays!; + const ctx = buildRecurrenceContext({ + medication, + userTz, + lastIntakeAt: input.lastIntakeAt, + }); + + // The engine's identity: `lastIntakeAt + N`, or the course start. + let occ: Occurrence | null = nextOccurrenceAfter(schedule, floor, ctx); + if (!occ) return input.lastIntakeAt; + let last: Occurrence | null = null; + + // Cross a long stretch before the floor in one stride, stopping one + // interval short so the single steps below land on the first slot at or + // after the floor, whatever DST did to the hours in between. + const strides = Math.floor( + (floor.getTime() - occ.at.getTime()) / (n * DAY_MS), + ); + if (strides >= 2) { + last = occ; + occ = advanceRollingOccurrence( + { ...schedule, rollingIntervalDays: (strides - 1) * n }, + occ, + ctx, + ); + } + + const passOver = (at: Date): boolean => { + if (at.getTime() < floor.getTime()) return true; + if (isInPauseEra(at, medication.pauseEras, now)) return true; + if (at.getTime() > now.getTime()) return false; + return decideSlot(at, rows, null).kind === "leave"; + }; + + let stepped = last !== null; + for (let step = 0; occ && step < MAX_ROLLING_STEPS; step++) { + if (!passOver(occ.at)) break; + last = occ; + stepped = true; + occ = advanceRollingOccurrence(schedule, occ, ctx); + } + if (!stepped) return input.lastIntakeAt; + if (occ === null) return last!.at; + return rollingIntakeAnchorFor(schedule, occ, ctx) ?? input.lastIntakeAt; +} + +/** + * The row window the job reads around one medication, and the read paths + * read for the same purpose: every row (tombstones included) whose slot or + * take falls within a day of `[floor, now]`. + */ +export function autoTakeRowWindow(floor: Date, now: Date) { + const gte = new Date(floor.getTime() - DAY_MS); + const lte = new Date(now.getTime() + DAY_MS); + return { + OR: [{ scheduledFor: { gte, lte } }, { takenAt: { gte, lte } }], + }; +} + +/** The medications the auto-take job works on (see `intake-auto-take.ts`). */ +export function autoTakeEligibleWhere(now: Date) { + return { + autoMarkTaken: true, + autoMarkTakenSince: { not: null, lte: now }, + active: true, + pausedAt: null, + asNeeded: false, + ...TRACKED_INTAKE_WHERE, + }; +} + +const ROLLING_SCHEDULE_SELECT = { + id: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + timesOfDay: true, + reminderGraceMinutes: true, + rrule: true, + rollingIntervalDays: true, + scheduleType: true, + cyclicOnWeeks: true, + cyclicOffWeeks: true, + doseWindows: true, +} as const; + +/** + * For the read paths: resolves the `lastIntakeAt` to hand the engine for + * each medication. An auto-taken rolling medication among `medicationIds` + * gets its stepped anchor; every other one its real last intake. At most + * one medication query and one row query, and none when `medicationIds` is + * empty. + */ +export async function loadAutoTakeRollingAnchors( + prisma: Pick, + input: { + userId: string; + medicationIds: readonly string[]; + now: Date; + userTz: string; + lastIntakeAt: (medicationId: string) => Date | null; + }, +): Promise<(medicationId: string) => Date | null> { + const out = new Map(); + const resolve = (id: string) => + out.has(id) ? (out.get(id) ?? null) : input.lastIntakeAt(id); + if (input.medicationIds.length === 0) return resolve; + const { now } = input; + const medications = await prisma.medication.findMany({ + where: { + userId: input.userId, + id: { in: [...input.medicationIds] }, + ...autoTakeEligibleWhere(now), + schedules: { some: { rollingIntervalDays: { not: null } } }, + }, + select: { + id: true, + startsOn: true, + endsOn: true, + oneShot: true, + createdAt: true, + autoMarkTakenSince: true, + schedules: { select: ROLLING_SCHEDULE_SELECT }, + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + }); + if (medications.length === 0) return resolve; + + const floors = new Map(); + let earliest = now; + for (const m of medications) { + const floor = autoTakeFloor({ + autoMarkTakenSince: m.autoMarkTakenSince!, + createdAt: m.createdAt, + now, + }); + floors.set(m.id, floor); + if (floor.getTime() < earliest.getTime()) earliest = floor; + } + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: input.userId, + medicationId: { in: medications.map((m) => m.id) }, + ...autoTakeRowWindow(earliest, now), + }, + select: { + id: true, + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + deletedAt: true, + }, + }); + + for (const m of medications) { + const anchor = autoTakeRollingAnchor({ + medication: m as AutoTakeRollingMedication, + userTz: input.userTz, + floor: floors.get(m.id)!, + now, + lastIntakeAt: input.lastIntakeAt(m.id), + rows: rows.filter((r) => r.medicationId === m.id), + }); + out.set(m.id, anchor); + } + return resolve; +} diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index cbbfbd456..3c4fb67b7 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -13,6 +13,7 @@ * readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard. */ import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; +import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { resolveCourseFields } from "@/lib/medications/courses"; @@ -220,6 +221,15 @@ export async function buildMedicationsList( const now = new Date(); // v1.40 (#1024) — every medication's courses, two queries for the list. const courseFieldsByMed = await resolveCourseFields(medications, now, userTz); + // v1.42.2 — a rolling cadence under auto-take stands where the auto-take + // job will record next, so the card shows that slot (see `auto-take.ts`). + const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { + userId, + medicationIds: medications.filter((m) => m.autoMarkTaken).map((m) => m.id), + now, + userTz, + lastIntakeAt: (id) => lastTakenAtDateByMedicationId[id] ?? null, + }); return medications.map((m) => { // v1.39.1 (#1033) — intake tracking off: the stored rows are a record, @@ -241,7 +251,7 @@ export async function buildMedicationsList( schedules: liveSchedules, now, userTz, - lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null, + lastIntakeAt: engineLastIntakeAt(m.id), resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], eraStart: eraStartByMedId.get(m.id) ?? null, }); diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 31f1b8177..568336b39 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -593,6 +593,25 @@ export function advanceRollingOccurrence( return buildOccurrence(at, time, schedule); } +/** + * The intake instant that makes `occurrence` the next rolling dose: the + * schedule's time on the local day N days earlier. Fed back as + * `lastIntakeAt`, the engine then yields exactly `occurrence`, which lets a + * caller that stepped a rolling cadence forward (past slots it will never + * record) hand the stepped position to every consumer of the engine. + */ +export function rollingIntakeAnchorFor( + schedule: CanonicalSchedule, + occurrence: Occurrence, + ctx: RecurrenceContext, +): Date | null { + const n = schedule.rollingIntervalDays; + if (n === null || n <= 0) return null; + const time = schedule.timesOfDay[0] ?? schedule.windowStart; + const day = addCivilDays(civilDayOfInstant(occurrence.at, ctx.timeZone), -n); + return applyTimeOfDayToCivilDay(day, time, ctx.timeZone); +} + /** * v1.13.x compliance — RETROSPECTIVE rolling expansion. * diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index d82a6ceb1..fb36186c1 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -380,6 +380,92 @@ describe("auto-mark-taken pass", () => { }); }); +describe("rolling cadence under auto-take", () => { + it("never stalls on a removed dose, and the card shows the slot the pass records", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + // Weekly at 09:00 local. Last taken Mon 06-01; the 06-08 dose the pass + // recorded was removed by the person (not taken that week). + const medication = await prisma.medication.create({ + data: { + userId, + name: "Weekly injection", + dose: "1 pen", + createdAt: new Date("2026-05-01T00:00:00.000Z"), + autoMarkTaken: true, + autoMarkTakenSince: new Date("2026-05-01T00:00:00.000Z"), + schedules: { + create: { + windowStart: "09:00", + windowEnd: "09:00", + timesOfDay: ["09:00"], + rollingIntervalDays: 7, + scheduleType: "SCHEDULED", + }, + }, + }, + select: { id: true }, + }); + await prisma.medicationIntakeEvent.createMany({ + data: [ + { + userId, + medicationId: medication.id, + scheduledFor: new Date("2026-06-01T07:00:00.000Z"), + takenAt: new Date("2026-06-01T07:00:00.000Z"), + source: "WEB", + }, + { + userId, + medicationId: medication.id, + scheduledFor: new Date("2026-06-08T07:00:00.000Z"), + takenAt: new Date("2026-06-08T07:00:00.000Z"), + autoRecorded: true, + source: "REMINDER", + deletedAt: new Date("2026-06-08T12:00:00.000Z"), + }, + ], + }); + + const detail = await import("@/app/api/medications/[id]/route"); + const list = await import("@/app/api/medications/route"); + const nextDue = async () => { + const res = await (detail.GET as Handler)( + new NextRequest(`http://localhost/api/medications/${medication.id}`), + { params: Promise.resolve({ id: medication.id }) }, + ); + const listRes = await (list.GET as Handler)( + new NextRequest("http://localhost/api/medications"), + { params: Promise.resolve({}) }, + ); + const listed = ( + (await listRes.json()) as { + data: Array<{ id: string; nextDueAt: string | null }>; + } + ).data.find((m) => m.id === medication.id); + const own = ((await res.json()) as { data: { nextDueAt: string | null } }) + .data.nextDueAt; + expect(listed?.nextDueAt).toBe(own); + return own; + }; + + at("2026-06-10T08:00:00.000Z"); + expect(await nextDue()).toBe("2026-06-15T07:00:00.000Z"); + + at("2026-06-15T07:20:00.000Z"); + const result = await runIntakeAutoTakePass(prisma, { + nowMs: Date.now(), + }); + expect(result.recordedCount).toBe(1); + const recorded = (await liveRows(medication.id)).filter( + (r) => r.autoRecorded, + ); + expect(recorded.map((r) => r.scheduledFor.toISOString())).toEqual([ + "2026-06-15T07:00:00.000Z", + ]); + }); +}); + describe("remindersActive — the published value is the gate", () => { it("equals sendsMedicationReminders() on the list and detail reads, for every combination", async () => { const prisma = getPrismaClient(); From 23e0cc35af476cf3cbe31e261e25d18921a532df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:04:14 +0200 Subject: [PATCH 02/12] fix(timeline): open on everything when the default window is empty Opened without a zoom, three months that hold nothing while the record has older entries now show everything instead of a dead end, without writing the zoom into the URL. A window named in the URL that is empty offers "Show everything" when the record has entries before it. An unreadable ?zoom= or range opens on the same default as no zoom. --- e2e/timeline.spec.ts | 29 ++++ e2e/utils/mock-timeline.ts | 16 ++ .../__tests__/timeline-default-zoom.test.tsx | 150 ++++++++++++++++++ .../timeline/__tests__/timeline-url.test.ts | 20 ++- src/components/timeline/timeline-url.ts | 23 ++- src/components/timeline/timeline-view.tsx | 81 ++++++++-- src/components/timeline/use-timeline.ts | 2 + 7 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 src/components/timeline/__tests__/timeline-default-zoom.test.tsx diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index e9ec9e2a3..edb47b780 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -79,6 +79,35 @@ test("opens on three months when the URL names no zoom", async ({ page }) => { await setTimelineModule(page, false); }); +test("only old data opens on everything, and an empty window offers it", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY, { onlyOld: true }); + await page.goto("/timeline"); + // The default three months hold nothing: the page opens on everything, + // without writing it into the URL. + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="all"]'), + ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); + await expect(page).toHaveURL(/\/timeline$/); + expect(log.timelineQueries.map((q) => q.get("zoom"))).toEqual( + expect.arrayContaining(["quarter", "all"]), + ); + + // Three months asked for by name stay three months, and offer the rest. + await page.goto("/timeline?zoom=quarter"); + const empty = page.locator('[data-slot="timeline-window-empty"]'); + await expect(empty).toBeVisible({ timeout: 20_000 }); + await page.locator('[data-slot="timeline-show-everything"]').click(); + await expect(page).toHaveURL(/zoom=all/); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); + + // The next test starts from the module switched off. + await setTimelineModule(page, false); +}); + test("switching it on opens the readiness inventory, which leads to the timeline", async ({ page, }) => { diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index c99ebda10..528f36106 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -822,11 +822,16 @@ export interface TimelineMockLog { /** * Answer the timeline routes from the `full` or `ready` record. Returns a * log of what the page asked for and posted, for assertions. + * + * `onlyOld`: the record's entries all lie before the last year, so a + * quarter or year window answers empty (with the record's `dataFrom`) and + * only everything, or a chosen range, carries them. */ export async function mockTimeline( page: Page, state: "full" | "ready", today: string, + options: { onlyOld?: boolean } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => @@ -844,6 +849,17 @@ export async function mockTimeline( const to = url.searchParams.get("to"); const bucket = bucketFor(zoom, from, to); const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01"; + if (options.onlyOld && (zoom === "quarter" || zoom === "year")) { + return json(route, 200, { + ...body, + zoom, + bucket, + range: { from, to, dataFrom }, + lanes: body.lanes.map((lane) => ({ ...lane, items: [] })), + standing: [], + series: values.map((key) => ({ key, unit: null, points: [] })), + }); + } // A chosen range answers its own window, as the server does. const chosen = zoom === "range" && from && to; const all = diff --git a/src/components/timeline/__tests__/timeline-default-zoom.test.tsx b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx new file mode 100644 index 000000000..30b453198 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx @@ -0,0 +1,150 @@ +/** + * v1.42.2 — the timeline never opens on a dead end. + * + * - Opened without a zoom, when the default three months hold nothing but + * the record has older entries, the page shows everything (and leaves + * the URL alone). + * - A window named in the URL stays that window; when it is empty and the + * record has entries before it, it offers "Show everything". + * - An empty record keeps the plain empty state. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { TimelineResponse, TimelineZoom } from "@/lib/day/contract"; +import { I18nProvider } from "@/lib/i18n/context"; + +import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; + +let search = new URLSearchParams(); +let dataFrom: string | null = "2019-01-01"; +const asked: TimelineZoom[] = []; + +/** Everything for `all`; any other window holds nothing. */ +function answer(zoom: TimelineZoom, from: string | null, to: string | null) { + const full = fullTimeline(); + if (zoom === "all" && dataFrom !== null) return { ...full, zoom }; + const empty: TimelineResponse = { + ...full, + zoom, + range: { from: from ?? "2026-08-01", to: to ?? TODAY, dataFrom }, + lanes: full.lanes.map((lane) => ({ ...lane, items: [] })), + standing: [], + series: [], + }; + return empty; +} + +vi.mock("next/navigation", () => ({ + usePathname: () => "/timeline", + useSearchParams: () => search, + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", timezone: "Europe/Berlin", modules: {} }, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: false }), +})); +vi.mock("../use-timeline", () => ({ + useTimeline: ( + zoom: TimelineZoom, + from: string | null, + to: string | null, + _values: readonly string[], + enabled = true, + ) => { + if (!enabled) { + return { + data: undefined, + isPending: true, + isError: false, + isPlaceholderData: false, + refetch: vi.fn(), + }; + } + asked.push(zoom); + return { + data: answer(zoom, from, to), + isPending: false, + isError: false, + isPlaceholderData: false, + refetch: vi.fn(), + }; + }, + useTimelineReadiness: () => ({ + data: readiness(), + isPending: false, + isError: false, + }), + useLifeEvents: () => ({ data: { events: [] } }), + useLifeEventMutations: () => ({ + create: { isPending: false }, + update: { isPending: false }, + remove: { isPending: false }, + }), +})); +vi.mock("../timeline-dates", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, todayKeyIn: () => TODAY }; +}); + +let TimelineView: typeof import("../timeline-view").TimelineView; + +beforeAll(async () => { + ({ TimelineView } = await import("../timeline-view")); +}, 60_000); + +beforeEach(() => { + search = new URLSearchParams(); + dataFrom = "2019-01-01"; + asked.length = 0; +}); + +function render(): string { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("the timeline's default window", () => { + it("opens on everything when three months hold nothing but the record is older", () => { + const html = render(); + expect(asked).toContain("quarter"); + expect(asked).toContain("all"); + expect(html).toMatch(/data-slot="timeline-desktop"[^>]*data-zoom="all"/); + expect(html).not.toContain('data-slot="timeline-window-empty"'); + }); + + it("an unreadable zoom opens the same way as none", () => { + search = new URLSearchParams({ zoom: "decade" }); + const html = render(); + // It asks for the default three months first, as without a zoom. + expect(asked[0]).toBe("quarter"); + expect(html).toMatch(/data-slot="timeline-desktop"[^>]*data-zoom="all"/); + }); + + it("a window named in the URL stays, and offers everything", () => { + search = new URLSearchParams({ zoom: "quarter" }); + const html = render(); + expect(asked).not.toContain("all"); + expect(html).toContain('data-slot="timeline-window-empty"'); + expect(html).toContain('data-slot="timeline-show-everything"'); + expect(html).not.toContain('data-slot="timeline-desktop"'); + }); + + it("an empty record keeps the plain empty state", () => { + dataFrom = null; + const html = render(); + expect(asked).not.toContain("all"); + expect(html).not.toContain('data-slot="timeline-show-everything"'); + expect(html).not.toContain('data-slot="timeline-desktop"'); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-url.test.ts b/src/components/timeline/__tests__/timeline-url.test.ts index 14fb1709a..eb36416c5 100644 --- a/src/components/timeline/__tests__/timeline-url.test.ts +++ b/src/components/timeline/__tests__/timeline-url.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it } from "vitest"; import { TIMELINE_RANGE_MAX_DAYS } from "@/lib/day/contract"; import { dayKey, dayNumber } from "../timeline-dates"; -import { clampRange, parseTimelineUrl, timelineSearch } from "../timeline-url"; +import { + clampRange, + hasDataBeforeWindow, + parseTimelineUrl, + timelineSearch, +} from "../timeline-url"; const TODAY = "2026-10-07"; const parse = (search: string) => @@ -26,7 +31,7 @@ describe("timeline URL", () => { }); }); - it("falls back to all, without a word, on anything it cannot read", () => { + it("reads anything it cannot read as no zoom, the same default as none", () => { for (const search of [ "zoom=decade", "zoom=range", @@ -36,10 +41,19 @@ describe("timeline URL", () => { "zoom=range&from=2027-01-01&to=2027-02-01", "zoom=range&from=yesterday&to=today", ]) { - expect(parse(search), search).toEqual({ zoom: "all", range: null }); + expect(parse(search), search).toEqual(parse("")); } }); + it("knows when a window misses the record's older entries", () => { + const quarter = { from: "2026-08-01" }; + expect(hasDataBeforeWindow("2025-02-11", quarter)).toBe(true); + expect(hasDataBeforeWindow("2026-08-01", quarter)).toBe(false); + expect(hasDataBeforeWindow("2026-09-15", quarter)).toBe(false); + expect(hasDataBeforeWindow(null, quarter)).toBe(false); + expect(hasDataBeforeWindow("2025-02-11", null)).toBe(false); + }); + it("pulls a range's end back to today and its span under the cap", () => { expect(parse("zoom=range&from=2026-09-01&to=2027-01-01").range).toEqual({ from: "2026-09-01", diff --git a/src/components/timeline/timeline-url.ts b/src/components/timeline/timeline-url.ts index 7e95b26b8..f469c4365 100644 --- a/src/components/timeline/timeline-url.ts +++ b/src/components/timeline/timeline-url.ts @@ -2,8 +2,9 @@ * The timeline's zoom and chosen range in the URL (v1.42): * `?zoom=year`, `?zoom=range&from=2026-03-01&to=2026-04-15`. Back and a * bookmark both land where the person was. Anything that does not read as - * a zoom, or a range without two valid ends, falls back to `all` without a - * word: a stale bookmark should open the timeline, not an error. + * a zoom, or a range without two valid ends, reads as no zoom at all and + * opens on the same default without a word: a stale bookmark should open + * the timeline, not an error. */ import { TIMELINE_RANGE_MAX_DAYS, @@ -57,7 +58,6 @@ export function parseTimelineUrl( const zoom = (TIMELINE_ZOOMS as readonly string[]).includes(raw ?? "") ? (raw as TimelineZoom) : null; - if (raw !== null && zoom === null) return { zoom: "all", range: null }; if (zoom !== "range") return { zoom, range: null }; const from = params.get(FROM_PARAM); const to = params.get(TO_PARAM); @@ -69,11 +69,26 @@ export function parseTimelineUrl( from > to || from > today ) { - return { zoom: "all", range: null }; + return { zoom: null, range: null }; } return { zoom, range: clampRange({ from, to }, today, null) }; } +/** The zoom the page opens on when the URL names none. */ +export const DEFAULT_ZOOM: TimelineZoom = "quarter"; + +/** + * True when a window shows nothing while the record has entries from before + * it: the page then offers everything instead of a dead end. `dataFrom` is + * the earliest date anything in the record carries. + */ +export function hasDataBeforeWindow( + dataFrom: string | null, + window: { from: string } | null, +): boolean { + return dataFrom !== null && window !== null && dataFrom < window.from; +} + /** * The search string for a zoom (and, for `range`, its ends), keeping every * other parameter (`day`, `add`) as it is. diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index d375c18b4..bd95d6047 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -38,6 +38,7 @@ import { type LifeEventDTO, type TimelineBucket, type TimelineLaneKey, + type TimelineResponse, type TimelineZoom, } from "@/lib/day/contract"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -69,6 +70,8 @@ import { RangeFields } from "./range-fields"; import { RangeSheet, ZoomControl } from "./range-picker"; import { clampRange, + DEFAULT_ZOOM, + hasDataBeforeWindow, parseTimelineUrl, timelineSearch, type TimelineRange, @@ -131,6 +134,15 @@ export const DEFAULT_VALUES = [ "RESTING_HEART_RATE", ]; +/** Nothing to draw: no lane item, nothing standing, no reading. */ +function timelineIsEmpty(data: TimelineResponse): boolean { + return ( + data.lanes.every((l) => l.items.length === 0) && + data.standing.length === 0 && + data.series.every((s) => s.points.length === 0) + ); +} + const VALUES_KEY = "healthlog.timeline.values"; const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes"; @@ -149,8 +161,8 @@ export function TimelineView() { // and a bookmark return to them. Without a `zoom` the page opens on three // months: around an incoming `?day=`, otherwise up to today. const urlState = parseTimelineUrl(searchParams, today); - const zoom: TimelineZoom = urlState.zoom ?? "quarter"; - const range = zoom === "range" ? urlState.range : null; + const askedZoom: TimelineZoom = urlState.zoom ?? DEFAULT_ZOOM; + const range = askedZoom === "range" ? urlState.range : null; const [grouping, setGrouping] = useState("month"); // The selection follows `?day=` (Back, Forward, the day panel's previous // and next) and otherwise the last click or key. A pick remembers the @@ -216,18 +228,40 @@ export function TimelineView() { [hiddenList], ); - const requestWindow = - zoom === "all" + const askedWindow = + askedZoom === "all" ? null - : zoom === "range" + : askedZoom === "range" ? range - : windowFor(zoom, today, selected ?? dayParam); - const timeline = useTimeline( - zoom, - requestWindow?.from ?? null, - requestWindow?.to ?? null, + : windowFor(askedZoom, today, selected ?? dayParam); + // Opened without a zoom on three months that hold nothing, while the + // record has entries from before them: open on everything instead of a + // dead end. The URL stays as it is, so choosing a zoom still works. Once + // decided it holds until the URL names a zoom, so picking or opening a + // day on the full view does not snap it back to three months. + const [opensOnAll, setOpensOnAll] = useState(false); + if (opensOnAll && urlState.zoom !== null) setOpensOnAll(false); + const asked = useTimeline( + askedZoom, + askedWindow?.from ?? null, + askedWindow?.to ?? null, values, + !opensOnAll, ); + if ( + !opensOnAll && + urlState.zoom === null && + !asked.isPlaceholderData && + asked.data && + timelineIsEmpty(asked.data) && + hasDataBeforeWindow(asked.data.range.dataFrom, askedWindow) + ) { + setOpensOnAll(true); + } + const everything = useTimeline("all", null, null, values, opensOnAll); + const zoom: TimelineZoom = opensOnAll ? "all" : askedZoom; + const requestWindow = opensOnAll ? null : askedWindow; + const timeline = opensOnAll ? everything : asked; const readiness = useTimelineReadiness(); const lifeEvents = useLifeEvents(canAddLifeEvent); @@ -329,11 +363,12 @@ export function TimelineView() { .filter((l) => l.items.length > 0) .map((l) => l.key) .sort((a, b) => LANE_ORDER.indexOf(a) - LANE_ORDER.indexOf(b)); - const isEmpty = + const isEmpty = !!data && timelineIsEmpty(data); + // An empty window with entries before it offers everything. + const showEverything = !!data && - presentLanes.length === 0 && - data.standing.length === 0 && - data.series.every((s) => s.points.length === 0); + zoom !== "all" && + hasDataBeforeWindow(data.range.dataFrom, requestWindow); const zoomOptions = TIMELINE_ZOOMS.map((z) => ({ value: z, @@ -436,6 +471,24 @@ export function TimelineView() { } /> + ) : showEverything && (isEmpty || !window_) ? ( + // A window with nothing in it while the record has older entries: + // one step to all of them. + setZoom("all")} + data-slot="timeline-show-everything" + > + {t("timeline.range.clear")} + + } + /> ) : isEmpty || !data || !window_ ? ( apiGet(timelinePath(zoom, from, to, values)), // Moving between zoom levels keeps the last answer on screen until the From 0ff1340ff491c6180c03e145cc9f598961b03c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:07:06 +0200 Subject: [PATCH 03/12] fix(medications): never backfill days an edit reopens under auto-take A start moved earlier, an end moved later or removed, or a medication made active again reopened past slots, and the auto-take pass filled up to a week of them as taken. The course window writer and the re-activation paths now move the auto-take floor to the moment of the edit. --- src/app/api/medications/[id]/route.ts | 8 ++- .../__tests__/auto-take-window.test.ts | 58 +++++++++++++++++++ src/lib/medications/auto-take.ts | 54 +++++++++++++++++ src/lib/medications/courses.ts | 29 ++++++++-- src/lib/medications/lifecycle.ts | 8 ++- .../medication-auto-mark-taken.test.ts | 52 +++++++++++++++++ 6 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 src/lib/medications/__tests__/auto-take-window.test.ts diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 500bc360f..da7612a17 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -32,7 +32,10 @@ import { toRevisionPayloadEntry, } from "@/lib/medications/scheduling/schedule-eras"; import type { Prisma } from "@/generated/prisma/client"; -import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; +import { + loadAutoTakeRollingAnchors, + moveAutoTakeFloorToNow, +} from "@/lib/medications/auto-take"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, @@ -1022,6 +1025,9 @@ export const PUT = apiHandler( where: { medicationId: id, userId: user.id, resumedAt: null }, data: { resumedAt: new Date() }, }); + // v1.42.2 — made active again: the auto-take pass starts from + // now, never filling the inactive days as taken. + await moveAutoTakeFloorToNow(tx, id); } // v1.40 (#1024) — write the window onto the latest course and diff --git a/src/lib/medications/__tests__/auto-take-window.test.ts b/src/lib/medications/__tests__/auto-take-window.test.ts new file mode 100644 index 000000000..17b660fbf --- /dev/null +++ b/src/lib/medications/__tests__/auto-take-window.test.ts @@ -0,0 +1,58 @@ +/** + * v1.42.2 — which window edits reopen past days for the auto-take pass. + */ +import { describe, expect, it, vi } from "vitest"; + +import { + moveAutoTakeFloorToNow, + windowWidens, +} from "@/lib/medications/auto-take"; + +const d = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe("windowWidens", () => { + it("an earlier or removed start, a later or removed end, reopens time", () => { + const before = { startsOn: d("2026-06-10"), endsOn: d("2026-06-20") }; + expect(windowWidens(before, { ...before, startsOn: d("2026-06-01") })).toBe( + true, + ); + expect(windowWidens(before, { ...before, startsOn: null })).toBe(true); + expect(windowWidens(before, { ...before, endsOn: d("2026-06-30") })).toBe( + true, + ); + expect(windowWidens(before, { ...before, endsOn: null })).toBe(true); + }); + + it("a later start, an earlier end, or no change reopens nothing", () => { + const before = { startsOn: d("2026-06-10"), endsOn: d("2026-06-20") }; + expect(windowWidens(before, before)).toBe(false); + expect(windowWidens(before, { ...before, startsOn: d("2026-06-12") })).toBe( + false, + ); + expect(windowWidens(before, { ...before, endsOn: d("2026-06-15") })).toBe( + false, + ); + expect( + windowWidens( + { startsOn: null, endsOn: null }, + { startsOn: d("2026-06-01"), endsOn: d("2026-06-30") }, + ), + ).toBe(false); + }); +}); + +describe("moveAutoTakeFloorToNow", () => { + it("moves only an auto-taken medication's earlier floor", async () => { + const updateMany = vi.fn(async () => ({ count: 1 })); + const now = new Date("2026-06-10T10:00:00.000Z"); + await moveAutoTakeFloorToNow({ medication: { updateMany } }, "m1", now); + expect(updateMany).toHaveBeenCalledWith({ + where: { + id: "m1", + autoMarkTaken: true, + autoMarkTakenSince: { lt: now }, + }, + data: { autoMarkTakenSince: now }, + }); + }); +}); diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts index d34a0e3ad..a9113cd48 100644 --- a/src/lib/medications/auto-take.ts +++ b/src/lib/medications/auto-take.ts @@ -236,6 +236,60 @@ export function autoTakeEligibleWhere(now: Date) { }; } +/** + * True when an edit to a medication's window reopens time before now: the + * start moves earlier (or goes), or the end moves later (or goes). Dates are + * calendar days, compared as stored. + */ +export function windowWidens( + before: { startsOn: Date | null; endsOn: Date | null }, + after: { startsOn: Date | null; endsOn: Date | null }, +): boolean { + const startsEarlier = + before.startsOn !== null && + (after.startsOn === null || + after.startsOn.getTime() < before.startsOn.getTime()); + const endsLater = + before.endsOn !== null && + (after.endsOn === null || after.endsOn.getTime() > before.endsOn.getTime()); + return startsEarlier || endsLater; +} + +/** + * Move an auto-taken medication's floor to now when it lies before now. + * Called wherever an edit reopens past slots (a wider window, a medication + * made active again): the option was on, but the person never had those + * days as due, so the pass must not fill them as taken. A no-op on a + * medication without the option. + */ +export interface AutoTakeFloorClient { + medication: { + updateMany(args: { + where: { + id: string; + autoMarkTaken: true; + autoMarkTakenSince: { lt: Date }; + }; + data: { autoMarkTakenSince: Date }; + }): Promise<{ count: number }>; + }; +} + +export async function moveAutoTakeFloorToNow( + client: AutoTakeFloorClient, + medicationId: string, + now: Date = new Date(), +): Promise { + await client.medication.updateMany({ + where: { + id: medicationId, + autoMarkTaken: true, + autoMarkTakenSince: { lt: now }, + }, + data: { autoMarkTakenSince: now }, + }); +} + const ROLLING_SCHEDULE_SELECT = { id: true, windowStart: true, diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 4abd7b7d0..760b187f9 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -27,6 +27,10 @@ import { type CourseStatus, } from "@/lib/medications/course-window"; import { userDayKey } from "@/lib/tz/format"; +import { + moveAutoTakeFloorToNow, + windowWidens, +} from "@/lib/medications/auto-take"; type Tx = Prisma.TransactionClient; @@ -129,17 +133,32 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) { type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null }; -/** Write the medication's window from its courses. The only writer. */ +/** + * Write the medication's window from its courses. The only writer. + * + * v1.42.2 — a window that now reaches further into the past than before + * (an earlier start, a later or removed end) would let the auto-take pass + * fill the days it reopens as taken; its floor moves to now instead. + */ async function project(tx: Tx, medicationId: string): Promise { - const courses = await tx.medicationCourse.findMany({ - where: { medicationId }, - select: { startsOn: true, endsOn: true }, - }); + const [before, courses] = await Promise.all([ + tx.medication.findUnique({ + where: { id: medicationId }, + select: { startsOn: true, endsOn: true }, + }), + tx.medicationCourse.findMany({ + where: { medicationId }, + select: { startsOn: true, endsOn: true }, + }), + ]); const window = projectCourseWindow(courses); await tx.medication.update({ where: { id: medicationId }, data: { startsOn: window.startsOn, endsOn: window.endsOn }, }); + if (before && windowWidens(before, window)) { + await moveAutoTakeFloorToNow(tx, medicationId); + } return window; } diff --git a/src/lib/medications/lifecycle.ts b/src/lib/medications/lifecycle.ts index 80b84dc57..a021daf9c 100644 --- a/src/lib/medications/lifecycle.ts +++ b/src/lib/medications/lifecycle.ts @@ -21,9 +21,13 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { + moveAutoTakeFloorToNow, + type AutoTakeFloorClient, +} from "@/lib/medications/auto-take"; interface ReconcilePrisma { - medication: { + medication: AutoTakeFloorClient["medication"] & { findUnique: (args: { where: { id: string }; select: { oneShot: true; active: true }; @@ -105,6 +109,8 @@ export async function reconcileOneShotState( where: { id: medicationId, userId, oneShot: true }, data: { active: shouldBeActive }, }); + // v1.42.2 — active again: the auto-take pass starts from now. + if (shouldBeActive) await moveAutoTakeFloorToNow(prismaClient, medicationId); await auditLog("medication.oneShot.reconciled", { userId, diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index fb36186c1..a0e80eb67 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -380,6 +380,58 @@ describe("auto-mark-taken pass", () => { }); }); +describe("reopening past days never backfills them", () => { + it("a later end date or a re-activation moves the floor to now", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const medicationId = await seedEvening(userId, "Course", { + autoMarkTakenSince: new Date("2026-06-01T00:00:00.000Z"), + }); + const route = await import("@/app/api/medications/[id]/route"); + const put = (body: unknown) => + (route.PUT as Handler)( + new NextRequest(`http://localhost/api/medications/${medicationId}`, { + method: "PUT", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }), + { params: Promise.resolve({ id: medicationId }) }, + ); + const since = async () => + ( + await prisma.medication.findUniqueOrThrow({ + where: { id: medicationId }, + select: { autoMarkTakenSince: true }, + }) + ).autoMarkTakenSince?.toISOString(); + + // The course ended on 06-05; the pass filled up to then. + at("2026-06-03T10:00:00.000Z"); + expect( + (await put({ startsOn: "2026-06-01", endsOn: "2026-06-05" })).status, + ).toBe(200); + expect(await since()).toBe("2026-06-01T00:00:00.000Z"); + at("2026-06-10T10:00:00.000Z"); + await runIntakeAutoTakePass(prisma, { nowMs: Date.now() }); + const filled = (await liveRows(medicationId)).length; + + // Extended on 06-10 to the end of the month: 06-06..06-09 reopen, but + // the person never had them as due. + expect((await put({ endsOn: "2026-06-30" })).status).toBe(200); + expect(await since()).toBe("2026-06-10T10:00:00.000Z"); + await runIntakeAutoTakePass(prisma, { nowMs: Date.now() }); + expect((await liveRows(medicationId)).length).toBe(filled); + + // Paused, then active again a week later: nothing in between is filled. + expect((await put({ active: false })).status).toBe(200); + at("2026-06-17T10:00:00.000Z"); + expect((await put({ active: true })).status).toBe(200); + expect(await since()).toBe("2026-06-17T10:00:00.000Z"); + await runIntakeAutoTakePass(prisma, { nowMs: Date.now() }); + expect((await liveRows(medicationId)).length).toBe(filled); + }); +}); + describe("rolling cadence under auto-take", () => { it("never stalls on a removed dose, and the card shows the slot the pass records", async () => { const prisma = getPrismaClient(); From dc86c42bef8e57dc853af904209abf510ef0ab4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:08:57 +0200 Subject: [PATCH 04/12] feat(timeline): fit the default window to the record Without a zoom in the URL the window now follows the record: one that starts inside the last three months opens on three months clamped to its first entry (at least four weeks up to today), an older one with entries there opens on three months, an older one without opens on everything, and an empty one keeps the empty state. The choice is made from the first answer before anything is drawn, so the page does not jump between windows. --- e2e/timeline.spec.ts | 22 ++++++++++ e2e/utils/mock-timeline.ts | 14 +++++-- .../__tests__/timeline-default-zoom.test.tsx | 29 +++++++++++++ .../timeline/__tests__/timeline-url.test.ts | 27 ++++++++++++ src/components/timeline/timeline-chart.tsx | 2 + src/components/timeline/timeline-url.ts | 41 +++++++++++++++++++ src/components/timeline/timeline-view.tsx | 33 +++++++++++---- 7 files changed, 156 insertions(+), 12 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index edb47b780..f691d0417 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -108,6 +108,28 @@ test("only old data opens on everything, and an empty window offers it", async ( await setTimelineModule(page, false); }); +test("a young record opens on three months starting at its first entry", async ({ + page, +}) => { + await setTimelineModuleOn(page); + // The record starts ten days ago: the window spans the four-week minimum. + const day = (offset: number) => { + const [y, m, d] = TODAY.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d + offset)).toISOString().slice(0, 10); + }; + await mockTimeline(page, "full", TODAY, { dataFrom: day(-10) }); + await page.goto("/timeline"); + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), + ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toHaveAttribute( + "data-window-from", + day(-27), + ); + // The next test starts from the module switched off. + await setTimelineModule(page, false); +}); + test("switching it on opens the readiness inventory, which leads to the timeline", async ({ page, }) => { diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index 528f36106..512b90c8f 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -826,12 +826,15 @@ export interface TimelineMockLog { * `onlyOld`: the record's entries all lie before the last year, so a * quarter or year window answers empty (with the record's `dataFrom`) and * only everything, or a chosen range, carries them. + * + * `dataFrom`: the record's first entry as the answers name it, to stand in + * for a young record. */ export async function mockTimeline( page: Page, state: "full" | "ready", today: string, - options: { onlyOld?: boolean } = {}, + options: { onlyOld?: boolean; dataFrom?: string } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => @@ -848,7 +851,8 @@ export async function mockTimeline( const from = url.searchParams.get("from"); const to = url.searchParams.get("to"); const bucket = bucketFor(zoom, from, to); - const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01"; + const dataFrom = + options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01"); if (options.onlyOld && (zoom === "quarter" || zoom === "year")) { return json(route, 200, { ...body, @@ -870,7 +874,11 @@ export async function mockTimeline( ...body, zoom, bucket, - ...(chosen ? { range: { from, to, dataFrom } } : {}), + ...(chosen + ? { range: { from, to, dataFrom } } + : options.dataFrom + ? { range: { ...body.range, dataFrom } } + : {}), // Any other value the menu offers answers a flat line of its own. series: values.map( (key) => diff --git a/src/components/timeline/__tests__/timeline-default-zoom.test.tsx b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx index 30b453198..2ebf69f22 100644 --- a/src/components/timeline/__tests__/timeline-default-zoom.test.tsx +++ b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx @@ -19,11 +19,20 @@ import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; let search = new URLSearchParams(); let dataFrom: string | null = "2019-01-01"; +/** A young record: everything sits inside the default three months. */ +let young = false; const asked: TimelineZoom[] = []; /** Everything for `all`; any other window holds nothing. */ function answer(zoom: TimelineZoom, from: string | null, to: string | null) { const full = fullTimeline(); + if (young) { + return { + ...full, + zoom, + range: { from: from ?? "2026-08-01", to: to ?? TODAY, dataFrom }, + }; + } if (zoom === "all" && dataFrom !== null) return { ...full, zoom }; const empty: TimelineResponse = { ...full, @@ -101,6 +110,7 @@ beforeAll(async () => { beforeEach(() => { search = new URLSearchParams(); dataFrom = "2019-01-01"; + young = false; asked.length = 0; }); @@ -140,6 +150,25 @@ describe("the timeline's default window", () => { expect(html).not.toContain('data-slot="timeline-desktop"'); }); + it("a young record opens on three months clamped to its first entry", () => { + young = true; + dataFrom = "2026-09-01"; + const html = render(); + expect(asked).not.toContain("all"); + expect(html).toMatch( + /data-slot="timeline-desktop"[^>]*data-zoom="quarter"/, + ); + expect(html).toMatch(/data-window-from="2026-09-01"/); + expect(html).toMatch(/data-window-to="2026-10-31"/); + }); + + it("an older record with entries in the three months opens on them", () => { + young = true; + const html = render(); + expect(asked).not.toContain("all"); + expect(html).toMatch(/data-window-from="2026-08-01"/); + }); + it("an empty record keeps the plain empty state", () => { dataFrom = null; const html = render(); diff --git a/src/components/timeline/__tests__/timeline-url.test.ts b/src/components/timeline/__tests__/timeline-url.test.ts index eb36416c5..0aa30d565 100644 --- a/src/components/timeline/__tests__/timeline-url.test.ts +++ b/src/components/timeline/__tests__/timeline-url.test.ts @@ -7,6 +7,7 @@ import { clampRange, hasDataBeforeWindow, parseTimelineUrl, + resolveDefaultWindow, timelineSearch, } from "../timeline-url"; @@ -54,6 +55,32 @@ describe("timeline URL", () => { expect(hasDataBeforeWindow("2025-02-11", null)).toBe(false); }); + it("adapts the default window to the record", () => { + // The default three months up to 2026-10-07. + const window = { from: "2026-08-01", to: "2026-10-31" }; + const decide = (dataFrom: string | null, windowHasData: boolean) => + resolveDefaultWindow({ dataFrom, window, windowHasData, today: TODAY }); + + // 1. A young record starts at its first entry... + expect(decide("2026-09-01", true)).toEqual({ + zoom: "quarter", + from: "2026-09-01", + }); + // ...but spans at least four weeks up to today. + expect(decide("2026-10-05", true)).toEqual({ + zoom: "quarter", + from: "2026-09-10", + }); + // A first entry on the window's first day is the plain three months. + expect(decide("2026-08-01", true)).toEqual({ zoom: "quarter", from: null }); + // 2. An older record with entries in the three months: three months. + expect(decide("2019-01-01", true)).toEqual({ zoom: "quarter", from: null }); + // 3. An older record with none there: everything. + expect(decide("2019-01-01", false)).toEqual({ zoom: "all" }); + // 4. No data at all: the plain empty state. + expect(decide(null, false)).toBeNull(); + }); + it("pulls a range's end back to today and its span under the cap", () => { expect(parse("zoom=range&from=2026-09-01&to=2027-01-01").range).toEqual({ from: "2026-09-01", diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 106d1839a..69b042257 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -230,6 +230,8 @@ export function TimelineChart({ onKeyDown={handleKey} data-slot="timeline-chart" data-width={width} + data-window-from={window.from} + data-window-to={window.to} className="focus-visible:ring-ring/50 relative rounded-lg outline-none focus-visible:ring-2" >

diff --git a/src/components/timeline/timeline-url.ts b/src/components/timeline/timeline-url.ts index f469c4365..149a0fd82 100644 --- a/src/components/timeline/timeline-url.ts +++ b/src/components/timeline/timeline-url.ts @@ -77,6 +77,47 @@ export function parseTimelineUrl( /** The zoom the page opens on when the URL names none. */ export const DEFAULT_ZOOM: TimelineZoom = "quarter"; +/** The shortest default window over a young record, in days to today. */ +export const YOUNG_RECORD_MIN_DAYS = 28; + +/** + * What the page opens on when the URL names no zoom, decided from the + * default three months' answer (`window`, whether it holds anything, and + * the record's first entry): + * + * - a record that starts inside the three months opens on three months + * clamped to its first entry, at least four weeks up to today, so a + * few days of data neither hide in a sliver nor stretch thin + * (`from` set); + * - an older record with entries in the three months opens on them + * (`from` null); + * - an older record with none there opens on everything; + * - an empty record: null, the plain empty state. + */ +export type DefaultWindow = + { zoom: "quarter"; from: string | null } | { zoom: "all" } | null; + +export function resolveDefaultWindow(input: { + dataFrom: string | null; + window: { from: string; to: string }; + windowHasData: boolean; + today: string; +}): DefaultWindow { + const { dataFrom, window, today } = input; + if (dataFrom === null) return null; + if (dataFrom >= window.from) { + const minimum = dayKey(dayNumber(today) - (YOUNG_RECORD_MIN_DAYS - 1)); + const start = dataFrom < minimum ? dataFrom : minimum; + return { + zoom: "quarter", + from: start > window.from ? start : null, + }; + } + return input.windowHasData + ? { zoom: "quarter", from: null } + : { zoom: "all" }; +} + /** * True when a window shows nothing while the record has entries from before * it: the page then offers everything instead of a dead end. `dataFrom` is diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index bd95d6047..feeb92d60 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -73,6 +73,7 @@ import { DEFAULT_ZOOM, hasDataBeforeWindow, parseTimelineUrl, + resolveDefaultWindow, timelineSearch, type TimelineRange, } from "./timeline-url"; @@ -248,16 +249,25 @@ export function TimelineView() { values, !opensOnAll, ); - if ( - !opensOnAll && + // Without a zoom the window adapts to the record (`resolveDefaultWindow`): + // decided from the three months' answer, before anything is drawn, so the + // page never draws one window and then jumps to another. + const defaultWindow = urlState.zoom === null && - !asked.isPlaceholderData && + !opensOnAll && + askedWindow && asked.data && - timelineIsEmpty(asked.data) && - hasDataBeforeWindow(asked.data.range.dataFrom, askedWindow) - ) { - setOpensOnAll(true); - } + !asked.isPlaceholderData + ? resolveDefaultWindow({ + dataFrom: asked.data.range.dataFrom, + window: askedWindow, + windowHasData: !timelineIsEmpty(asked.data), + today, + }) + : null; + if (defaultWindow?.zoom === "all") setOpensOnAll(true); + const youngFrom = + defaultWindow?.zoom === "quarter" ? defaultWindow.from : null; const everything = useTimeline("all", null, null, values, opensOnAll); const zoom: TimelineZoom = opensOnAll ? "all" : askedZoom; const requestWindow = opensOnAll ? null : askedWindow; @@ -334,7 +344,7 @@ export function TimelineView() { ? t("timeline.readiness.lanes.mood") : t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key); - const window_ = data + const shownWindow = data ? windowFor( zoom, today, @@ -342,6 +352,11 @@ export function TimelineView() { zoom === "all" || zoom === "range" ? data.range : null, ) : null; + // A young record's three months start at its first entry. + const window_ = + shownWindow && youngFrom && youngFrom > shownWindow.from + ? { from: youngFrom, to: shownWindow.to } + : shownWindow; // A new range starts as the chosen one, or as what is on screen, up to // today. const rangeDraft = clampRange( From fe782493a103a58f5ad5df545db2a1866f7728c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:10:01 +0200 Subject: [PATCH 05/12] fix(medications): a take bound to another slot never claims this one The auto-take pass left a slot alone when any take fell inside its band, so the morning dose pinned and taken at 19:00 also hid the 20:00 dose. Only a take attributed to no other expected slot counts as nearby now. --- .../jobs/__tests__/intake-auto-take.test.ts | 97 +++++++++++++++++++ src/lib/jobs/intake-auto-take.ts | 5 + src/lib/medications/auto-take.ts | 23 ++++- 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index 969e84758..a2daeada1 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -485,6 +485,7 @@ interface FakeRow { deletedAt: Date | null; source: string; syncVersion: number; + attributionSource?: "AUTO" | "USER_PIN"; } function fakeClient(meds: ReturnType[], rows: FakeRow[]) { @@ -892,3 +893,99 @@ describe("runIntakeAutoTakePass — rolling cadence keeps moving", () => { expect(autoRecordedSlots(rows)).toEqual([display!.at.toISOString()]); }); }); + +// ── a take bound to another slot never claims this one ─────────────── + +describe("runIntakeAutoTakePass — nearby takes of another slot", () => { + const twiceDaily = () => + medication({ + autoMarkTakenSince: new Date("2026-06-10T00:00:00Z"), + schedules: [schedule({ timesOfDay: ["08:00", "20:00"] })], + }); + // 2026-06-10 (CEST): 08:00 = 06:00Z, 20:00 = 18:00Z; 19:00 = 17:00Z. + const MORNING = new Date("2026-06-10T06:00:00Z"); + const EVENING = "2026-06-10T18:00:00.000Z"; + const autoSlots = (rows: FakeRow[]) => + rows.filter((r) => r.autoRecorded).map((r) => r.scheduledFor.toISOString()); + + it("the morning dose pinned and taken at 19:00 leaves the evening dose due", async () => { + const rows: FakeRow[] = [ + row({ + id: "pin", + scheduledFor: MORNING, + takenAt: new Date("2026-06-10T17:00:00Z"), + attributionSource: "USER_PIN", + source: "WEB", + }), + ]; + await runIntakeAutoTakePass(fakeClient([twiceDaily()], rows), { + nowMs: Date.parse("2026-06-10T18:20:00Z"), + }); + expect(autoSlots(rows)).toEqual([EVENING]); + }); + + it("the same without a pin: a take on the morning slot is the morning's", async () => { + const rows: FakeRow[] = [ + row({ + id: "late", + scheduledFor: MORNING, + takenAt: new Date("2026-06-10T17:00:00Z"), + source: "WEB", + }), + ]; + await runIntakeAutoTakePass(fakeClient([twiceDaily()], rows), { + nowMs: Date.parse("2026-06-10T18:20:00Z"), + }); + expect(autoSlots(rows)).toEqual([EVENING]); + }); + + it("an ad-hoc take at 19:00 still claims the evening dose", async () => { + const at19 = new Date("2026-06-10T17:00:00Z"); + const rows: FakeRow[] = [ + // The morning dose, taken on time. + row({ + id: "am", + scheduledFor: MORNING, + takenAt: MORNING, + source: "WEB", + }), + row({ id: "adhoc", scheduledFor: at19, takenAt: at19, source: "WEB" }), + ]; + await runIntakeAutoTakePass(fakeClient([twiceDaily()], rows), { + nowMs: Date.parse("2026-06-10T18:20:00Z"), + }); + expect(autoSlots(rows)).toEqual([]); + }); + + it("decideSlot: a pinned row elsewhere is not nearby, an ad-hoc one is", () => { + const band = { + earlyStart: new Date("2026-06-10T15:00:00Z"), + overdueEnd: new Date("2026-06-10T21:00:00Z"), + }; + const evening = new Date(EVENING); + const taken = new Date("2026-06-10T17:00:00Z"); + const base = { + takenAt: taken, + skipped: false, + autoMissed: false, + deletedAt: null, + }; + expect( + decideSlot( + evening, + [ + { + ...base, + id: "p", + scheduledFor: new Date("2026-06-09T12:00:00Z"), + attributionSource: "USER_PIN", + }, + ], + band, + ), + ).toEqual({ kind: "create" }); + expect( + decideSlot(evening, [{ ...base, id: "a", scheduledFor: taken }], band), + ).toEqual({ kind: "leave", reason: "taken_nearby" }); + }); +}); diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index e65338966..6df52c701 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -387,6 +387,7 @@ async function autoTakeMedication( skipped: true, autoMissed: true, deletedAt: true, + attributionSource: true, }, }); @@ -435,6 +436,7 @@ async function autoTakeMedication( lastIntakeAt: anchor, }); + const expectedSlots = new Set(bands.keys()); let recordedRolling = false; for (const slot of slots) { handled.add(slot.at.getTime()); @@ -445,6 +447,9 @@ async function autoTakeMedication( slot.at, rows, isRollingSlot ? null : (bands.get(slot.at.getTime()) ?? null), + // Every expected slot around the window: a take sitting on one of + // them is that slot's dose, never a nearby take of this one. + expectedSlots, ); const written = await applyDecision(prisma, { decision, diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts index a9113cd48..e02e87a3e 100644 --- a/src/lib/medications/auto-take.ts +++ b/src/lib/medications/auto-take.ts @@ -78,6 +78,8 @@ export interface SlotRow { skipped: boolean; autoMissed: boolean; deletedAt: Date | null; + /** `USER_PIN` when the person bound the row to its `scheduledFor`. */ + attributionSource?: "AUTO" | "USER_PIN"; } export type SlotDecision = @@ -96,11 +98,18 @@ export type SlotDecision = * `band` is the slot's attribution band when the cadence has one (a take * anywhere inside it belongs to this slot); `null` for a rolling slot, whose * next occurrence is by definition later than every taken dose. + * + * Only a take that belongs to no other slot claims this one from inside its + * band. A take whose `scheduledFor` is another expected slot (`otherSlots`), + * or that the person pinned to an instant other than this slot, is that + * slot's dose however close its `takenAt` lies: the morning dose taken in + * the evening is still the morning dose, and the evening one stays due. */ export function decideSlot( slotAt: Date, rows: readonly SlotRow[], band: Pick | null, + otherSlots: ReadonlySet = new Set(), ): SlotDecision { const t = slotAt.getTime(); const onSlot = rows.filter((r) => r.scheduledFor.getTime() === t); @@ -121,7 +130,8 @@ export function decideSlot( !r.skipped && r.takenAt !== null && r.takenAt.getTime() >= from && - r.takenAt.getTime() <= to, + r.takenAt.getTime() <= to && + !belongsElsewhere(r, t, otherSlots), ); if (takenNearby) return { kind: "leave", reason: "taken_nearby" }; } @@ -131,6 +141,17 @@ export function decideSlot( return pending ? { kind: "convert", rowId: pending.id } : { kind: "create" }; } +/** True when a row is attributed to a slot other than the one at `slotMs`. */ +function belongsElsewhere( + row: SlotRow, + slotMs: number, + otherSlots: ReadonlySet, +): boolean { + const at = row.scheduledFor.getTime(); + if (at === slotMs) return false; + return row.attributionSource === "USER_PIN" || otherSlots.has(at); +} + export interface AutoTakeRollingMedication extends WorkerMedicationRow { schedules: WorkerScheduleRow[]; pauseEras: { pausedAt: Date; resumedAt: Date | null }[]; From 38900656a398e05a0951c9fd0ad2b49185fcb496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:11:27 +0200 Subject: [PATCH 06/12] fix(audit): name automatic dose recording in medication audit rows Switching autoMarkTaken on or off now appears in the medication.update row with its previous value, and medication.create records whether the medication starts with doses recorded automatically. --- src/app/api/medications/[id]/route.ts | 4 ++ src/app/api/medications/route.ts | 8 ++- .../medication-auto-mark-taken.test.ts | 52 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index da7612a17..13417b366 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -1134,6 +1134,9 @@ export const PUT = apiHandler( active: existing.active, asNeeded: existing.asNeeded, trackIntake: existing.trackIntake, + // v1.42.2 — switching automatic recording on or off is a change + // to how doses are recorded, so the row names it. + autoMarkTaken: existing.autoMarkTaken, endsOn: existing.endsOn, }, after: { @@ -1142,6 +1145,7 @@ export const PUT = apiHandler( active: medication.active, asNeeded: medication.asNeeded, trackIntake: medication.trackIntake, + autoMarkTaken: medication.autoMarkTaken, endsOn: medication.endsOn, }, }), diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index d6de31e15..e2fd46d76 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -491,7 +491,13 @@ async function postMedication(request: NextRequest): Promise { await auditLog("medication.create", { userId: user.id, ipAddress: getClientIp(request), - details: { medicationId: medication.id, name }, + details: { + medicationId: medication.id, + name, + // v1.42.2 — a medication created with its doses recorded + // automatically says so in its audit row. + autoMarkTaken: medication.autoMarkTaken, + }, }); annotate({ diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index a0e80eb67..cb79cb58f 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -377,6 +377,58 @@ describe("auto-mark-taken pass", () => { select: { autoMarkTaken: true, autoMarkTakenSince: true }, }); expect(off).toEqual({ autoMarkTaken: false, autoMarkTakenSince: null }); + + // Each flip is named in the audit row with what it was before; the + // re-save that left it on names no change to it. + const audits = await prisma.auditLog.findMany({ + where: { userId, action: "medication.update" }, + orderBy: { createdAt: "asc" }, + select: { details: true }, + }); + const details = audits.map( + (a) => + JSON.parse(a.details ?? "{}") as { + fields: string[]; + previous: Record; + }, + ); + expect(details).toHaveLength(3); + // Two of the same timestamp: matched by content, not order. + const flips = details + .filter((d) => d.fields.includes("autoMarkTaken")) + .map((d) => d.previous.autoMarkTaken) + .sort(); + expect(flips).toEqual([false, true]); + const resave = details.find((d) => d.fields.includes("name")); + expect(resave?.fields).not.toContain("autoMarkTaken"); + }); + + it("the create audit row says whether doses are recorded automatically", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const route = await import("@/app/api/medications/route"); + at("2026-06-10T10:00:00.000Z"); + const res = await (route.POST as Handler)( + new NextRequest("http://localhost/api/medications", { + method: "POST", + body: JSON.stringify({ + name: "Vitamin D", + dose: "1 capsule", + autoMarkTaken: true, + schedules: [{ windowStart: "19:00", windowEnd: "19:00" }], + }), + headers: { "content-type": "application/json" }, + }), + { params: Promise.resolve({}) }, + ); + expect(res.status).toBe(201); + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { userId, action: "medication.create" }, + select: { details: true }, + }); + expect(JSON.parse(audit.details ?? "{}")).toMatchObject({ + autoMarkTaken: true, + }); }); }); From 1ba422b87cbd08e30f48805a1726bb93827e7285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:14:43 +0200 Subject: [PATCH 07/12] fix(backup): restart the auto-take floor at the restore A portable file carries no removed doses, so a restored medication with automatic recording on had the pass fill a dose the person deleted in the days before the export. The restored floor is now the later of the file's value and the restore. --- src/lib/export/restore-backup.ts | 19 ++- tests/integration/backup-round-trip.test.ts | 123 +++++++++++++++++++- 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 6ce6e2520..b2659d8ea 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -1232,14 +1232,21 @@ export async function restoreBackup( oneShot: m.oneShot ?? false, asNeeded: m.asNeeded ?? false, trackIntake: m.trackIntake ?? true, - // v1.42.2 — an option that is on always carries its floor; a - // file that has the flag without one starts it at the restore, - // so the auto-take pass never fills history it did not own. + // v1.42.2 — an option that is on always carries its floor, and + // a restored floor is never before the restore: a portable file + // carries no removed doses, so a dose the person deleted in the + // days before the export would otherwise be filled again as + // taken by the next auto-take pass. autoMarkTaken: m.autoMarkTaken ?? false, autoMarkTakenSince: m.autoMarkTaken - ? m.autoMarkTakenSince - ? new Date(m.autoMarkTakenSince) - : new Date() + ? new Date( + Math.max( + m.autoMarkTakenSince + ? Date.parse(m.autoMarkTakenSince) + : 0, + restoreStartedAt.getTime(), + ), + ) : null, deliveryForm: m.deliveryForm ?? "ORAL", trackInjectionSites: m.trackInjectionSites ?? false, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6ba296592..7cde2c4cb 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -3620,6 +3620,16 @@ const COLUMN_EXCLUSIONS: Readonly> = { "readable copy of the sealed period location; the restore seals it and writes this empty", }; +/** + * Columns the restore carries on purpose as "set where it was set" rather + * than the same value, each with the reason. A presence check still fails a + * column that stops coming back at all. + */ +const COLUMN_PRESENCE_ONLY: Readonly> = { + "Medication.autoMarkTakenSince": + "the auto-take floor restarts at the restore, so doses a portable file cannot carry as removed are not filled again; pinned in the restore test for it", +}; + /** * Columns a release adds ahead of the backup ends that carry them, so the * packages built in parallel on its branch each own their own section of the @@ -3760,6 +3770,7 @@ function comparison( // A declared foreign key may be re-pointed at a row the restore minted, so // it is held to being set where it was set, not to the same id. if (column.isForeignKey) return "presence"; + if (key in COLUMN_PRESENCE_ONLY) return "presence"; return "value"; } @@ -3915,7 +3926,12 @@ describe("every column of every two-ended model survives a real restore", () => const rows = seededRows.get(model.name)!; expect(rows.length, `${model.name} seeded no row`).toBeGreaterThan(0); for (const column of model.columns) { - if (comparison(column) !== "value") continue; + if ( + comparison(column) !== "value" && + !(`${model.name}.${column.name}` in COLUMN_PRESENCE_ONLY) + ) { + continue; + } // Filling it would turn the fixture's row into a tombstone, and which // models carry tombstones is a row decision, not a column one. if (column.name === "deletedAt") continue; @@ -4092,6 +4108,7 @@ describe("every column of every two-ended model survives a real restore", () => ); const stale = [ ...Object.keys(COLUMN_EXCLUSIONS), + ...Object.keys(COLUMN_PRESENCE_ONLY), ...Object.keys(RELEASE_PENDING_COLUMNS), ...Object.keys(READ_THROUGH), ...Object.keys(FILL_OVERRIDES), @@ -4315,6 +4332,110 @@ describe("the account's own settings survive a real restore", () => { }); }); +describe("automatic dose recording after a restore", () => { + it("starts its floor at the restore, so a dose removed before the export stays removed", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { timezone: "UTC" }, + }); + const now = Date.now(); + const dayAt12 = (offset: number) => { + const d = new Date(now + offset * 86_400_000); + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12), + ); + }; + const since = dayAt12(-10); + const medication = await prisma.medication.create({ + data: { + userId: OWNER_ID, + name: "Evening supplement", + dose: "1", + createdAt: dayAt12(-20), + autoMarkTaken: true, + autoMarkTakenSince: since, + schedules: { + create: { + windowStart: "12:00", + windowEnd: "12:00", + timesOfDay: ["12:00"], + rrule: "FREQ=DAILY", + scheduleType: "SCHEDULED", + }, + }, + }, + select: { id: true }, + }); + // The pass filled the last days; the person removed the dose two days + // ago (they did not take it). A portable file carries no removal. + const removed = dayAt12(-2); + for (let offset = -6; offset <= -1; offset++) { + const at = dayAt12(offset); + await prisma.medicationIntakeEvent.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + scheduledFor: at, + takenAt: at, + source: "REMINDER", + autoRecorded: true, + deletedAt: + at.getTime() === removed.getTime() + ? new Date(at.getTime() + 3_600_000) + : null, + }, + }); + } + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const restoredFrom = Date.now(); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + const restored = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID }, + select: { id: true, autoMarkTaken: true, autoMarkTakenSince: true }, + }); + expect(restored.autoMarkTaken).toBe(true); + expect(restored.autoMarkTakenSince!.getTime()).toBeGreaterThanOrEqual( + restoredFrom, + ); + + const { runIntakeAutoTakePass } = + await import("@/lib/jobs/intake-auto-take"); + await runIntakeAutoTakePass(prisma, { nowMs: Date.now() }); + const onRemovedSlot = await prisma.medicationIntakeEvent.count({ + where: { + medicationId: restored.id, + deletedAt: null, + scheduledFor: removed, + }, + }); + expect(onRemovedSlot).toBe(0); + }); +}); + describe("a document kind added after the backup format", () => { it("restores a sick note as a sick note", async () => { const prisma = getPrismaClient(); From 24f06884b3d02c7bb23983f400672b8af51346f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:16:54 +0200 Subject: [PATCH 08/12] fix(reminders): an ended course reads as not reminding The worker expands no slot past endsOn, so it never reminds for a course that has ended, but remindersActive still read true for it. The reminder gate now includes the course end on the person's clock, so the published value, the worker and the admin check make the same decision. --- docs/api/openapi.yaml | 17 +++--- .../notifications/reminder-check/route.ts | 6 +- src/app/api/medications/[id]/route.ts | 5 +- .../reminder/medication-reminder-check.ts | 4 +- .../__tests__/auto-mark-taken.test.ts | 49 +++++++++++++-- src/lib/medications/auto-mark-taken.ts | 23 +++++-- src/lib/medications/list-read.ts | 2 +- src/lib/openapi/routes/medications/schemas.ts | 4 +- .../medication-auto-mark-taken.test.ts | 61 ++++++++++++++++++- 9 files changed, 142 insertions(+), 29 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4c7f73d4d..1754488a8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -47714,10 +47714,10 @@ components: remindersActive: type: boolean description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when - `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not - `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client - that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not - stored." + `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or + later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and + `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local + reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored." nextDueAt: anyOf: - type: string @@ -83031,10 +83031,11 @@ components: - type: "null" remindersActive: description: "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders - for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is - tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every - server-side reminder send. A client that arms local reminders arms them from this, not from - `notificationsEnabled`. Read-only — computed, not stored." + for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has + not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked + (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side + reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. + Read-only — computed, not stored." type: boolean nextDueAt: description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index efdd13547..b25c36d1d 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -185,7 +185,11 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.42.2 — a medication whose doses are recorded automatically // is never reminded, here or in the worker. - if (isOverdue && !hasEvent && sendsMedicationReminders(med)) { + if ( + isOverdue && + !hasEvent && + sendsMedicationReminders(med, { now, timeZone: userTz }) + ) { const doseInfo = schedule.dose ?? med.dose; const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`; diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 13417b366..212716afe 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -280,7 +280,10 @@ export const GET = apiHandler( ...courseFields, nextDueAt: display ? display.at.toISOString() : null, // v1.42.2 — the server's own reminder decision, published resolved. - remindersActive: sendsMedicationReminders(medication), + remindersActive: sendsMedicationReminders(medication, { + now, + timeZone: userTz, + }), nextDueOverdue: display?.overdue ?? false, // v1.39.4 (#1040) — see `resolveIntakeActionability`. ...resolveIntakeActionability( diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 5ae9bbba7..a23ea7a46 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -663,7 +663,7 @@ export async function handleReminderCheck( }), }); - if (sendsMedicationReminders(med)) { + if (sendsMedicationReminders(med, { now, timeZone: userTz })) { const claimed = await claimNotificationEvent(prisma, { recordUserId: med.user.id, eventType: "MEDICATION_REMINDER", @@ -727,7 +727,7 @@ export async function handleReminderCheck( // banner, every other channel the user configured still delivers // — so the gate lives in the dispatcher's APNs branch, which is // the only place that knows whether an APNs send was on the table. - if (sendsMedicationReminders(med)) { + if (sendsMedicationReminders(med, { now, timeZone: userTz })) { const recipientLocale = await resolveJobLocale(med.user.locale); const { title, message } = getPhaseMessage( currentPhase, diff --git a/src/lib/medications/__tests__/auto-mark-taken.test.ts b/src/lib/medications/__tests__/auto-mark-taken.test.ts index a77065d7f..0f04c842b 100644 --- a/src/lib/medications/__tests__/auto-mark-taken.test.ts +++ b/src/lib/medications/__tests__/auto-mark-taken.test.ts @@ -10,25 +10,62 @@ import { describe, expect, it } from "vitest"; import { sendsMedicationReminders } from "../auto-mark-taken"; describe("sendsMedicationReminders", () => { - it("reminds only for an active, tracked, scheduled medication with reminders on and no auto-recording", () => { - for (let bits = 0; bits < 32; bits++) { + const at = { + now: new Date("2026-06-10T10:00:00.000Z"), + timeZone: "Europe/Berlin", + }; + + it("reminds only for an active, tracked, scheduled medication with reminders on, no auto-recording and a course not ended", () => { + for (let bits = 0; bits < 64; bits++) { + const ended = (bits & 32) !== 0; const med = { notificationsEnabled: (bits & 1) !== 0, autoMarkTaken: (bits & 2) !== 0, active: (bits & 4) !== 0, trackIntake: (bits & 8) !== 0, asNeeded: (bits & 16) !== 0, + endsOn: ended ? new Date("2026-06-09T00:00:00.000Z") : null, }; - expect(sendsMedicationReminders(med), JSON.stringify(med)).toBe( + expect(sendsMedicationReminders(med, at), JSON.stringify(med)).toBe( med.notificationsEnabled && !med.autoMarkTaken && med.active && med.trackIntake && - !med.asNeeded, + !med.asNeeded && + !ended, ); } }); + it("a course ending today still reminds; one that ended yesterday does not", () => { + const base = { + notificationsEnabled: true, + autoMarkTaken: false, + active: true, + trackIntake: true, + asNeeded: false, + }; + expect( + sendsMedicationReminders( + { ...base, endsOn: new Date("2026-06-10T00:00:00.000Z") }, + at, + ), + ).toBe(true); + expect( + sendsMedicationReminders( + { ...base, endsOn: new Date("2026-06-09T00:00:00.000Z") }, + at, + ), + ).toBe(false); + // Late on the 10th in Berlin is already the 11th there. + expect( + sendsMedicationReminders( + { ...base, endsOn: new Date("2026-06-10T00:00:00.000Z") }, + { ...at, now: new Date("2026-06-10T22:30:00.000Z") }, + ), + ).toBe(false); + }); + it("is the decision at every gate and on every published DTO", () => { const src = (rel: string) => readFileSync(join(process.cwd(), "src", rel), "utf8"); @@ -36,7 +73,9 @@ describe("sendsMedicationReminders", () => { "lib/jobs/reminder/medication-reminder-check.ts", "app/api/admin/notifications/reminder-check/route.ts", ]) { - expect(src(gate), gate).toMatch(/sendsMedicationReminders\(med\)/); + expect(src(gate), gate).toMatch( + /sendsMedicationReminders\(med, \{ now, timeZone: userTz \}\)/, + ); // No send site falls back to the raw preference. expect(src(gate), gate).not.toMatch( /if \([^)]*med\.notificationsEnabled\)/, diff --git a/src/lib/medications/auto-mark-taken.ts b/src/lib/medications/auto-mark-taken.ts index e1dfe55e2..b8644d5a5 100644 --- a/src/lib/medications/auto-mark-taken.ts +++ b/src/lib/medications/auto-mark-taken.ts @@ -7,28 +7,39 @@ * decision instead of re-deriving it from the raw flags. * * A medication reminds only when the person wants reminders - * (`notificationsEnabled`), it is active (not paused or ended), it has doses - * that fall due (intake tracked, not as-needed), and its doses are not - * recorded automatically (`autoMarkTaken`). The person's own reminder - * preference stays stored untouched: switching auto-mark-taken off restores - * whatever they had chosen. + * (`notificationsEnabled`), it is active (not paused or ended), its course + * has not ended (`endsOn` lies before today on the person's clock: the + * worker expands no slot past it), it has doses that fall due (intake + * tracked, not as-needed), and its doses are not recorded automatically + * (`autoMarkTaken`). A course that has not started yet still reminds once + * it does. The person's own reminder preference stays stored untouched: + * switching auto-mark-taken off restores whatever they had chosen. */ +import { courseStatusAt } from "@/lib/medications/scheduling/recurrence"; + export interface ReminderGateMedication { notificationsEnabled: boolean; autoMarkTaken: boolean; active: boolean; trackIntake: boolean; asNeeded: boolean; + endsOn: Date | null; } export function sendsMedicationReminders( medication: ReminderGateMedication, + at: { now: Date; timeZone: string }, ): boolean { return ( medication.notificationsEnabled && !medication.autoMarkTaken && medication.active && medication.trackIntake && - !medication.asNeeded + !medication.asNeeded && + courseStatusAt( + { startsOn: null, endsOn: medication.endsOn }, + at.now, + at.timeZone, + ) !== "ENDED" ); } diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 3c4fb67b7..32f6301e8 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -338,7 +338,7 @@ export async function buildMedicationsList( (carriesPriorRollingOccurrence ? 1 : 0), nextDueAt: display ? display.at.toISOString() : null, // v1.42.2 — the server's own reminder decision, published resolved. - remindersActive: sendsMedicationReminders(m), + remindersActive: sendsMedicationReminders(m, { now, timeZone: userTz }), nextDueOverdue: display?.overdue ?? false, nextDueScheduleId: display?.scheduleId ?? null, // v1.39.4 (#1040) — whether the card offers taken / skip today, and diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 50b5defa7..99e8a6d36 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -282,7 +282,7 @@ export const medicationResource = z .boolean() .optional() .describe( - "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", + "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", ), nextDueAt: z.iso .datetime({ offset: true }) @@ -375,7 +375,7 @@ export const medicationListEntry = medicationResource remindersActive: z .boolean() .describe( - "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", + "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", ), courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index cb79cb58f..b30f2e907 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -584,14 +584,17 @@ describe("remindersActive — the published value is the gate", () => { active: boolean; trackIntake: boolean; asNeeded: boolean; + endsOn: Date | null; }> = []; - for (let bits = 0; bits < 32; bits++) { + for (let bits = 0; bits < 64; bits++) { combos.push({ notificationsEnabled: (bits & 1) !== 0, autoMarkTaken: (bits & 2) !== 0, active: (bits & 4) !== 0, trackIntake: (bits & 8) !== 0, asNeeded: (bits & 16) !== 0, + // A course that ended the day before. + endsOn: (bits & 32) !== 0 ? new Date("2026-06-09T00:00:00.000Z") : null, }); } const ids: string[] = []; @@ -636,7 +639,10 @@ describe("remindersActive — the published value is the gate", () => { const detail = await import("@/app/api/medications/[id]/route"); let trueCount = 0; for (const [i, id] of ids.entries()) { - const expected = sendsMedicationReminders(combos[i]); + const expected = sendsMedicationReminders(combos[i], { + now: new Date(), + timeZone: TZ, + }); if (expected) trueCount++; expect(listed.get(id), `list combo ${i}`).toBe(expected); const res = await (detail.GET as Handler)( @@ -646,7 +652,56 @@ describe("remindersActive — the published value is the gate", () => { const body = (await res.json()) as { data: { remindersActive: boolean } }; expect(body.data.remindersActive, `detail combo ${i}`).toBe(expected); } - // Exactly one combination reminds: on, not auto, active, tracked, scheduled. + // Exactly one combination reminds: on, not auto, active, tracked, + // scheduled, course not ended. expect(trueCount).toBe(1); }); + + it("a course that has ended reads as not reminding, and the worker sends nothing for it", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const ended = await seedEvening(userId, "Antibiotic", { + autoMarkTakenSince: null, + }); + const running = await seedEvening(userId, "Iron", { + autoMarkTakenSince: null, + }); + await prisma.medication.update({ + where: { id: ended }, + data: { endsOn: new Date("2026-06-09T00:00:00.000Z") }, + }); + for (const tick of [ + "2026-06-10T17:30:00.000Z", + "2026-06-10T19:00:00.000Z", + "2026-06-10T22:00:00.000Z", + ]) { + at(tick); + await handleReminderCheck([]); + } + const dispatchedFor = (medicationId: string) => + vi + .mocked(dispatchNotification) + .mock.calls.filter( + ([arg]) => + (arg as { metadata?: { medicationId?: string } }).metadata + ?.medicationId === medicationId, + ).length; + expect(dispatchedFor(running)).toBeGreaterThan(0); + expect(dispatchedFor(ended)).toBe(0); + + const list = await import("@/app/api/medications/route"); + const res = await (list.GET as Handler)( + new NextRequest("http://localhost/api/medications"), + { params: Promise.resolve({}) }, + ); + const published = new Map( + ( + (await res.json()) as { + data: Array<{ id: string; remindersActive: boolean }>; + } + ).data.map((m) => [m.id, m.remindersActive] as const), + ); + expect(published.get(running)).toBe(true); + expect(published.get(ended)).toBe(false); + }); }); From 88aec7edb6fb0464fe9888f5c2f5a5117a824447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:17:43 +0200 Subject: [PATCH 09/12] fix(search): key the in-flight search by actor and record Two tabs searching two different records (one's own and a shared one) cancelled each other, because a new search aborted any earlier one by the same actor. The slot is now held per actor and record. --- src/app/api/search/route.ts | 2 +- src/lib/search/__tests__/in-flight.test.ts | 26 ++++++++++++++++++++++ src/lib/search/in-flight.ts | 20 ++++++++++------- 3 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 src/lib/search/__tests__/in-flight.test.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 8493f39a7..0ffcd61df 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -90,7 +90,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const slot = beginSearch(actor.id); + const slot = beginSearch(actor.id, user.id); try { const [modules, domainVisible, timezone] = await Promise.all([ resolveModuleMap(user.id), diff --git a/src/lib/search/__tests__/in-flight.test.ts b/src/lib/search/__tests__/in-flight.test.ts new file mode 100644 index 000000000..0806052b9 --- /dev/null +++ b/src/lib/search/__tests__/in-flight.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { SUPERSEDED, beginSearch, searchesInFlight } from "../in-flight"; + +describe("beginSearch", () => { + it("a newer search of the same record stops the one before it", () => { + const first = beginSearch("actor", "record-a"); + const second = beginSearch("actor", "record-a"); + expect(first.signal.aborted).toBe(true); + expect(first.signal.reason).toBe(SUPERSEDED); + expect(second.signal.aborted).toBe(false); + first.end(); + second.end(); + expect(searchesInFlight()).toBe(0); + }); + + it("searches of two different records by one actor run side by side", () => { + const own = beginSearch("actor", "record-a"); + const shared = beginSearch("actor", "record-b"); + expect(own.signal.aborted).toBe(false); + expect(shared.signal.aborted).toBe(false); + own.end(); + shared.end(); + expect(searchesInFlight()).toBe(0); + }); +}); diff --git a/src/lib/search/in-flight.ts b/src/lib/search/in-flight.ts index e58c01ee7..7d0a6c818 100644 --- a/src/lib/search/in-flight.ts +++ b/src/lib/search/in-flight.ts @@ -1,12 +1,14 @@ /** - * One search in flight per actor. + * One search in flight per actor and record. * * The palette searches once per pause in typing, and the client cancels the * previous request when the text changes. A cancelled fetch does not stop the * server, though: without this, every keystroke-pause left a search reading * and decrypting on the server until its own budget ran out, so a fast typist - * held several at once. A new search by the same actor aborts the one before - * it, which stops at its next budget check. + * held several at once. A new search by the same actor in the same record + * aborts the one before it, which stops at its next budget check. Searches + * of two different records (a tab on one's own record, another on a record + * shared with them) are two separate palettes and never cancel each other. * * Per process: on a multi-instance deploy two searches by one actor on two * instances both run, each still bounded by its own time budget and by the @@ -18,20 +20,22 @@ const inFlight = new Map(); export const SUPERSEDED = "search.superseded"; export interface SearchSlot { - /** Aborted when the same actor starts a newer search. */ + /** Aborted when the same actor starts a newer search of the same record. */ signal: AbortSignal; /** Release the slot once the search answered (or failed). */ end: () => void; } -export function beginSearch(actorId: string): SearchSlot { - inFlight.get(actorId)?.abort(SUPERSEDED); +export function beginSearch(actorId: string, recordId: string): SearchSlot { + // Ids are cuids, so the separator cannot occur inside either part. + const key = `${actorId}:${recordId}`; + inFlight.get(key)?.abort(SUPERSEDED); const controller = new AbortController(); - inFlight.set(actorId, controller); + inFlight.set(key, controller); return { signal: controller.signal, end: () => { - if (inFlight.get(actorId) === controller) inFlight.delete(actorId); + if (inFlight.get(key) === controller) inFlight.delete(key); }, }; } From 5bfdc9d04ba6e888c964f0ae1b87cde117b39075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:19:08 +0200 Subject: [PATCH 10/12] fix(jobs): one bad medication never stops the auto-take pass A row whose follow-up threw, or whose stored time zone the runtime refuses, aborted the pass for every other person. Each medication now runs on its own: a failure is named in a warning, counted, and the next one runs; an unusable zone falls back to the default as in other jobs. --- .../jobs/__tests__/intake-auto-take.test.ts | 40 ++++++ src/lib/jobs/intake-auto-take.ts | 120 +++++++++++------- .../jobs/reminder/medication-maintenance.ts | 1 + 3 files changed, 116 insertions(+), 45 deletions(-) diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index a2daeada1..dbcb3e0a9 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -989,3 +989,43 @@ describe("runIntakeAutoTakePass — nearby takes of another slot", () => { ).toEqual({ kind: "leave", reason: "taken_nearby" }); }); }); + +// ── one bad row never stops the pass ────────────────────────────────── + +describe("runIntakeAutoTakePass — per-medication isolation", () => { + it("a medication whose follow-up throws is skipped, the next one still runs", async () => { + const broken = medication({ + id: "m-broken", + autoMarkTakenSince: new Date("2026-06-10T00:00:00Z"), + }); + const fine = medication({ + id: "m-fine", + autoMarkTakenSince: new Date("2026-06-10T00:00:00Z"), + }); + const rows: FakeRow[] = []; + vi.mocked(consumeForIntake).mockImplementationOnce(async () => { + throw new Error("inventory unavailable"); + }); + const result = await runIntakeAutoTakePass( + fakeClient([broken, fine], rows), + { nowMs: Date.parse("2026-06-10T17:20:00Z") }, + ); + expect(result.failedCount).toBe(1); + expect( + rows.filter((r) => r.medicationId === "m-fine" && r.autoRecorded), + ).toHaveLength(1); + }); + + it("a stored zone the runtime refuses falls back instead of throwing", async () => { + const med = { + ...medication({ autoMarkTakenSince: new Date("2026-06-10T00:00:00Z") }), + user: { timezone: "Mars/Olympus_Mons" }, + }; + const rows: FakeRow[] = []; + const result = await runIntakeAutoTakePass(fakeClient([med], rows), { + nowMs: Date.parse("2026-06-10T20:00:00Z"), + }); + expect(result.failedCount).toBe(0); + expect(result.recordedCount).toBe(1); + }); +}); diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 6df52c701..e8d93e765 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -86,7 +86,8 @@ import { dayKeyForScheduledFor, recomputeMedicationComplianceForEvent, } from "@/lib/rollups/medication-compliance-rollups"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, validTimezoneOr } from "@/lib/tz/format"; +import { getEvent } from "@/lib/logging/context"; export { autoTakeFloor, @@ -126,6 +127,8 @@ export interface IntakeAutoTakeResult { recordedCount: number; /** Of those, pending placeholders converted in place. */ convertedCount: number; + /** Medications whose share of the pass failed and was skipped. */ + failedCount: number; /** Users whose caches were dropped because a dose was recorded. */ userIds: string[]; } @@ -289,56 +292,27 @@ export async function runIntakeAutoTakePass( let recordedCount = 0; let convertedCount = 0; + let failedCount = 0; const touchedUsers = new Set(); for (const medication of medications) { if (!medication.autoMarkTakenSince || medication.schedules.length === 0) { continue; } - const userTz = medication.user.timezone || DEFAULT_TIMEZONE; - const floor = autoTakeFloor({ - autoMarkTakenSince: medication.autoMarkTakenSince, - createdAt: medication.createdAt, - now, - }); - const rolling = medication.schedules.some( - (s) => s.rollingIntervalDays !== null, - ); - - const outcome = await autoTakeMedication(prisma, { - medication, - userTz, - floor, - now, - rolling, - }); - recordedCount += outcome.recorded.length; - convertedCount += outcome.converted; - if (outcome.recorded.length === 0) continue; - - touchedUsers.add(medication.userId); - // The same follow-ups the intake routes run after a manual take. - const days = new Set(); - for (const slot of outcome.recorded) { - await consumeForIntake({ - client: prisma, - userId: medication.userId, - medicationId: medication.id, - eventId: slot.eventId, - intakeAt: slot.at, - }); - const dayKey = dayKeyForScheduledFor(slot.at, userTz); - if (days.has(dayKey)) continue; - days.add(dayKey); - await recomputeMedicationComplianceForEvent({ - userId: medication.userId, - medicationId: medication.id, - scheduledFor: slot.at, - tz: userTz, - }); - } - if (medication.oneShot) { - await reconcileOneShotState(prisma, medication.id, medication.userId); + // One bad row never stops the pass for everyone else: it is named in a + // warning and the next medication runs. + try { + const outcome = await autoTakeOne(prisma, medication, now); + recordedCount += outcome.recorded; + convertedCount += outcome.converted; + if (outcome.recorded > 0) touchedUsers.add(medication.userId); + } catch (err) { + failedCount += 1; + getEvent()?.addWarning( + `intake-auto-take failed for medication ${medication.id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); } } @@ -353,10 +327,66 @@ export async function runIntakeAutoTakePass( medicationCount: medications.length, recordedCount, convertedCount, + failedCount, userIds: [...touchedUsers], }; } +/** One medication's share of a pass: record its due slots, then follow up. */ +async function autoTakeOne( + prisma: AutoTakeClient, + medication: AutoTakeMedication, + now: Date, +): Promise<{ recorded: number; converted: number }> { + // A stored zone the runtime refuses (or a bare offset) falls back to the + // default, as in every other job, rather than throwing mid-pass. + const userTz = validTimezoneOr(medication.user.timezone, DEFAULT_TIMEZONE); + const floor = autoTakeFloor({ + autoMarkTakenSince: medication.autoMarkTakenSince!, + createdAt: medication.createdAt, + now, + }); + const rolling = medication.schedules.some( + (s) => s.rollingIntervalDays !== null, + ); + + const outcome = await autoTakeMedication(prisma, { + medication, + userTz, + floor, + now, + rolling, + }); + if (outcome.recorded.length === 0) { + return { recorded: 0, converted: outcome.converted }; + } + + // The same follow-ups the intake routes run after a manual take. + const days = new Set(); + for (const slot of outcome.recorded) { + await consumeForIntake({ + client: prisma, + userId: medication.userId, + medicationId: medication.id, + eventId: slot.eventId, + intakeAt: slot.at, + }); + const dayKey = dayKeyForScheduledFor(slot.at, userTz); + if (days.has(dayKey)) continue; + days.add(dayKey); + await recomputeMedicationComplianceForEvent({ + userId: medication.userId, + medicationId: medication.id, + scheduledFor: slot.at, + tz: userTz, + }); + } + if (medication.oneShot) { + await reconcileOneShotState(prisma, medication.id, medication.userId); + } + return { recorded: outcome.recorded.length, converted: outcome.converted }; +} + async function autoTakeMedication( prisma: AutoTakeClient, input: { diff --git a/src/lib/jobs/reminder/medication-maintenance.ts b/src/lib/jobs/reminder/medication-maintenance.ts index f75b5d042..0d70064ac 100644 --- a/src/lib/jobs/reminder/medication-maintenance.ts +++ b/src/lib/jobs/reminder/medication-maintenance.ts @@ -99,6 +99,7 @@ export async function handleIntakeAutoTake( evt.addMeta("intake_auto_take_medications", result.medicationCount); evt.addMeta("intake_auto_take_recorded", result.recordedCount); evt.addMeta("intake_auto_take_converted", result.convertedCount); + evt.addMeta("intake_auto_take_failed", result.failedCount); return jobDone({ intake_auto_take_medications: result.medicationCount, intake_auto_take_recorded: result.recordedCount, From ab104ab633a6c622ac57d4be7a6cea101b4b4636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:23:57 +0200 Subject: [PATCH 11/12] refactor(medications): spread the intake-tracking filter at each auto-take reader The shared eligibility filter hid TRACKED_INTAKE_WHERE from the structural guard that checks every schedule and intake reader honours the switch. Each reader now spreads it itself. --- src/lib/jobs/intake-auto-take.ts | 3 ++- src/lib/medications/auto-take.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index e8d93e765..6915edc2c 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -55,6 +55,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { autoTakeEligibleWhere, autoTakeFloor, @@ -265,7 +266,7 @@ export async function runIntakeAutoTakePass( const medications = (await prisma.medication.findMany({ // Opted in, active, not paused, not as-needed, intake tracked (a // record-only medication expects no dose, so none is recorded). - where: autoTakeEligibleWhere(now), + where: { ...autoTakeEligibleWhere(now), ...TRACKED_INTAKE_WHERE }, select: { id: true, userId: true, diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts index e02e87a3e..271f0b3b8 100644 --- a/src/lib/medications/auto-take.ts +++ b/src/lib/medications/auto-take.ts @@ -245,7 +245,11 @@ export function autoTakeRowWindow(floor: Date, now: Date) { }; } -/** The medications the auto-take job works on (see `intake-auto-take.ts`). */ +/** + * The medications the auto-take job works on (see `intake-auto-take.ts`), + * apart from the intake-tracking switch, which each caller spreads itself + * (`TRACKED_INTAKE_WHERE`) so the structural guard sees every reader ask. + */ export function autoTakeEligibleWhere(now: Date) { return { autoMarkTaken: true, @@ -253,7 +257,6 @@ export function autoTakeEligibleWhere(now: Date) { active: true, pausedAt: null, asNeeded: false, - ...TRACKED_INTAKE_WHERE, }; } @@ -353,6 +356,7 @@ export async function loadAutoTakeRollingAnchors( userId: input.userId, id: { in: [...input.medicationIds] }, ...autoTakeEligibleWhere(now), + ...TRACKED_INTAKE_WHERE, schedules: { some: { rollingIntervalDays: { not: null } } }, }, select: { From a54b47428e93b7c20b3712eba9298631ab91e8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:25:05 +0200 Subject: [PATCH 12/12] test(timeline): match the zoom each journey opened with The day-strip journey opens /timeline?zoom=all, so closing the day returns there, not to bare /timeline. An unreadable range now opens on the default window, which for this record is three months. --- e2e/timeline.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index f691d0417..39b4d40fb 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -558,12 +558,13 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy await popover.locator('[data-slot="timeline-range-apply"]').click(); await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`)); - // A parameter it cannot read opens the whole record. + // A parameter it cannot read opens the same default as no zoom: this + // record has entries in the last three months, so three months. await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01"); - await expect(zoom("all")).toHaveAttribute("aria-checked", "true", { + await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", { timeout: 20_000, }); - await expect(desktop).toHaveAttribute("data-zoom", "all"); + await expect(desktop).toHaveAttribute("data-zoom", "quarter"); }); async function setTimelineModuleOn(page: Page) { @@ -985,7 +986,8 @@ test("the day's strip stays at the right edge, opens and closes the day, and nev await expect(strip).toHaveAttribute("data-state", "closed"); await expect(toggle).toBeFocused(); expect(await where()).toEqual(closed); - await expect(page).toHaveURL(/\/timeline$/); + // Closing the day takes `?day=` out and leaves the zoom it opened with. + await expect(page).toHaveURL(/\/timeline\?zoom=all$/); } // Both themes, open at 1440.