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/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index e9ec9e2a3..39b4d40fb 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -79,6 +79,57 @@ 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("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, }) => { @@ -507,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) { @@ -934,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. diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index c99ebda10..512b90c8f 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -822,11 +822,19 @@ 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. + * + * `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; dataFrom?: string } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => @@ -843,7 +851,19 @@ 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, + 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 = @@ -854,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/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 dc5d768f8..212716afe 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -32,6 +32,10 @@ import { toRevisionPayloadEntry, } from "@/lib/medications/scheduling/schedule-eras"; import type { Prisma } from "@/generated/prisma/client"; +import { + loadAutoTakeRollingAnchors, + moveAutoTakeFloorToNow, +} from "@/lib/medications/auto-take"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, @@ -179,6 +183,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 +203,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, }); @@ -266,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( @@ -1011,6 +1028,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 @@ -1117,6 +1137,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: { @@ -1125,6 +1148,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/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/components/timeline/__tests__/timeline-default-zoom.test.tsx b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx new file mode 100644 index 000000000..2ebf69f22 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-default-zoom.test.tsx @@ -0,0 +1,179 @@ +/** + * 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"; +/** 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, + 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"; + young = false; + 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("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(); + 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..0aa30d565 100644 --- a/src/components/timeline/__tests__/timeline-url.test.ts +++ b/src/components/timeline/__tests__/timeline-url.test.ts @@ -3,7 +3,13 @@ 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, + resolveDefaultWindow, + timelineSearch, +} from "../timeline-url"; const TODAY = "2026-10-07"; const parse = (search: string) => @@ -26,7 +32,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 +42,45 @@ 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("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 7e95b26b8..149a0fd82 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,67 @@ 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"; + +/** 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 + * 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..feeb92d60 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,7 +70,10 @@ import { RangeFields } from "./range-fields"; import { RangeSheet, ZoomControl } from "./range-picker"; import { clampRange, + DEFAULT_ZOOM, + hasDataBeforeWindow, parseTimelineUrl, + resolveDefaultWindow, timelineSearch, type TimelineRange, } from "./timeline-url"; @@ -131,6 +135,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 +162,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 +229,49 @@ 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, ); + // 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 && + !opensOnAll && + askedWindow && + asked.data && + !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; + const timeline = opensOnAll ? everything : asked; const readiness = useTimelineReadiness(); const lifeEvents = useLifeEvents(canAddLifeEvent); @@ -300,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, @@ -308,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( @@ -329,11 +378,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 +486,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 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/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/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index 82198030f..dbcb3e0a9 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"; @@ -483,6 +485,7 @@ interface FakeRow { deletedAt: Date | null; source: string; syncVersion: number; + attributionSource?: "AUTO" | "USER_PIN"; } function fakeClient(meds: ReturnType[], rows: FakeRow[]) { @@ -710,3 +713,319 @@ 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()]); + }); +}); + +// ── 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" }); + }); +}); + +// ── 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 1f08876e9..6915edc2c 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 @@ -48,6 +56,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 { @@ -69,7 +87,16 @@ 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, + 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 +110,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 @@ -104,6 +128,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[]; } @@ -131,95 +157,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 +264,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), ...TRACKED_INTAKE_WHERE }, select: { id: true, userId: true, @@ -362,56 +293,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) + }`, + ); } } @@ -426,10 +328,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: { @@ -451,20 +409,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, @@ -473,6 +418,7 @@ async function autoTakeMedication( skipped: true, autoMissed: true, deletedAt: true, + attributionSource: true, }, }); @@ -492,12 +438,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,9 +464,10 @@ async function autoTakeMedication( userTz, floor, now, - lastIntakeAt, + lastIntakeAt: anchor, }); + const expectedSlots = new Set(bands.keys()); let recordedRolling = false; for (const slot of slots) { handled.add(slot.at.getTime()); @@ -518,6 +478,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/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, 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/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/__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/__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-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/auto-take.ts b/src/lib/medications/auto-take.ts new file mode 100644 index 000000000..271f0b3b8 --- /dev/null +++ b/src/lib/medications/auto-take.ts @@ -0,0 +1,415 @@ +/** + * 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; + /** `USER_PIN` when the person bound the row to its `scheduledFor`. */ + attributionSource?: "AUTO" | "USER_PIN"; +} + +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. + * + * 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); + 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 && + !belongsElsewhere(r, t, otherSlots), + ); + 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 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 }[]; +} + +/** + * 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`), + * 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, + autoMarkTakenSince: { not: null, lte: now }, + active: true, + pausedAt: null, + asNeeded: false, + }; +} + +/** + * 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, + 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), + ...TRACKED_INTAKE_WHERE, + 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/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/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index cbbfbd456..32f6301e8 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, }); @@ -328,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/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/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/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); }, }; } 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(); diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index d82a6ceb1..b30f2e907 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -377,6 +377,196 @@ 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, + }); + }); +}); + +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(); + 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", + ]); }); }); @@ -394,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[] = []; @@ -446,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)( @@ -456,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); + }); });