Merge branch 'v1422/review2' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 15:33:51 +02:00
commit e833bd0949
33 files changed
+1994 -247

No files matched your search

+9 -8
View File
@@ -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
+57 -4
View File
@@ -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.
+26 -2
View File
@@ -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<TimelineMockLog> {
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) =>
@@ -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}`;
+27 -3
View File
@@ -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,
},
}),
+7 -1
View File
@@ -491,7 +491,13 @@ async function postMedication(request: NextRequest): Promise<Response> {
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({
+1 -1
View File
@@ -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),
@@ -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<typeof import("../timeline-dates")>();
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(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<TimelineView />
</I18nProvider>
</QueryClientProvider>,
);
}
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"');
});
});
@@ -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",
@@ -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"
>
<p id="timeline-chart-help" className="sr-only">
+60 -4
View File
@@ -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.
+83 -15
View File
@@ -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<ChronicleGrouping>("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() {
}
/>
</div>
) : showEverything && (isEmpty || !window_) ? (
// A window with nothing in it while the record has older entries:
// one step to all of them.
<EmptyState
data-slot="timeline-window-empty"
title={t("timeline.selection.empty")}
action={
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setZoom("all")}
data-slot="timeline-show-everything"
>
{t("timeline.range.clear")}
</Button>
}
/>
) : isEmpty || !data || !window_ ? (
<EmptyState
title={t("timeline.empty.title")}
+2
View File
@@ -41,9 +41,11 @@ export function useTimeline(
from: string | null,
to: string | null,
values: readonly string[],
enabled = true,
) {
return useQuery({
queryKey: queryKeys.timeline(zoom, from, to, values.join(",")),
enabled,
queryFn: () =>
apiGet<TimelineResponse>(timelinePath(zoom, from, to, values)),
// Moving between zoom levels keeps the last answer on screen until the
+12 -1
View File
@@ -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,
});
+13 -6
View File
@@ -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,
@@ -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<typeof medication>[], 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);
});
});
+125 -162
View File
@@ -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<SlotBand, "earlyStart" | "overdueEnd"> | 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<string>();
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<string>();
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<string>();
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,
@@ -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,
@@ -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,
+14 -1
View File
@@ -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,
});
@@ -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\)/,
@@ -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 },
});
});
});
+17 -6
View File
@@ -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"
);
}
+415
View File
@@ -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<SlotBand, "earlyStart" | "overdueEnd"> | null,
otherSlots: ReadonlySet<number> = 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<number>,
): 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<void> {
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<PrismaClient, "medication" | "medicationIntakeEvent">,
input: {
userId: string;
medicationIds: readonly string[];
now: Date;
userTz: string;
lastIntakeAt: (medicationId: string) => Date | null;
},
): Promise<(medicationId: string) => Date | null> {
const out = new Map<string, Date | null>();
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<string, Date>();
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;
}
+24 -5
View File
@@ -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<ProjectedWindow> {
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;
}
+7 -1
View File
@@ -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,
+12 -2
View File
@@ -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
@@ -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.
*
@@ -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"])
@@ -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);
});
});
+12 -8
View File
@@ -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<string, AbortController>();
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);
},
};
}
+122 -1
View File
@@ -3620,6 +3620,16 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"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();
@@ -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<string, unknown>;
},
);
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);
});
});