mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'v1422/review2' into release/v1.42.2
This commit is contained in:
commit
e833bd0949
33 files changed
+1994
-247
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user