fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body

The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 10:43:32 +02:00
1 parent ea29e9cf1b
commit aaecd141ee
53 files changed
+2550 -202

No files matched your search

+5 -2
View File
@@ -2118,7 +2118,7 @@ paths:
summary: List medications for the calling user
description: Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row
carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the
count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per
count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per
user; writes flush the cache.
responses:
"200":
@@ -4890,6 +4890,7 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
takenAt:
description: When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes.
Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
@@ -8362,7 +8363,9 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
description: Number of intake events scheduled for today (user-local day window).
description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an
explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this
against the passed-dose count, and a pending mint must not read as covered."
required:
- id
- name
+1
View File
@@ -760,6 +760,7 @@
"withoutNotification": "Ohne Benachrichtigung",
"pausedSince": "Pausiert seit",
"takeNow": "Jetzt einnehmen",
"takenEarly": "Bereits vorzeitig eingenommen",
"overdue": "Überfällig",
"veryOverdue": "Stark überfällig",
"compliance7d": "Therapietreue (7 Tage)",
+1
View File
@@ -760,6 +760,7 @@
"withoutNotification": "Without notification",
"pausedSince": "Paused since",
"takeNow": "Take now",
"takenEarly": "Already taken early",
"overdue": "Overdue",
"veryOverdue": "Very overdue",
"compliance7d": "Adherence (7 days)",
+1
View File
@@ -760,6 +760,7 @@
"withoutNotification": "Sin notificación",
"pausedSince": "En pausa desde",
"takeNow": "Tomar ahora",
"takenEarly": "Ya tomado con antelación",
"overdue": "Vencida",
"veryOverdue": "Muy vencida",
"compliance7d": "Cumplimiento 7 días",
+1
View File
@@ -760,6 +760,7 @@
"withoutNotification": "Sans notification",
"pausedSince": "En pause depuis",
"takeNow": "Prendre maintenant",
"takenEarly": "Déjà pris en avance",
"overdue": "En retard",
"veryOverdue": "Très en retard",
"compliance7d": "Observance 7 jours",
+1
View File
@@ -760,6 +760,7 @@
"withoutNotification": "Senza notifica",
"pausedSince": "In pausa dal",
"takeNow": "Assumi ora",
"takenEarly": "Già assunto in anticipo",
"overdue": "In ritardo",
"veryOverdue": "Molto in ritardo",
"compliance7d": "Aderenza 7 giorni",
+1
View File
@@ -760,6 +760,7 @@
"withoutNotification": "Bez powiadomienia",
"pausedSince": "Wstrzymane od",
"takeNow": "Przyjmij teraz",
"takenEarly": "Już przyjęto wcześniej",
"overdue": "Po terminie",
"veryOverdue": "Mocno po terminie",
"compliance7d": "Przestrzeganie 7 dni",
@@ -50,6 +50,7 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -536,6 +537,10 @@ const handler = apiHandler(
},
});
// v1.16.9 — the restore replaced every row the owner's cached
// payloads were built on; hard-evict all of that user's buckets.
invalidateUserData(ownerId);
const response: RestoreResponse = {
restored: true,
summary,
+5
View File
@@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -105,5 +106,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
details: result,
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
});
@@ -12,7 +12,9 @@ vi.mock("@/lib/db", () => ({
},
medicationIntakeEvent: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
medication: {
findFirst: vi.fn(),
@@ -38,6 +40,9 @@ vi.mock("@/lib/app-settings", () => ({
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -55,6 +60,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/ingest/medication", {
@@ -145,3 +151,156 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.9)", () => {
// The maintainer's live bug: a daily 09:00 med, the worker pre-minted the
// pending REMINDER row at 09:00 Berlin, the user ingested the take at
// 08:42 Berlin via the external API. The bare create anchored a SECOND
// row at 08:42 and left the 09:00 pending row open — reminder kept
// firing, ledger showed ad-hoc + later missed, today feed said "due".
const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST
const TAKEN_0842_BERLIN = new Date("2026-06-10T06:42:00.000Z"); // 08:42 CEST
const scheduleRow = {
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const pendingReminderRow = {
id: "evt-pending-0900",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: SLOT_0900_BERLIN,
source: "REMINDER",
createdAt: new Date("2026-06-10T05:00:00.000Z"),
};
function wireHappyPath() {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
// First findFirst (by name) returns the bare medication; the slot
// resolver's findFirst (selects schedules) returns the projection.
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
where: Record<string, unknown>;
}) => {
if (args.where && "name" in args.where) {
return Promise.resolve({ id: "med-1", name: "Metformin" });
}
return Promise.resolve({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules: [scheduleRow],
scheduleRevisions: [],
});
}) as never);
// Idempotency probe → no replay.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
// The slot's live rows: the worker-minted pending REMINDER row.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingReminderRow,
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: {
where: { id: string };
data: Record<string, unknown>;
}) =>
Promise.resolve({
...pendingReminderRow,
...args.data,
syncVersion: 2,
})) as never);
}
it("converges an 08:42 take onto the pending 09:00 REMINDER row (no second row)", async () => {
wireHappyPath();
const res = await POST(
postReq({
medicationName: "Metformin",
takenAt: TAKEN_0842_BERLIN.toISOString(),
idempotencyKey: "ha-ingest-1",
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { id: string } };
// The pending REMINDER row was updated in place — never a bare create.
expect(body.data.id).toBe("evt-pending-0900");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date; skipped: boolean; autoMissed?: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt?.getTime()).toBe(TAKEN_0842_BERLIN.getTime());
expect(update.data.skipped).toBe(false);
// A recorded take clears a prior cron auto-miss.
expect(update.data.autoMissed).toBe(false);
});
it("hard-evicts the user's medication caches after the write", async () => {
wireHappyPath();
await POST(
postReq({
medicationName: "Metformin",
takenAt: TAKEN_0842_BERLIN.toISOString(),
idempotencyKey: "ha-ingest-2",
}),
);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("keeps the standalone ad-hoc contract for an off-window take", async () => {
wireHappyPath();
// 14:30 Berlin — outside the 09:00 band and its late tail.
const offWindow = new Date("2026-06-10T12:30:00.000Z");
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
id: "evt-adhoc",
scheduledFor: offWindow,
takenAt: offWindow,
skipped: false,
source: "API",
} as never);
const res = await POST(
postReq({
medicationName: "Metformin",
takenAt: offWindow.toISOString(),
idempotencyKey: "ha-ingest-3",
}),
);
expect(res.status).toBe(201);
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
const create = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0] as { data: { scheduledFor: Date; takenAt: Date } };
// Ad-hoc rows anchor on the intake instant (`scheduledFor = takenAt`).
expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime());
expect(create.data.takenAt.getTime()).toBe(offWindow.getTime());
});
});
+73 -14
View File
@@ -15,6 +15,12 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -159,17 +165,64 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("API endpoint for this medication is disabled", 403);
}
const event = await prisma.medicationIntakeEvent.create({
data: {
// The external ingest path runs without a User record in scope;
// resolve the timezone once — band attribution and the compliance
// recompute below both anchor on the user's local day.
const ingestUser = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: { timezone: true },
});
const userTz = ingestUser?.timezone || DEFAULT_TIMEZONE;
const effectiveTakenAt = takenAt ?? new Date();
// v1.16.9 — attribute the take to its scheduled slot by window-band
// membership, the SAME engine the per-medication intake route and the
// read ledger consume. The bare create this replaces anchored every
// ingest at `scheduledFor = takenAt`, leaving the slot's pending
// REMINDER row open: the reminder kept firing, the ledger showed an
// ad-hoc take PLUS a missed slot, and the today feed still said "due".
const attribution = await resolveSlotForWriteByBand({
userId: apiToken.userId,
medicationId: medication.id,
userTz,
takenAt: effectiveTakenAt,
});
let event;
if (attribution.slotInstant) {
// Scheduled dose — converge onto the one canonical slot row (the
// pending REMINDER row when one exists) through the shared upsert:
// H1 deterministic selection, C2 no-downgrade, P2002-safe create.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: takenAt ?? new Date(),
takenAt: takenAt ?? new Date(),
canonicalSlot: attribution.slotInstant,
takenAt: effectiveTakenAt,
skipped: false,
source: "API",
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey,
},
});
createSource: "API",
attributionSource: "AUTO",
});
event = applied.row;
} else {
// Ad-hoc / PRN / off-window — standalone row under the documented
// contract (`scheduledFor = takenAt`).
event = await prisma.medicationIntakeEvent.create({
data: {
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: effectiveTakenAt,
takenAt: effectiveTakenAt,
skipped: false,
source: "API",
idempotencyKey,
},
});
}
await auditLog("medication.ingest.external", {
userId: apiToken.userId,
@@ -181,15 +234,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
annotate({ meta: { medication_id: medication.id, event_id: event.id } });
annotate({
meta: {
medication_id: medication.id,
event_id: event.id,
slot_resolved: attribution.slotInstant !== null,
},
});
// v1.16.9 — the ingest is an interactive dose record; the next read
// (today feed, card pill, compliance heatmap) must reflect it rather
// than wait out the TTL.
invalidateUserMedications(apiToken.userId, { evict: true });
// v1.4.39 W-MED — refresh the compliance rollup for the ingested
// event's user-day. The external ingest path runs without a User
// record in scope; resolve the tz once for the recompute.
const ingestUser = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: { timezone: true },
});
// event's user-day.
await recomputeMedicationComplianceForEvent({
userId: apiToken.userId,
medicationId: medication.id,
@@ -264,6 +264,11 @@ export const PUT = apiHandler(
idempotencyKey: null,
createSource: "WEB",
attributionSource,
// v1.16.9 — a slot move is a re-binding, not a new dose: the
// original row's recorded injection site and dose override ride
// along onto the converged row instead of being tombstoned away.
injectionSite: event.injectionSite,
doseTaken: event.doseTaken,
});
updated = applied.row;
}
+23 -8
View File
@@ -179,14 +179,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// Skips carry no binding decision (undefined → column untouched/default).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (skipped) {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: scheduledFor !== undefined,
isTakenWrite: false,
});
if (scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band membership
// on the skip moment, like a take does. The defaulted-now snap only
// reached the tight reminder-grace window, so a skip tapped past it
// landed as an orphan ad-hoc row while the slot's pending REMINDER
// row stayed open to auto-miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
} else {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: true,
isTakenWrite: false,
});
}
} else if (forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
+5 -2
View File
@@ -25,6 +25,7 @@ import type { Prisma } from "@/generated/prisma/client";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import {
dayKeyForScheduledFor,
@@ -137,7 +138,9 @@ export const GET = apiHandler(
{ autoMissed: true },
],
},
select: { scheduledFor: true },
// v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor
// === takenAt`) cannot ±6h-resolve a different slot.
select: { scheduledFor: true, takenAt: true },
});
// v1.16.4 — current-era floor: the open-overdue search mints from
// the LIVE schedule rows, so it must not reach past the newest
@@ -161,7 +164,7 @@ export const GET = apiHandler(
now,
userTz: user.timezone || "Europe/Berlin",
lastIntakeAt: lastIntake?.takenAt ?? null,
resolvedSlots: resolvedRows.map((r) => r.scheduledFor),
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
eraStart: latestRevision?.validUntil ?? null,
});
@@ -39,6 +39,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
@@ -249,6 +250,10 @@ export const PATCH = apiHandler(
// The corrected era re-segments history; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({
@@ -321,6 +326,10 @@ export const DELETE = apiHandler(
// History re-segments without the era; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({ deleted: true });
@@ -40,6 +40,7 @@ import {
type ScheduleRevisionEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
@@ -280,6 +281,10 @@ export const POST = apiHandler(
// The new era re-segments history the compliance rollups already
// pre-aggregated; refresh them asynchronously. Best-effort like
// every other rollup write-hook.
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess(
@@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
medicationIntakeEvent: {
groupBy: vi.fn().mockResolvedValue([]),
findMany: vi.fn().mockResolvedValue([]),
},
medicationScheduleRevision: {
groupBy: vi.fn().mockResolvedValue([]),
},
medicationCategoryAssignment: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -59,9 +66,11 @@ vi.mock("next/headers", () => ({
})),
}));
import { POST } from "../route";
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { getUserTodayBounds } from "@/lib/tz/local-day";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -353,3 +362,52 @@ describe("POST /api/medications — v1.5.4 clinical category extension", () => {
expect(res.status).toBe(422);
});
});
describe("GET /api/medications — todayEventCount counts only actioned rows (v1.16.9)", () => {
it("filters the today-count groupBy to taken-or-skipped rows", async () => {
// The dashboard projector mints a pending row per slot of the day, so
// an unfiltered count covered every passed dose after any dashboard
// visit and the cards' overdue pill went dark. The count read must
// exclude pending rows.
// (`vi.resetAllMocks()` in beforeEach clears the factory defaults, so
// re-wire the pass-through + empty result sets here.)
vi.mocked(cachedSwr).mockImplementation((async (
_c: unknown,
_k: string,
f: () => Promise<unknown>,
) => f()) as never);
vi.mocked(getUserTodayBounds).mockReturnValue({
start: new Date("2026-06-10T00:00:00Z"),
end: new Date("2026-06-10T23:59:59Z"),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
// The handler body takes no parameters; `apiHandler` still receives
// the request through the wrapper's rest args.
const res = await (
GET as unknown as (req: NextRequest) => Promise<Response>
)(new NextRequest("http://localhost/api/medications"));
expect(res.status).toBe(200);
const groupByCalls = vi.mocked(prisma.medicationIntakeEvent.groupBy).mock
.calls;
const countCall = groupByCalls.find(
(c) => (c[0] as { _count?: unknown })._count !== undefined,
);
expect(countCall).toBeDefined();
const where = (
countCall![0] as {
where: { OR?: unknown };
}
).where;
expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]);
});
});
+19 -4
View File
@@ -59,6 +59,7 @@ import {
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
boundedTakenAtSchema,
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
@@ -74,10 +75,9 @@ const bulkEntrySchema = z.object({
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
// 5-year floor), matching the single-intake create + edit paths.
takenAt: boundedTakenAtSchema.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
@@ -313,6 +313,21 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
canonicalSlot = attribution.slotInstant;
attributionSource = "AUTO";
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band
// membership on the skip moment, exactly like a take does. The
// defaulted-now snap below only reaches the tight reminder-grace
// window, so a skip tapped 20 minutes past the dose time inserted
// an orphan ad-hoc skip row while the slot's pending REMINDER row
// stayed open and later auto-missed — the user's deliberate skip
// recorded as a miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
} else {
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
// iOS posts the reminder actions here (source API); without this
+20 -1
View File
@@ -560,7 +560,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
injectionSite: resolvedInjectionSite,
// v1.16.9 — carry the recorded site / dose override across the
// tombstone + re-insert: a slot move is a re-binding, not a new
// dose, so the original row's documentation must survive when the
// toggle write itself carries none.
injectionSite:
resolvedInjectionSite ??
(existing.injectionSite as InjectionSiteValue | null),
doseTaken: existing.doseTaken,
});
updated = applied.row;
await prisma.medication.update({
@@ -611,6 +618,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
scheduledFor: existing.scheduledFor,
tz: userTzForHook,
});
// v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant,
// possibly a different local day. Recompute the target day too, or the
// source day self-corrects while the destination keeps its stale tuple.
const movedTo = updated?.scheduledFor;
if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) {
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: movedTo,
tz: userTzForHook,
});
}
return apiSuccess(updated);
});
+18 -4
View File
@@ -18,6 +18,8 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
@@ -73,10 +75,17 @@ async function buildMedicationsList(
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
// v1.16.9 — count only ACTIONED rows (taken or skipped). The
// dashboard projector mints pending rows for every slot of the
// day, so counting all rows made `todayEventCount` cover every
// passed dose after any dashboard visit — and the cards'
// overdue-pill suppression (`todayEventCount < passedDoseCount`)
// went dark nondeterministically for genuinely overdue doses.
where: {
userId,
deletedAt: null,
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
OR: [{ takenAt: { not: null } }, { skipped: true }],
},
_count: { id: true },
}),
@@ -91,7 +100,11 @@ async function buildMedicationsList(
{ autoMissed: true },
],
},
select: { medicationId: true, scheduledFor: true },
// v1.16.9 — `takenAt` rides along so the ad-hoc shape
// (`scheduledFor === takenAt`) is detectable: such a row must not
// ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
// genuinely-due 20:00 dose).
select: { medicationId: true, scheduledFor: true, takenAt: true },
}),
// v1.16.4 — current-era floor per medication: the newest revision's
// `validUntil` is where the LIVE schedule rows became valid. The
@@ -106,11 +119,12 @@ async function buildMedicationsList(
}),
]);
const resolvedSlotsByMedId = new Map<string, Date[]>();
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
for (const e of resolvedEvents) {
const mark = toResolvedSlotMark(e);
const list = resolvedSlotsByMedId.get(e.medicationId);
if (list) list.push(e.scheduledFor);
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
if (list) list.push(mark);
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
+5
View File
@@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -119,5 +120,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "settings.data.clear" }, meta: result });
// v1.16.9 — every cached payload was built on the rows just deleted;
// hard-evict the user's buckets so no surface serves pre-wipe data.
invalidateUserData(userId);
return apiSuccess({ cleared: true, ...result });
});
@@ -18,9 +18,12 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
telegramReminderMessage: {
deleteMany: vi.fn(),
findFirst: vi.fn(),
},
telegramScheduledDeletion: {
createMany: vi.fn(),
@@ -52,8 +55,17 @@ vi.mock("@/lib/logging/context", () => ({
})),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
answerTelegramCallbackQuery,
deleteMessage,
@@ -67,10 +79,40 @@ const TG_USER = {
id: "user-1",
telegramBotToken: "ENC(token-blob)",
locale: "en",
timezone: "Europe/Berlin",
};
const MEDICATION = { id: "med-1", name: "Ramipril" };
/**
* The slot-attribution load (`loadAttributeMedication`) selects the full
* schedule projection (distinguishable by `select.schedules`); every other
* `medication.findFirst` in the webhook wants the bare id/name shape. The
* default projection carries no schedules, so the generic dispatch tests
* exercise the ad-hoc (standalone create) path unchanged.
*/
function wireMedicationFindFirst(
schedules: unknown[] = [],
scheduleRevisions: unknown[] = [],
) {
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
select?: Record<string, unknown>;
}) => {
if (args?.select && "schedules" in args.select) {
return Promise.resolve({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules,
scheduleRevisions,
});
}
return Promise.resolve(MEDICATION);
}) as never);
}
beforeEach(() => {
vi.resetAllMocks();
process.env.TELEGRAM_WEBHOOK_SECRET = "sekret";
@@ -80,14 +122,18 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never);
wireMedicationFindFirst();
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({
count: 0,
} as never);
@@ -185,15 +231,7 @@ describe("Telegram webhook — callback dispatch", () => {
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// $transaction is invoked with [create-intake, update-medication]. Inspect
// its argument list for both writes.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown;
expect(Array.isArray(txOps)).toBe(true);
// The route calls prisma.medicationIntakeEvent.create + medication.update
// synchronously while building the transaction array, so these mocks
// record the arguments the way it built them.
// No schedules wired → the take is ad-hoc and inserts standalone.
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0];
@@ -226,7 +264,7 @@ describe("Telegram webhook — callback dispatch", () => {
null,
);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
// Second call: existing-row lookup hits → no second write.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
@@ -234,7 +272,7 @@ describe("Telegram webhook — callback dispatch", () => {
} as never);
const res2 = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res2.status).toBe(200);
expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // unchanged
});
it("'snooze:<medId>:60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => {
@@ -599,6 +637,159 @@ describe("Telegram webhook — text-message dispatch", () => {
});
});
describe("Telegram webhook — slot convergence (v1.16.9)", () => {
// Daily 09:00 Berlin med; the worker pre-minted the pending REMINDER row
// at the canonical slot instant. A Telegram take/skip must converge onto
// that row instead of inserting a second row anchored at `now` — the old
// shape left the pending row open to auto-miss, punishing compliance for
// a dose the user explicitly confirmed.
const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST
const NOW_0903_BERLIN = new Date("2026-06-10T07:03:00.000Z"); // 09:03 CEST
const scheduleRow = {
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const pendingReminderRow = {
id: "evt-pending-0900",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: SLOT_0900_BERLIN,
source: "REMINDER",
createdAt: new Date("2026-06-10T05:00:00.000Z"),
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW_0903_BERLIN);
wireMedicationFindFirst([scheduleRow]);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingReminderRow,
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: {
data: Record<string, unknown>;
}) =>
Promise.resolve({
...pendingReminderRow,
...args.data,
syncVersion: 2,
})) as never);
});
afterEach(() => {
vi.useRealTimers();
});
it("a take converges onto the pending REMINDER row via the reminder's own slot", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// The pending row was updated in place — never a duplicate insert.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date; skipped: boolean; autoMissed?: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt?.getTime()).toBe(NOW_0903_BERLIN.getTime());
expect(update.data.skipped).toBe(false);
expect(update.data.autoMissed).toBe(false);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("a take converges via band attribution when no reminder row matches", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// 09:03 sits inside the 09:00 band → same convergence, no second row.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
expect(
(
vi.mocked(prisma.medicationIntakeEvent.update).mock.calls[0][0] as {
where: { id: string };
}
).where.id,
).toBe("evt-pending-0900");
});
it("a skip converges onto the pending REMINDER row as skipped (no orphan + no miss)", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date | null; skipped: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt).toBeNull();
expect(update.data.skipped).toBe(true);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("an exotic-zone reminder slot reconstructs on the correct local day", async () => {
// Pacific/Kiritimati (UTC+14): 09:00 on 2026-06-10 local is
// 2026-06-09T19:00Z — a naive UTC-noon reference would land a day off.
const slotKiritimati = new Date("2026-06-09T19:00:00.000Z");
const nowKiritimati = new Date("2026-06-09T19:05:00.000Z");
vi.setSystemTime(nowKiritimati);
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...TG_USER,
timezone: "Pacific/Kiritimati",
} as never);
const pendingRow = {
...pendingReminderRow,
scheduledFor: slotKiritimati,
};
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingRow,
] as never);
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as { where: { id: string } };
expect(update.where.id).toBe("evt-pending-0900");
});
});
describe("Telegram webhook — GET verification", () => {
it("returns 200 with a valid header secret", async () => {
const req = new NextRequest("http://localhost/api/telegram/webhook", {
+199 -22
View File
@@ -14,6 +14,15 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { locales, type Locale } from "@/lib/i18n/config";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface TelegramUpdate {
update_id: number;
@@ -123,12 +132,88 @@ async function findTelegramUser(chatId: string) {
});
}
/**
* v1.16.9 — the slot a Telegram action should converge onto.
*
* The reminder context knows its own slot: the dispatcher records every
* reminder message as a `TelegramReminderMessage` row carrying the
* local `date` + `timeOfDay` of the dose it fired for. When the action
* callback carries the chat/message ids, that row names the EXACT slot
* — preferred over band attribution, which would orphan a confirmation
* tapped hours after the reminder (band closed → ad-hoc) even though
* the user is answering a specific dose prompt.
*
* Returns the canonical band anchor (validated against the schedule's
* real slots), or `null` when no reminder row matches / the instant is
* not a real slot — the caller then falls back to band attribution.
*/
async function resolveReminderSlot(input: {
userId: string;
medicationId: string;
userTz: string;
chatId: string;
messageId: number;
}): Promise<Date | null> {
const reminder = await prisma.telegramReminderMessage.findFirst({
where: {
medicationId: input.medicationId,
chatId: input.chatId,
messageId: input.messageId,
},
select: { date: true, timeOfDay: true },
});
if (!reminder) return null;
const instant = reminderLocalInstant(
reminder.date,
reminder.timeOfDay,
input.userTz,
);
if (!instant) return null;
// Validate against the medication's real band anchors so a stale or
// malformed reminder row can never park a dose on a non-slot instant.
return resolveForcedSlotForWrite({
userId: input.userId,
medicationId: input.medicationId,
userTz: input.userTz,
slotInstant: instant,
});
}
/**
* Mint the UTC instant of local `timeOfDay` on the local calendar day
* `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either
* side of UTC noon so the result is correct in every zone offset, then
* derives the DST-correct instant via `localHmAsUtc`.
*/
function reminderLocalInstant(
date: string,
timeOfDay: string,
tz: string,
): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) {
return null;
}
const [hour, minute] = timeOfDay.split(":").map(Number);
const base = new Date(`${date}T12:00:00.000Z`);
if (Number.isNaN(base.getTime())) return null;
for (const shiftDays of [0, -1, 1]) {
const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000);
const parts = wallClockInTz(ref, tz);
const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(
parts.day,
).padStart(2, "0")}`;
if (localDay === date) return localHmAsUtc(ref, tz, hour, minute);
}
return null;
}
async function markMedicationTaken(
userId: string,
medicationId: string,
idempotencyKey: string,
locale: Locale,
userTz: string | null,
reminderRef?: { chatId: string; messageId: number },
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
const { t } = getServerTranslator(locale);
const medication = await prisma.medication.findFirst({
@@ -146,29 +231,77 @@ async function markMedicationTaken(
let scheduledFor: Date | null = null;
if (!existing) {
scheduledFor = new Date();
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
const takenAt = new Date();
const tz = userTz || DEFAULT_TIMEZONE;
// v1.16.9 — converge onto the slot's canonical row instead of bare-
// creating a second row anchored at `now`. The old shape left the
// worker-minted pending REMINDER row open: the slot later auto-missed
// and compliance punished a dose the user explicitly confirmed.
// Prefer the reminder's own slot (the message names the dose it fired
// for); fall back to band attribution on the tap instant.
let canonicalSlot: Date | null = reminderRef
? await resolveReminderSlot({
userId,
medicationId: medication.id,
userTz: tz,
chatId: reminderRef.chatId,
messageId: reminderRef.messageId,
})
: null;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId,
medicationId: medication.id,
userTz: tz,
takenAt,
});
canonicalSlot = attribution.slotInstant;
}
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId,
medicationId: medication.id,
canonicalSlot,
takenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey,
createSource: "REMINDER",
attributionSource: "AUTO",
});
scheduledFor = applied.row.scheduledFor;
} else {
// Ad-hoc / PRN — standalone row under the documented contract
// (`scheduledFor = takenAt`).
const created = await prisma.medicationIntakeEvent.create({
data: {
userId,
medicationId: medication.id,
scheduledFor,
takenAt: scheduledFor,
scheduledFor: takenAt,
takenAt,
skipped: false,
source: "REMINDER",
idempotencyKey,
},
}),
prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: null },
}),
]);
});
scheduledFor = created.scheduledFor;
}
await prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: null },
});
// The confirmation must reflect on the next read (today feed, card
// pill, compliance) rather than wait out the cache TTL.
invalidateUserMedications(userId, { evict: true });
}
// v1.4.39 W-MED — refresh the compliance rollup for the
// affected day so the next read after the cache miss reflects the
// new dose. Only fires when the create-path landed a new row.
// new dose. Only fires when the write landed a row.
if (scheduledFor) {
await recomputeMedicationComplianceForEvent({
userId,
@@ -225,6 +358,7 @@ async function handleCallback(update: TelegramUpdate) {
idempotencyKey,
locale,
user.timezone,
messageId !== undefined ? { chatId, messageId } : undefined,
);
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
if (messageId) {
@@ -313,25 +447,68 @@ async function handleCallback(update: TelegramUpdate) {
if (!existing) {
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
skippedScheduledFor = new Date();
const skipMoment = new Date();
const tz = user.timezone || DEFAULT_TIMEZONE;
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
// v1.16.9 — a Telegram skip answers a specific dose prompt, so it
// must converge onto the slot's pending row like a take does. The
// old shape inserted a second row anchored at `now` and left the
// pending REMINDER row open to auto-miss — the user's deliberate
// skip recorded as a miss AND an orphan. Prefer the reminder's own
// slot; fall back to the band the skip moment falls in.
let canonicalSlot: Date | null =
messageId !== undefined
? await resolveReminderSlot({
userId: user.id,
medicationId: medication.id,
userTz: tz,
chatId,
messageId,
})
: null;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: medication.id,
userTz: tz,
takenAt: skipMoment,
});
canonicalSlot = attribution.slotInstant;
}
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: medication.id,
canonicalSlot,
takenAt: null,
skipped: true,
isExplicitTaken: false,
isExplicitSkip: true,
idempotencyKey,
createSource: "REMINDER",
});
skippedScheduledFor = applied.row.scheduledFor;
} else {
const created = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: medication.id,
scheduledFor: skippedScheduledFor,
scheduledFor: skipMoment,
takenAt: null,
skipped: true,
source: "REMINDER",
idempotencyKey,
},
}),
prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: endOfDay },
}),
]);
});
skippedScheduledFor = created.scheduledFor;
}
await prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: endOfDay },
});
invalidateUserMedications(user.id, { evict: true });
}
// v1.4.39 W-MED — refresh the compliance rollup for the skipped
@@ -116,6 +116,65 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => {
expect(result).toBe("b");
});
it("does not pre-select a medication whose server next-due is in the future (v1.16.9)", () => {
// The band alone reads "in window" at 10:00 Berlin, but the server
// says the next unresolved dose is tomorrow (a rolling cadence whose
// dose was taken) — the gate must keep the heuristic off it.
const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin (UTC+2)
const result = pickDefaultMedicationId(
[
makeMed("a", { name: "Aaa" }),
makeMed("b", {
name: "Bbb",
nextDueAt: "2026-05-13T06:00:00.000Z", // tomorrow
nextDueOverdue: false,
schedules: [
{
id: "s1",
windowStart: "08:00",
windowEnd: "11:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
}),
],
now,
);
// Nothing genuinely due → alphabetical fallback ("Aaa").
expect(result).toBe("a");
});
it("does not pre-select a day-scale medication already taken early in its period (v1.16.9)", () => {
// Weekly Tuesday med (2026-05-12 is a Tuesday), taken two days ago.
const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin
const result = pickDefaultMedicationId(
[
makeMed("a", { name: "Aaa" }),
makeMed("b", {
name: "Bbb",
lastTakenAt: "2026-05-10T07:00:00.000Z", // Sunday — early take
nextDueAt: "2026-05-12T07:00:00.000Z", // today's slot (unresolved)
nextDueOverdue: true,
schedules: [
{
id: "s1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: "2",
timesOfDay: ["09:00"],
label: null,
dose: null,
},
],
}),
],
now,
);
expect(result).toBe("a");
});
it("falls back to the alphabetical-first active medication when nothing is due", () => {
// 03:00 Berlin time: no schedules overlap. The helper should
// alphabetically sort and pick the leading id, "ramipril".
@@ -80,6 +80,15 @@ export interface MedicationOption {
schedules: Schedule[];
lastTakenAt: string | null;
todayEventCount: number | null;
/**
* v1.16.9 — the server display-due verdict from `GET /api/medications`
* (`nextDueAt` + `nextDueOverdue`). Threaded into the default pick so
* the quick-add gates its "due now" heuristic exactly like the cards:
* a rolling cadence whose next dose is days away must not pre-select.
* Optional for older mocks; absent keeps the legacy band-only pick.
*/
nextDueAt?: string | null;
nextDueOverdue?: boolean;
}
interface MedicationIntakeQuickAddProps {
@@ -127,6 +136,10 @@ export function pickDefaultMedicationId(
const nowBerlin = toBerlinDate(now);
const due = actives.find((m) => {
// v1.16.9 — the same server display-due gate the cards apply: a
// future (non-overdue) next-due suppresses the overdue tiers and a
// day-scale dose taken early in its period must not pre-select.
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin,
@@ -135,8 +148,14 @@ export function pickDefaultMedicationId(
active: true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
nextDue:
m.nextDueAt === undefined
? undefined
: Number.isFinite(nextDueMs)
? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true }
: null,
});
return status.status !== null;
return status.status !== null && !status.takenEarly;
});
if (due) return due.id;
@@ -71,6 +71,8 @@ export interface MedicationCardBodyProps {
status: Exclude<MedicationWindowStatus, null>;
windowStart: string;
windowEnd: string;
/** v1.16.9 — day-scale early-take context (see the pill). */
takenEarly?: boolean;
} | null;
/**
@@ -178,7 +180,17 @@ export function MedicationCardBody({
overdue tier arrives with the compliance query, and a line
popping in late used to shift every row below it (CLS). */}
<div className="min-h-5">
{overdueLabel ? (
{windowStatus?.takenEarly ? (
// v1.16.9 — taken-early context outranks the overdue line: a
// day-scale dose already taken earlier in its period must never
// escalate into a take prompt (double-dose risk).
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
takenEarly
/>
) : overdueLabel ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
@@ -9,6 +9,14 @@ interface MedicationStatusPillProps {
status: Exclude<MedicationWindowStatus, null>;
windowStart: string;
windowEnd: string;
/**
* v1.16.9 — day-scale early-take context. When true the dose was
* already taken earlier in the cadence period (a weekly shot days
* before its slot day), so the pill renders a calm "taken early" note
* instead of the take-now / overdue prompt — prompting a full take on
* the slot day would be a double-dose prompt.
*/
takenEarly?: boolean;
}
/**
@@ -23,9 +31,25 @@ export function MedicationStatusPill({
status,
windowStart,
windowEnd,
takenEarly = false,
}: MedicationStatusPillProps) {
const { t, locale } = useTranslations();
if (takenEarly) {
return (
<p className="text-sm">
<span className="text-muted-foreground inline-flex items-center gap-1 font-medium">
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("medications.takenEarly")}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
</p>
);
}
return (
<p className="text-sm">
<span
@@ -23,9 +23,10 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
reduceCurrentWindowStatus,
toBerlinDate,
toZonedDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { useAuth } from "@/hooks/use-auth";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
/**
@@ -166,6 +167,10 @@ export function Glp1MedicationCard({
}: Glp1MedicationCardProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
// v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the
// last-resort fallback for logged-out mounts and legacy fixtures.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
@@ -288,7 +293,7 @@ export function Glp1MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toBerlinDate(now);
const nowBerlin = toZonedDate(now, userTz);
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
const currentWindowStatus = reduceCurrentWindowStatus({
@@ -299,6 +304,7 @@ export function Glp1MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
tz: userTz,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next injection is days away can never paint an overdue
// pill today. `undefined` (older payloads / fixtures) keeps legacy
@@ -319,6 +325,7 @@ export function Glp1MedicationCard({
currentWindowStatus,
nextDueAt: medication.nextDueAt,
now,
timeZone: userTz,
});
// v1.15.9 — the recent-injection list now feeds ONLY the post-dose
@@ -439,6 +446,7 @@ export function Glp1MedicationCard({
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
takenEarly: currentWindowStatus.takenEarly,
}
: null
}
+13 -4
View File
@@ -13,7 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import {
reduceCurrentWindowStatus,
toBerlinDate,
toZonedDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -26,6 +26,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import { useAuth } from "@/hooks/use-auth";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
interface Schedule {
@@ -104,6 +105,11 @@ export function MedicationCard({
}: MedicationCardProps) {
const queryClient = useQueryClient();
const { t, locale } = useTranslations();
// v1.16.9 — the card reasons in the PROFILE timezone (the snapshot the
// session carries); Berlin stays the last-resort fallback so logged-out
// mounts and legacy fixtures behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
@@ -199,7 +205,7 @@ export function MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toBerlinDate(new Date());
const nowBerlin = toZonedDate(new Date(), userTz);
// v1.8.4 — the next-due instant comes from the server (`nextDueAt`,
// computed by the canonical recurrence engine anchored on the last
// intake). The day label below derives from it; the window-range /
@@ -224,6 +230,7 @@ export function MedicationCard({
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
tz: userTz,
// v1.16.6 — gate the pill on the server display-due so a rolling
// cadence whose next dose is tomorrow can never paint an overdue pill
// today. `undefined` (older payloads / fixtures) keeps legacy behaviour.
@@ -244,6 +251,7 @@ export function MedicationCard({
currentWindowStatus,
nextDueAt: medication.nextDueAt,
now: new Date(),
timeZone: userTz,
});
function formatLastTakenAt(value: string): string {
@@ -251,7 +259,7 @@ export function MedicationCard({
// string-comparable for the today / yesterday / older bucketing below.
// The actual user-facing display goes through formatTime / formatDateTime.
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
timeZone: userTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -314,7 +322,7 @@ export function MedicationCard({
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toBerlinDate(new Date(nextAt));
const nextDate = toZonedDate(new Date(nextAt), userTz);
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
@@ -381,6 +389,7 @@ export function MedicationCard({
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
takenEarly: currentWindowStatus.takenEarly,
}
: null
}
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
@@ -67,6 +68,7 @@ const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
medication: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
@@ -170,12 +172,51 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => {
score: 3 + (i % 3),
})),
);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue(
Array.from({ length: 360 }, (_, i) => ({
scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
skipped: false,
})),
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
// v1.16.9 — the compliance timeline derives from the band-engine
// ledger now; the snapshot loads medications (schedules + window
// events) instead of raw intake rows.
// Branch on the caller: the GLP-1 block filters on
// `treatmentClass: "GLP1"` (no GLP-1 therapy in this fixture); the
// compliance loader reads every medication.
const complianceMedRows = [
{
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
schedules: [
{
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
},
],
scheduleRevisions: [],
intakeEvents: Array.from({ length: 360 }, (_, i) => ({
scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
skipped: false,
autoMissed: false,
attributionSource: "AUTO",
})),
},
];
prismaMock.medication.findMany.mockImplementation(
(args: { where?: { treatmentClass?: string } }) =>
Promise.resolve(
args?.where?.treatmentClass === "GLP1" ? [] : complianceMedRows,
),
);
prismaMock.workout.findMany.mockResolvedValue(
Array.from({ length: 80 }, (_, i) => ({
@@ -295,6 +336,7 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => {
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
@@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
@@ -26,6 +27,7 @@ const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
medication: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
@@ -60,6 +62,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
__resetCoachSnapshotCacheForTests();
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default
// null = legacy defaults so the existing test fixtures stay
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
@@ -36,6 +37,7 @@ const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
medication: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
@@ -68,6 +70,7 @@ describe("buildCoachSnapshot", () => {
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply
// per-user excludeMetrics. Default null = use legacy defaults.
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
+96 -26
View File
@@ -35,6 +35,13 @@ import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
buildComplianceLedgerRows,
buildComplianceMedicationContext,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import type { BaselineProfile } from "@/lib/insights/derived";
import {
CLUSTER_PRIORITY,
@@ -753,12 +760,46 @@ async function buildCoachSnapshotImpl(
select: { moodLoggedAt: true, score: true },
})
: null;
const intakeRowsPromise = wantsCompliance
? prisma.medicationIntakeEvent.findMany({
// Tombstoned intake rows must never reach the Coach snapshot.
where: { userId, deletedAt: null, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: { scheduledFor: true, takenAt: true, skipped: true },
// v1.16.9 — the adherence timeline derives from the LEDGER tally (the
// same band engine the compliance % + dose history consume), not from a
// raw intake-row count. The raw count read a worker-minted pending row
// as "not taken" (today's later doses dragged the day's rate down all
// morning) and double-counted cross-source duplicate rows on one slot.
// Load each medication's schedules + eras + window events so the ledger
// can be reconstructed per medication.
const complianceMedsPromise = wantsCompliance
? prisma.medication.findMany({
where: { userId },
select: {
id: true,
startsOn: true,
endsOn: true,
oneShot: true,
createdAt: true,
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
intakeEvents: {
// Tombstoned intake rows must never reach the Coach snapshot.
where: { deletedAt: null, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: {
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
attributionSource: true,
},
},
},
})
: null;
const sleepRowsPromise = sources.has("sleep")
@@ -826,7 +867,7 @@ async function buildCoachSnapshotImpl(
const [
moodRows,
intakeRows,
complianceMeds,
sleepRows,
workoutRows,
glp1Block,
@@ -836,7 +877,7 @@ async function buildCoachSnapshotImpl(
cycleBlock,
] = await Promise.all([
moodRowsPromise,
intakeRowsPromise,
complianceMedsPromise,
sleepRowsPromise,
workoutRowsPromise,
glp1BlockPromise,
@@ -939,31 +980,60 @@ async function buildCoachSnapshotImpl(
registerBlock("mood", "mood");
}
if (wantsCompliance && intakeRows) {
// Medication compliance lives outside the structured features
// today — the legacy Coach surface labelled it as "general"
// provenance only. v1.4.20.1 ships a per-day adherence row built
// from the intake-event log so the Coach can answer "did I miss
// my dose on Tuesday?" without inventing the schedule. The read is
// issued in parallel above.
if (intakeRows.length > 0) {
// Per-day adherence rate within the recent window. Older days
// collapse into a single weekly bucket.
const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff);
const olderRows = intakeRows.filter((r) => r.scheduledFor < recentCutoff);
if (wantsCompliance && complianceMeds) {
// Medication compliance lives outside the structured features today —
// the legacy Coach surface labelled it as "general" provenance only.
// v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from
// the band-engine LEDGER (the same expansion the compliance % and the
// dose-history view consume): a slot counts against the rate only once
// it is genuinely missed, a pending/upcoming slot never reads as "not
// taken", deliberate skips and ad-hoc takes stay out of the
// denominator, and cross-source duplicate rows collapse onto one slot.
const ledgerRows: DoseHistoryRow[] = [];
for (const med of complianceMeds) {
if (med.schedules.length === 0) continue;
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(med.intakeEvents),
userTz,
);
ledgerRows.push(
...buildComplianceLedgerRows(
med.intakeEvents,
med.schedules,
ctx,
cutoff,
now,
now,
),
);
}
// Countable rows: taken (on-time or late) or genuinely missed. The
// pending / upcoming / skipped / ad-hoc rows carry no adherence signal.
const countable = ledgerRows.filter(
(r) =>
r.status === "taken_on_time" ||
r.status === "taken_late" ||
r.status === "missed",
);
if (countable.length > 0) {
const recent = countable.filter((r) => r.at >= recentCutoff);
const olderRows = countable.filter((r) => r.at < recentCutoff);
const isTaken = (r: DoseHistoryRow) =>
r.status === "taken_on_time" || r.status === "taken_late";
const recentByDay = new Map<
string,
{ date: Date; total: number; taken: number }
>();
for (const r of recent) {
const key = tzDayKey(r.scheduledFor, userTz);
const key = tzDayKey(r.at, userTz);
const e = recentByDay.get(key) ?? {
date: r.scheduledFor,
date: r.at,
total: 0,
taken: 0,
};
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
if (isTaken(r)) e.taken += 1;
recentByDay.set(key, e);
}
const recentRows = Array.from(recentByDay.entries())
@@ -977,10 +1047,10 @@ async function buildCoachSnapshotImpl(
.sort((a, b) => a.date.localeCompare(b.date));
const olderByWeek = new Map<string, { taken: number; total: number }>();
for (const r of olderRows) {
const key = isoWeekKey(r.scheduledFor, userTz);
const key = isoWeekKey(r.at, userTz);
const e = olderByWeek.get(key) ?? { taken: 0, total: 0 };
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
if (isTaken(r)) e.taken += 1;
olderByWeek.set(key, e);
}
const weeklyRows = Array.from(olderByWeek.entries())
@@ -995,7 +1065,7 @@ async function buildCoachSnapshotImpl(
timeline: { recent: recentRows, weekly: weeklyRows },
};
metrics.add("compliance");
counts.compliance = intakeRows.length;
counts.compliance = countable.length;
registerBlock("compliance", "compliance");
} else {
// v1.7.0 — toggled-on cluster with no rows. Annotate so the
+12 -1
View File
@@ -8,7 +8,10 @@ import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { isValidTimezone } from "@/lib/tz/format";
import { encrypt } from "@/lib/crypto";
import { invalidateUserProfile } from "@/lib/cache/invalidate";
import {
invalidateUserMedications,
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { z } from "zod/v4";
const extendedProfileSchema = profileSchema.extend({
@@ -168,6 +171,14 @@ export async function applyProfileUpdate(
invalidateUserProfile(userId);
}
// v1.16.9 — the medication payloads (list next-due, today counts, the
// compliance day buckets) are computed in the user's timezone, but the
// list cache key carries only the userId. A timezone change must evict
// the bucket or the next read serves the previous zone's day grid.
if (data.timezone !== undefined) {
invalidateUserMedications(userId, { evict: true });
}
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
+159
View File
@@ -451,6 +451,165 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => {
});
});
describe("ServerCache invalidation vs in-flight builders", () => {
it("a read right after an evict does NOT join the pre-write builder", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let releaseFirst: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) {
return new Promise<string>((r) => {
releaseFirst = r;
});
}
return Promise.resolve("post-write");
});
// Builder 1 starts (reads pre-write state) and is still in flight…
const first = cache.wrap("u1|list", builder);
// …when a write evicts the key.
cache.delete("u1|list");
// The next read must start a FRESH build, not join the stale one.
const second = await cache.wrap("u1|list", builder);
expect(second.outcome).toBe("miss");
expect(second.value).toBe("post-write");
expect(builder).toHaveBeenCalledTimes(2);
// The detached pre-write builder resolves AFTER the fresh one — its
// commit must be discarded, never overwriting the post-write entry.
releaseFirst!("pre-write");
const firstResult = await first;
expect(firstResult.value).toBe("pre-write");
expect(cache.get("u1|list")).toBe("post-write");
});
it("deleteByPrefix fences an in-flight builder the same way", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let release: ((v: string) => void) | null = null;
const builder = vi.fn(
() =>
new Promise<string>((r) => {
release = r;
}),
);
const inFlight = cache.wrap("u1|compliance", builder);
cache.deleteByPrefix("u1|");
release!("pre-write");
await inFlight;
// The pre-write result never entered the cache.
expect(cache.get("u1|compliance")).toBeNull();
});
it("an SWR refresh that completes after an evict does not re-cache the pre-write body", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
let releaseRefresh: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) return Promise.resolve("v1");
if (calls === 2) {
return new Promise<string>((r) => {
releaseRefresh = r;
});
}
return Promise.resolve("post-write");
});
await cache.wrapSwr("u1|list", builder);
vi.advanceTimersByTime(1500);
// Stale read kicks off the detached background refresh (pre-write).
const stale = await cache.wrapSwr("u1|list", builder);
expect(stale.outcome).toBe("stale");
// A write evicts the bucket while the refresh is still in flight…
cache.deleteByPrefix("u1|");
// …then the pre-write refresh completes. Without the fence it would
// re-insert the pre-write body as FRESH for the full TTL.
releaseRefresh!("pre-write");
await vi.runAllTimersAsync();
expect(cache.get("u1|list")).toBeNull();
const next = await cache.wrapSwr("u1|list", builder);
expect(next.outcome).toBe("miss");
expect(next.value).toBe("post-write");
});
it("markStaleByPrefix discards an in-flight refresh and keeps SWR semantics", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
let releaseRefresh: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) return Promise.resolve("v1");
if (calls === 2) {
return new Promise<string>((r) => {
releaseRefresh = r;
});
}
return Promise.resolve("post-write");
});
await cache.wrapSwr("u1|k", builder);
vi.advanceTimersByTime(1500);
const stale = await cache.wrapSwr("u1|k", builder);
expect(stale.outcome).toBe("stale");
// Background write marks the bucket stale mid-refresh; the pre-write
// refresh result must not land as a fresh entry.
cache.markStaleByPrefix("u1|");
releaseRefresh!("pre-write");
await vi.runAllTimersAsync();
// SWR semantics hold: the prior value still serves stale and a NEW
// (post-write) recompute is kicked off.
const next = await cache.wrapSwr("u1|k", builder);
expect(next.outcome).toBe("stale");
expect(next.value).toBe("v1");
await vi.runAllTimersAsync();
expect(builder).toHaveBeenCalledTimes(3);
const warm = await cache.wrapSwr("u1|k", builder);
expect(warm.outcome).toBe("hit");
expect(warm.value).toBe("post-write");
});
it("invalidation between two stampeding readers gives the later reader fresh data", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let release: ((v: string) => void) | null = null;
let calls = 0;
const builder = vi.fn(() => {
calls += 1;
if (calls === 1) {
return new Promise<string>((r) => {
release = r;
});
}
return Promise.resolve("fresh");
});
const a = cache.wrap("k", builder); // pre-write build in flight
cache.delete("k"); // write
const b = cache.wrap("k", builder); // post-write read → fresh build
release!("stale");
const [ra, rb] = await Promise.all([a, b]);
expect(ra.value).toBe("stale"); // joined its own (pre-write) build
expect(rb.value).toBe("fresh");
expect(cache.get("k")).toBe("fresh");
});
});
describe("global cache registry", () => {
it("exposes the eight blueprint caches as module-scope singletons", () => {
expect(caches.analytics).toBeDefined();
+32
View File
@@ -246,6 +246,38 @@ export function invalidateUserInsightsLayout(userId: string): void {
caches.insightsLayout.deleteByPrefix(userId);
}
/**
* v1.16.9 — full per-user cache eviction for the wipe / restore paths.
* A data wipe or a backup restore replaces (or removes) every row the
* cached payloads were built on, so every per-user bucket hard-evicts —
* a marked-stale SWR cell would keep serving the pre-wipe body.
*/
export function invalidateUserData(userId: string): void {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.medications.deleteByPrefix(userId);
caches.medicationCompliance.deleteByPrefix(`${userId}|`);
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
caches.insightsTargets.deleteByPrefix(userId);
caches.insightsDerived.deleteByPrefix(`${userId}|`);
caches.moodAnalytics.deleteByPrefix(userId);
caches.moodInsights.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
caches.dashboardWidgets.deleteByPrefix(userId);
caches.insightsLayout.deleteByPrefix(userId);
}
/**
* v1.16.9 — bucket-wide clear for the admin all-user wipe. Every cache
* empties; the next read per user is a cold build over the (now empty /
* restored) tables. Acceptable for an operation this rare.
*/
export function invalidateAllCaches(): void {
for (const cache of Object.values(caches)) {
cache.deleteByPrefix("");
}
}
/**
* Global eviction of the bug-report-status cache.
*
+96 -26
View File
@@ -67,12 +67,30 @@ export interface ServerCacheStats {
readonly stampedes: number;
}
/**
* v1.16.9 — single-flight slot with invalidation fencing. A builder that
* was in flight when an invalidation (`delete` / `deleteByPrefix` /
* `markStaleByPrefix`) hit its key must NOT commit its result: the build
* read pre-write data, and committing it after the eviction would
* re-cache the pre-write body as fresh for the full TTL. The slot is the
* fence — invalidation flips `invalidated` and detaches the slot from
* `pending`, so (a) the builder's `.then` skips the `set()` and (b) the
* next read starts a fresh build instead of joining the stale one.
* Callers already awaiting the detached promise still receive its value
* (equivalent to having read a moment before the write); it just never
* enters the cache.
*/
interface PendingBuild<T> {
promise: Promise<T>;
invalidated: boolean;
}
export class ServerCache<T> {
private readonly map = new Map<
string,
{ expiresAt: number; staleUntil: number; value: T }
>();
private readonly pending = new Map<string, Promise<T>>();
private readonly pending = new Map<string, PendingBuild<T>>();
private hits = 0;
private misses = 0;
private evictions = 0;
@@ -80,6 +98,59 @@ export class ServerCache<T> {
constructor(private readonly opts: ServerCacheOptions) {}
/**
* Fence + detach the in-flight builder for one key (no-op when none).
* Part of every invalidation op — see `PendingBuild`.
*/
private detachPending(key: string): void {
const slot = this.pending.get(key);
if (slot) {
slot.invalidated = true;
this.pending.delete(key);
}
}
/** Fence + detach every in-flight builder under `prefix`. */
private detachPendingByPrefix(prefix: string): void {
for (const [key, slot] of this.pending) {
if (key.startsWith(prefix)) {
slot.invalidated = true;
this.pending.delete(key);
}
}
}
/**
* Register a fenced single-flight build for `key`. The commit only
* lands when no invalidation hit the key while the builder ran; the
* `finally` only clears the slot it owns (an invalidation may already
* have detached it and a newer build may occupy the key).
*/
private startBuild(
key: string,
builder: () => Promise<T>,
ttlMsOverride?: number,
): PendingBuild<T> {
const slot: PendingBuild<T> = {
promise: undefined as unknown as Promise<T>,
invalidated: false,
};
slot.promise = builder()
.then((value) => {
if (!slot.invalidated) {
this.set(key, value, ttlMsOverride);
}
return value;
})
.finally(() => {
if (this.pending.get(key) === slot) {
this.pending.delete(key);
}
});
this.pending.set(key, slot);
return slot;
}
/**
* Read a key. Returns null on miss, expired entry, or absent entry.
* Side effect: an expired entry is removed; a live entry is
@@ -178,6 +249,12 @@ export class ServerCache<T> {
* behaviour).
*/
markStaleByPrefix(prefix: string): number {
// v1.16.9 — fence in-flight builders too: a refresh that started
// before the write would otherwise complete after this mark and
// re-cache the pre-write body as FRESH for the full TTL. Detaching
// keeps the SWR contract — the marked entry still serves stale, and
// the next read kicks off a fresh (post-write) recompute.
this.detachPendingByPrefix(prefix);
let marked = 0;
const now = Date.now();
for (const [key, entry] of this.map) {
@@ -191,6 +268,9 @@ export class ServerCache<T> {
/** Single-key eviction. Returns true when the key was present. */
delete(key: string): boolean {
// v1.16.9 — fence the in-flight builder so a pre-write build can
// neither be joined by post-evict reads nor commit its result.
this.detachPending(key);
return this.map.delete(key);
}
@@ -203,6 +283,8 @@ export class ServerCache<T> {
* possible cache-key variant the user might have warmed.
*/
deleteByPrefix(prefix: string): number {
// v1.16.9 — fence in-flight builders under the prefix (see `delete`).
this.detachPendingByPrefix(prefix);
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
@@ -238,21 +320,13 @@ export class ServerCache<T> {
const inFlight = this.pending.get(key);
if (inFlight) {
this.stampedes += 1;
const value = await inFlight;
const value = await inFlight.promise;
return { value, outcome: "stampede" };
}
this.misses += 1;
const promise = builder()
.then((value) => {
this.set(key, value, ttlMsOverride);
return value;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
const value = await promise;
const slot = this.startBuild(key, builder, ttlMsOverride);
const value = await slot.promise;
return { value, outcome: "miss" };
}
@@ -288,20 +362,11 @@ export class ServerCache<T> {
// single-flight slot so concurrent stale reads share one rebuild.
this.hits += 1;
if (!this.pending.has(key)) {
const refresh = builder()
.then((value) => {
this.set(key, value, ttlMsOverride);
return value;
})
.catch((err) => {
// The stale value already went out; don't crash the worker.
// Re-throw so the join path of any awaiting caller sees it.
throw err;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, refresh);
// v1.16.9 — the background refresh runs through the same fenced
// single-flight slot as `wrap`, so an invalidation that lands
// while it is in flight discards its (pre-write) result instead
// of re-caching it as fresh.
const refresh = this.startBuild(key, builder, ttlMsOverride).promise;
// Detach: the foreground caller does not await the rebuild.
// Handle the rejection on the detached handle so an
// unhandled-rejection warning never fires — but never silently:
@@ -344,6 +409,11 @@ export class ServerCache<T> {
/** Reset every counter + entry. Test-only escape hatch. */
__resetForTests(): void {
this.map.clear();
// Fence any in-flight builder so a build crossing a test boundary
// cannot commit into the next test's clean cache.
for (const slot of this.pending.values()) {
slot.invalidated = true;
}
this.pending.clear();
this.hits = 0;
this.misses = 0;
@@ -77,7 +77,11 @@ function medication(
let callOrder: string[];
let medications: Array<Record<string, unknown>>;
let todayRows: FakeIntakeRow[];
let resolvedRows: Array<{ medicationId: string; scheduledFor: Date }>;
let resolvedRows: Array<{
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
}>;
let latestIntakeGroups: Array<{
medicationId: string;
_max: { takenAt: Date | null };
@@ -298,6 +302,8 @@ describe("buildMedsTodayBlock — next due (real engine)", () => {
{
medicationId: "med-1",
scheduledFor: new Date("2026-06-10T09:00:00.000Z"),
// Slot-anchored take (attributed): takenAt differs from the anchor.
takenAt: new Date("2026-06-10T09:05:00.000Z"),
},
];
+9 -4
View File
@@ -39,6 +39,8 @@ import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/pr
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
@@ -136,7 +138,9 @@ export async function buildMedsTodayBlock(
{ autoMissed: true },
],
},
select: { medicationId: true, scheduledFor: true },
// v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor
// === takenAt`) cannot ±6h-resolve a different slot.
select: { medicationId: true, scheduledFor: true, takenAt: true },
}),
// Current-era floor per medication: the newest revision's
// `validUntil` is where the LIVE schedule rows became valid; the
@@ -157,11 +161,12 @@ export async function buildMedsTodayBlock(
const lastTakenAtByMedId = new Map<string, Date | null>(
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
);
const resolvedSlotsByMedId = new Map<string, Date[]>();
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
for (const e of resolvedEvents) {
const mark = toResolvedSlotMark(e);
const list = resolvedSlotsByMedId.get(e.medicationId);
if (list) list.push(e.scheduledFor);
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
if (list) list.push(mark);
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
for (const f of eraFloors) {
@@ -40,9 +40,12 @@ describe("prompt reads exclude tombstoned rows", () => {
});
it("the Coach snapshot filters its intake read", () => {
// v1.16.9 — the compliance timeline reads intake events through the
// medication relation (`intakeEvents`) so the ledger can be rebuilt
// per medication; the tombstone filter must ride on that nested read.
const src = read("../../ai/coach/snapshot.ts");
const intakeRead = src.match(
/medicationIntakeEvent\.findMany\(\{[\s\S]{0,200}?where:\s*\{[^}]*\}/,
/intakeEvents:\s*\{[\s\S]{0,300}?where:\s*\{[^}]*\}/,
);
expect(intakeRead).not.toBeNull();
expect(intakeRead![0]).toContain("deletedAt: null");
@@ -0,0 +1,226 @@
/**
* v1.16.9 — ad-hoc→pending band convergence in the nightly dedup.
*
* The production damage this heals: the external-API and Telegram write
* paths historically bare-created `scheduledFor = takenAt`, leaving the
* worker-minted pending REMINDER row open. The legacy snap pass only
* reaches a ± half-window tolerance and the discovery query only matched
* 2-minute drift, so an 08:42 API take beside its pending 09:00 row
* never collapsed — the ledger showed an ad-hoc take PLUS a missed slot
* for one real dose.
*
* Covered here:
* 1. The 08:42 / 09:00 pair heals: the pending row tombstones, the
* take normalises onto the canonical anchor.
* 2. A take outside the legacy snap tolerance but inside the band's
* late tail still heals (the case the snap pass can never reach).
* 3. An ambiguous double-slot (two live rows on the anchor) is left
* alone by the convergence pass.
* 4. A WEB ad-hoc row (deliberate user shape) is never converged.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: {
findFirst: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
import { dedupeUserIntakeSlots } from "../intake-slot-dedup";
import { prisma } from "@/lib/db";
const TZ = "Europe/Berlin";
// Daily 09:00 Berlin med. 09:00 CEST on 2026-06-10 = 07:00:00Z.
const SLOT_0900 = new Date("2026-06-10T07:00:00.000Z");
const TAKEN_0842 = new Date("2026-06-10T06:42:00.000Z");
// 12:30 Berlin — outside the ±2h legacy snap tolerance of the 09:00
// anchor, but inside the band's late tail (on-time end 10:00 + 180 min).
const TAKEN_1230 = new Date("2026-06-10T10:30:00.000Z");
const SCHEDULE = {
id: "s1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const MED = {
id: "med-1",
userId: "u1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [SCHEDULE],
scheduleRevisions: [],
};
function pendingRow(over: Record<string, unknown> = {}) {
return {
id: "r-pending",
medicationId: "med-1",
scheduledFor: SLOT_0900,
takenAt: null,
skipped: false,
syncVersion: 0,
createdAt: new Date("2026-06-10T05:00:00Z"),
attributionSource: "AUTO",
source: "REMINDER",
...over,
};
}
function adhocRow(at: Date, over: Record<string, unknown> = {}) {
return {
id: "r-adhoc",
medicationId: "med-1",
scheduledFor: at,
takenAt: at,
skipped: false,
syncVersion: 1,
createdAt: at,
attributionSource: "AUTO",
source: "API",
...over,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: TZ,
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
{} as never,
);
});
describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () => {
it("heals the 08:42 API take / pending 09:00 REMINDER pair", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_0842),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
// The pending row tombstones; the take is the dose of record.
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as {
where: { id: string };
data: { deletedAt: Date };
};
expect(tombstone.where.id).toBe("r-pending");
expect(tombstone.data.deletedAt).toBeInstanceOf(Date);
// The take normalises onto the canonical 09:00 anchor.
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { scheduledFor: Date };
};
expect(normalise.where.id).toBe("r-adhoc");
expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime());
expect(summary.rowsSoftDeleted).toBe(1);
expect(summary.rowsNormalised).toBe(1);
expect(summary.slotsCollapsed).toBe(1);
expect(summary.daysRecomputed).toBeGreaterThanOrEqual(1);
});
it("heals a take outside the legacy snap tolerance but inside the band's late tail", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
expect(summary.rowsSoftDeleted).toBe(1);
expect(summary.rowsNormalised).toBe(1);
const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as { where: { id: string } };
expect(tombstone.where.id).toBe("r-pending");
const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } };
expect(normalise.where.id).toBe("r-adhoc");
expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime());
});
it("leaves an ambiguous double-slot (two live rows on the anchor) alone", async () => {
// Two live rows already sit on the 09:00 anchor — converging the take
// would have to pick one blindly. The convergence pass declines; the
// take stays where it is. (The legacy exact-instant collapse of the
// two anchor rows is a separate, pre-existing pass.)
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230),
pendingRow(),
pendingRow({ id: "r-pending-2", source: "API" }),
] as never);
await dedupeUserIntakeSlots("u1");
// The ad-hoc take was never normalised onto the anchor.
const normaliseCalls = vi
.mocked(prisma.medicationIntakeEvent.update)
.mock.calls.map((c) => (c[0] as { where: { id: string } }).where.id);
expect(normaliseCalls).not.toContain("r-adhoc");
});
it("never converges a WEB ad-hoc row (deliberate user shape)", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230, { source: "WEB" }),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
expect(summary.rowsSoftDeleted).toBe(0);
expect(summary.rowsNormalised).toBe(0);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
});
it("never converges a USER_PIN released row", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
adhocRow(TAKEN_1230, { attributionSource: "USER_PIN" }),
pendingRow(),
] as never);
const summary = await dedupeUserIntakeSlots("u1");
expect(summary.rowsSoftDeleted).toBe(0);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
});
@@ -365,3 +365,95 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
expect(res.status).toBeNull();
});
});
/**
* v1.16.9 — day-scale early-take downgrade. A weekly injectable taken two
* days before its slot day is already on board; on the slot day the
* in-window pill must carry taken-early context instead of prompting a
* full take (a "take now" there is a double-dose prompt).
*/
describe("reduceCurrentWindowStatus — day-scale taken-early context", () => {
// Weekly Wednesday 09:00 (2026-06-10 is a Wednesday). daysOfWeek "3" =
// Wednesday in the legacy CSV encoding.
const weeklyWedSchedule = {
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: "3",
timesOfDay: ["09:00"],
};
// Monday 2026-06-08, 08:00 Berlin (CEST) — two days before the slot day.
const MONDAY_TAKE = "2026-06-08T06:00:00.000Z";
it("flags takenEarly on the slot day after a Monday take (Mon-take/Wed-slot)", () => {
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(9, 15),
lastTakenAt: MONDAY_TAKE,
todayEventCount: 0,
});
expect(res.status).toBe("in_window");
expect(res.takenEarly).toBe(true);
});
it("does not flag takenEarly when the last take was a full period ago", () => {
// Previous Wednesday — the prior cycle's regular dose.
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(9, 15),
lastTakenAt: "2026-06-03T07:05:00.000Z",
todayEventCount: 0,
});
expect(res.status).toBe("in_window");
expect(res.takenEarly).toBe(false);
});
it("does not flag a daily cadence (minute-scale periods stay untouched)", () => {
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [
{
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
},
],
nowBerlin: localClock(9, 15),
lastTakenAt: "2026-06-09T07:05:00.000Z", // yesterday — normal daily take
todayEventCount: 0,
});
expect(res.status).toBe("in_window");
expect(res.takenEarly).toBe(false);
});
it("rides on the late tier too — an early take must not escalate to overdue", () => {
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(11, 30), // past the band + inside lateMinutes
lastTakenAt: MONDAY_TAKE,
todayEventCount: 0,
});
expect(res.status).toBe("late");
expect(res.takenEarly).toBe(true);
});
it("evaluates the early-take day in the supplied timezone", () => {
// Pacific/Auckland (UTC+12): the same instant lands on different local
// days in Berlin vs Auckland near midnight. 2026-06-08T13:00:00Z is
// 2026-06-09 01:00 in Auckland (one day before the Wed slot day) —
// still inside the weekly period → takenEarly.
const res = reduceCurrentWindowStatus({
...BASE,
schedules: [weeklyWedSchedule],
nowBerlin: localClock(9, 15), // interpreted as Auckland wall clock
lastTakenAt: "2026-06-08T13:00:00.000Z",
todayEventCount: 0,
tz: "Pacific/Auckland",
});
expect(res.status).toBe("in_window");
expect(res.takenEarly).toBe(true);
});
});
+226 -1
View File
@@ -49,6 +49,11 @@ import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant";
import {
buildMedicationDayBands,
type AttributeIntakeMedication,
} from "@/lib/medications/scheduling/attribute-intake";
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
import type {
WorkerMedicationRow,
WorkerScheduleRow,
@@ -89,6 +94,8 @@ type DedupMedication = WorkerMedicationRow & {
userId: string;
timezone: string;
schedules: WorkerScheduleRow[];
/** v1.16.9 — archived eras so the band convergence honours old times. */
scheduleRevisions: ScheduleRevisionLike[];
};
interface DedupIntakeRow {
@@ -105,6 +112,13 @@ interface DedupIntakeRow {
* ad-hoc). Such a row never joins a snap cluster.
*/
attributionSource: string;
/**
* v1.16.9 — write origin. The ad-hoc→pending band convergence only
* touches API / REMINDER rows (external ingest + the Telegram webhook,
* the two paths that historically bare-created `scheduledFor =
* takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape.
*/
source: string;
}
/**
@@ -197,6 +211,21 @@ export async function dedupeUserIntakeSlots(
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
// v1.16.9 — the band convergence honours the persisted per-dose
// windows (the same lever the write path + read ledger consume).
doseWindows: true,
},
},
// v1.16.9 — archived eras: an old ad-hoc row converges against the
// schedule that was live at its takenAt, not today's times.
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
},
@@ -235,12 +264,33 @@ export async function dedupeUserIntakeSlots(
createdAt: true,
// v1.16.0 — pin-awareness: a USER_PIN row never joins a cluster.
attributionSource: true,
// v1.16.9 — write origin for the ad-hoc→pending convergence gate.
source: true,
},
orderBy: { scheduledFor: "asc" },
})) as DedupIntakeRow[];
if (rows.length < 2) continue;
// v1.16.9 — heal pass FIRST: converge ad-hoc actioned API/REMINDER
// rows onto the pending row of the slot whose minted band contains
// the takenAt. The legacy snap pass below only reaches a ±half-window
// tolerance, so an 18-minute-early API ingest beside its pending
// 09:00 REMINDER row never clustered — the production damage this
// backfill exists to repair. The pass mutates `rows` in place
// (tombstoned pending rows removed, the winner's `scheduledFor`
// normalised) so the snap pass below operates on the healed state.
await convergeAdhocRowsOntoPendingSlots({
med,
rows,
userTz,
lastIntakeAt,
summary,
daysToRecompute,
});
if (rows.length < 2) continue;
// Group rows by the canonical slot instant they snap to. Rows that
// resolve to `null` are PRN / off-slot — keyed individually so they
// never collapse together.
@@ -402,6 +452,161 @@ export async function dedupeUserIntakeSlots(
return summary;
}
/**
* v1.16.9 — converge ad-hoc actioned rows onto their slot's pending row.
*
* The healed shape: an external-API or Telegram take historically
* bare-created `scheduledFor = takenAt`, leaving the worker-minted
* pending REMINDER row open. The pending row later auto-missed, so the
* ledger showed an ad-hoc take PLUS a missed slot for one real dose.
* The write paths converge since v1.16.9; this pass repairs the rows
* already written.
*
* Deliberately conservative — a pair converges ONLY when:
* - the actioned row is ad-hoc shaped (`scheduledFor === takenAt`,
* taken, not skipped), source API or REMINDER, and not USER_PIN;
* - exactly ONE minted band contains the takenAt (the bands are
* disjoint by construction, but a zero- or ambiguous-match take is
* left alone);
* - the band's anchor carries exactly ONE live row and that row is
* unactioned (no takenAt, not skipped — a cron auto-miss flag does
* not count as an action: the take proves the miss wrong).
*
* The actioned row is the dose of record: its `scheduledFor` normalises
* to the band anchor and the pending row is tombstoned (soft-deleted,
* syncVersion bumped) per the canonical pattern. `rows` is mutated in
* place so the caller's snap pass sees the healed state.
*/
async function convergeAdhocRowsOntoPendingSlots(input: {
med: DedupMedication;
rows: DedupIntakeRow[];
userTz: string;
lastIntakeAt: Date | null;
summary: IntakeSlotDedupSummary;
daysToRecompute: Set<string>;
}): Promise<void> {
const { med, rows, userTz, lastIntakeAt, summary, daysToRecompute } = input;
const isRolling = med.schedules.some((s) => s.rollingIntervalDays !== null);
const adhocCandidates = rows.filter(
(r) =>
r.takenAt !== null &&
!r.skipped &&
r.scheduledFor.getTime() === r.takenAt.getTime() &&
r.attributionSource !== "USER_PIN" &&
(r.source === "API" || r.source === "REMINDER"),
);
if (adhocCandidates.length === 0) return;
const medication: AttributeIntakeMedication = {
id: med.id,
startsOn: med.startsOn,
endsOn: med.endsOn,
oneShot: med.oneShot,
createdAt: med.createdAt,
schedules: med.schedules,
scheduleRevisions: med.scheduleRevisions ?? [],
};
for (const adhoc of adhocCandidates) {
try {
const takenAt = adhoc.takenAt as Date;
// Rolling cadences anchor their retrospective grid AT each intake;
// build the instants from the rows already in memory, EXCLUDING the
// candidate itself (its own ad-hoc anchor must not mint the band it
// is judged against).
const intakeInstants = isRolling
? rows
.filter(
(r) =>
r.id !== adhoc.id &&
r.takenAt !== null &&
!r.skipped &&
r.takenAt.getTime() <= takenAt.getTime(),
)
.map((r) => r.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime())
: undefined;
const { bands, hasExpectedSlots } = buildMedicationDayBands({
medication,
userTz,
around: takenAt,
lastIntakeAt,
intakeInstants,
now: takenAt,
});
if (!hasExpectedSlots) continue;
const t = takenAt.getTime();
const matching = bands.filter(
(b) => t >= b.onTimeStart.getTime() && t <= b.overdueEnd.getTime(),
);
if (matching.length !== 1) continue; // zero or ambiguous — leave alone.
const anchor = matching[0].at;
if (anchor.getTime() === adhoc.scheduledFor.getTime()) continue; // same instant — the snap pass owns it.
const anchorRows = rows.filter(
(r) =>
r.id !== adhoc.id && r.scheduledFor.getTime() === anchor.getTime(),
);
if (anchorRows.length !== 1) continue; // empty or double-slot — leave alone.
const pending = anchorRows[0];
if (pending.takenAt !== null || pending.skipped) continue; // already served.
// Tombstone the pending row; the take is the dose of record.
await prisma.medicationIntakeEvent.updateMany({
where: { id: pending.id, deletedAt: null },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
summary.rowsSoftDeleted += 1;
const adhocDayInstant = adhoc.scheduledFor;
// Normalise the take onto the canonical anchor. Same P2002 guard as
// the snap pass: a tombstoned row may already occupy
// `(anchor, source)`; when it does the take keeps its own anchor —
// the pending row is already gone, so the slot no longer double-counts.
try {
await prisma.medicationIntakeEvent.update({
where: { id: adhoc.id },
data: { scheduledFor: anchor, syncVersion: { increment: 1 } },
});
summary.rowsNormalised += 1;
adhoc.scheduledFor = anchor;
} catch (normErr) {
if (!isP2002(normErr)) throw normErr;
annotate({
meta: {
intake_slot_dedup_converge_collision: true,
intake_slot_dedup_medication: med.id,
intake_slot_dedup_slot: anchor.toISOString(),
},
});
}
summary.slotsCollapsed += 1;
// Both local days can differ for day-scale bands — recompute each.
daysToRecompute.add(`${med.id}|${anchor.toISOString()}`);
daysToRecompute.add(`${med.id}|${adhocDayInstant.toISOString()}`);
// Mutate the in-memory rows so the snap pass sees the healed state.
const pendingIdx = rows.findIndex((r) => r.id === pending.id);
if (pendingIdx >= 0) rows.splice(pendingIdx, 1);
} catch (pairErr) {
annotate({
meta: {
intake_slot_dedup_converge_failed: true,
intake_slot_dedup_medication: med.id,
intake_slot_dedup_error:
pairErr instanceof Error ? pairErr.message : String(pairErr),
},
});
}
}
}
/**
* Discovery pass — runs at worker boot and on the daily cron tick (the
* cron payload omits `userId`; the worker handler dispatches here). Finds
@@ -444,6 +649,15 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{
// `(user_id, medication_id, scheduled_for)`. The window absorbs the
// sub-minute iOS-vs-server drift; the per-user pass re-checks
// precisely so a wider-than-needed window only costs idle jobs.
//
// v1.16.9 — second arm: the ad-hoc→pending heal pattern. An actioned
// ad-hoc row (`scheduled_for = taken_at`) beside an unactioned row on
// the same medication can sit up to the widest band reach apart
// (weekly on-time 1d + overdue 4d ≈ 5 days; 475200 s adds half a day
// of slack) — far outside the 2-minute drift arm, which is why the
// 18-minute API/REMINDER pairs were never enqueued. The per-user pass
// re-checks by exact band membership, so the wide arm only costs idle
// jobs.
const users = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT DISTINCT a."user_id" AS id
FROM "medication_intake_events" a
@@ -453,7 +667,18 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{
AND a."id" <> b."id"
AND a."deleted_at" IS NULL
AND b."deleted_at" IS NULL
AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120
AND (
abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120
OR (
a."taken_at" IS NOT NULL
AND a."scheduled_for" = a."taken_at"
AND a."skipped" = false
AND a."source" IN ('API', 'REMINDER')
AND b."taken_at" IS NULL
AND b."skipped" = false
AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 475200
)
)
`;
if (users.length === 0) {
@@ -96,8 +96,17 @@ describe("attributeIntakeToSlot — summer (CEST)", () => {
expect(r?.status).toBe("on_time");
});
it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => {
expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull();
it("a take at 17:30 credits the 19:00 slot through the bounded early grace (v1.16.9)", () => {
// 30 min before the 18:00 on-time start — inside the 60-min early
// grace, so a slightly-early take credits its slot instead of
// orphaning ad-hoc while the slot later reads missed.
const r = attributeIntakeToSlot(at(day, 17, 30), bands);
expect(r?.band.timeOfDay).toBe("19:00");
expect(r?.status).toBe("on_time");
});
it("a take at 16:30 stays ad-hoc (beyond the 60-min early grace)", () => {
expect(attributeIntakeToSlot(at(day, 16, 30), bands)).toBeNull();
});
});
@@ -166,3 +175,73 @@ describe("attributeIntakeToSlot — configurable window widens on-time", () => {
expect(r?.status).toBe("on_time");
});
});
/**
* v1.16.9 — bounded early grace ahead of the on-time band.
*
* A window configured to start AT the dose time ("09:00–10:00") refused
* an 08:42 take: it orphaned ad-hoc and the slot read missed. Takes up to
* 60 min before `onTimeStart` now credit the slot; the reach is capped at
* the previous slot's `overdueEnd` so a take inside the prior dose's late
* tail is never re-claimed.
*/
describe("attributeIntakeToSlot — bounded early grace (v1.16.9)", () => {
const day = new Date("2026-06-08T12:00:00Z");
function nineToTenWindow(): SlotWindowInput {
return {
at: localHmAsUtc(day, TZ, 9, 0),
timeOfDay: "09:00",
onTimeStart: localHmAsUtc(day, TZ, 9, 0),
onTimeEnd: localHmAsUtc(day, TZ, 10, 0),
lateGraceMs: 180 * MIN,
};
}
it("an 08:42 take credits the 09:00 slot whose window starts AT 09:00", () => {
const bands = buildSlotBands([nineToTenWindow()]);
const r = attributeIntakeToSlot(at(day, 8, 42), bands);
expect(r?.band.timeOfDay).toBe("09:00");
expect(r?.status).toBe("on_time");
});
it("a take more than 60 min before the window start stays ad-hoc", () => {
const bands = buildSlotBands([nineToTenWindow()]);
expect(attributeIntakeToSlot(at(day, 7, 55), bands)).toBeNull();
});
it("the early grace never crosses into the previous slot's band", () => {
// 06:00 slot with a long late tail reaching to 09:00 (capped exactly
// at the 09:00 window start) + the 09:00–10:00 window. A take at
// 08:42 sits inside the 06:00 slot's tail — it must stay attributed
// LATE to the 06:00 slot, never early-claimed by the 09:00 slot.
const slots: SlotWindowInput[] = [
{
at: localHmAsUtc(day, TZ, 6, 0),
timeOfDay: "06:00",
onTimeStart: localHmAsUtc(day, TZ, 5, 30),
onTimeEnd: localHmAsUtc(day, TZ, 6, 30),
lateGraceMs: 600 * MIN, // capped at the next window's start
},
nineToTenWindow(),
];
const bands = buildSlotBands(slots);
// The cap binds: the 09:00 band's early reach starts where the 06:00
// tail ends.
expect(bands[1].earlyStart.toISOString()).toBe(
bands[0].overdueEnd.toISOString(),
);
const r = attributeIntakeToSlot(at(day, 8, 42), bands);
expect(r?.band.timeOfDay).toBe("06:00");
expect(r?.status).toBe("late");
});
it("isLastIntakeInBand suppression mirrors the grace (no take-now after an 08:42 take)", () => {
// Covered at the card level: see window-status — this pins the band
// shape the suppression relies on (earlyStart <= onTimeStart always).
const bands = buildSlotBands([nineToTenWindow()]);
expect(bands[0].earlyStart.getTime()).toBeLessThanOrEqual(
bands[0].onTimeStart.getTime(),
);
});
});
@@ -11,12 +11,22 @@
*/
import { describe, expect, it } from "vitest";
import { computeDisplayDue, computeNextDueAt } from "../next-due";
import {
computeDisplayDue,
computeNextDueAt,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "../next-due";
import type {
WorkerMedicationRow,
WorkerScheduleRow,
} from "../worker-helpers";
/** A slot-anchored resolved mark (the write paths' canonical shape). */
function mark(at: Date): ResolvedSlotMark {
return { at, slotAnchored: true };
}
function d(iso: string): Date {
return new Date(iso);
}
@@ -155,8 +165,8 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => {
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T15:30:00Z"),
resolvedSlots: [
d("2026-06-10T05:00:00Z"), // today 07:00 Berlin
d("2026-06-10T17:00:00Z"), // today 19:00 Berlin
mark(d("2026-06-10T05:00:00Z")), // today 07:00 Berlin
mark(d("2026-06-10T17:00:00Z")), // today 19:00 Berlin
],
});
expect(next).not.toBeNull();
@@ -173,7 +183,7 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => {
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T07:13:00Z"),
resolvedSlots: [d("2026-06-10T05:00:00Z")], // only today 07:00
resolvedSlots: [mark(d("2026-06-10T05:00:00Z"))], // only today 07:00
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00
@@ -315,7 +325,7 @@ describe("computeDisplayDue — open overdue slot", () => {
now,
userTz: BERLIN,
lastIntakeAt: null,
resolvedSlots: [d("2026-06-10T19:00:00Z")],
resolvedSlots: [mark(d("2026-06-10T19:00:00Z"))],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
@@ -336,3 +346,137 @@ describe("computeDisplayDue — open overdue slot", () => {
expect(due!.overdue).toBe(false);
});
});
/**
* v1.16.9 — an AD-HOC row (`scheduledFor === takenAt`) must never
* ±6h-resolve a DIFFERENT slot. The proven failure: twice-daily 08:00 /
* 20:00 Berlin, an ad-hoc take at 14:30 sat within 6h of tonight's 20:00
* anchor and resolved it — the genuinely-due dose disappeared from the
* card while the ledger still counted the slot missed.
*/
describe("resolved-slot marks — ad-hoc rows only match their own anchor", () => {
const medication = makeMedication();
// 08:00 / 20:00 Berlin (CEST): 06:00Z / 18:00Z.
function schedule(): WorkerScheduleRow {
return {
id: "sched-2x",
windowStart: "08:00",
windowEnd: "20:00",
daysOfWeek: null,
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
const ADHOC_1430 = d("2026-06-10T12:30:00Z"); // 14:30 Berlin
it("a 14:30 ad-hoc take leaves tonight's 20:00 slot due", () => {
const now = d("2026-06-10T15:00:00Z"); // 17:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: ADHOC_1430,
resolvedSlots: [
// The morning slot was genuinely taken (slot-anchored row)…
mark(d("2026-06-10T06:00:00Z")),
// …and the 14:30 take recorded ad-hoc (`scheduledFor === takenAt`).
toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }),
],
});
expect(next).not.toBeNull();
// Tonight's 20:00 Berlin (18:00Z) stays due — the ad-hoc row must not
// resolve it across the radius.
expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z");
});
it("a 14:30 ad-hoc take does not suppress the 20:00 overdue pill either", () => {
const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin — inside the late tail
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: ADHOC_1430,
resolvedSlots: [
mark(d("2026-06-10T06:00:00Z")),
toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(true);
expect(due!.at.toISOString()).toBe("2026-06-10T18:00:00.000Z");
});
it("a slot-anchored attributed row still resolves its slot across drift", () => {
const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T18:05:00Z"),
resolvedSlots: [
mark(d("2026-06-10T06:00:00Z")),
// Attributed take: scheduledFor on the anchor, takenAt 20:05.
toResolvedSlotMark({
scheduledFor: d("2026-06-10T18:00:00Z"),
takenAt: d("2026-06-10T18:05:00Z"),
}),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
// Both today's slots resolved → tomorrow's 08:00 Berlin (06:00Z).
expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z");
});
it("an ad-hoc row sitting exactly on the anchor still resolves that slot", () => {
const now = d("2026-06-10T19:00:00Z");
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T18:00:00Z"),
resolvedSlots: [
mark(d("2026-06-10T06:00:00Z")),
// Take recorded at the exact anchor instant — ad-hoc shaped but ON
// the slot; the near-exact epsilon keeps it resolving.
toResolvedSlotMark({
scheduledFor: d("2026-06-10T18:00:00Z"),
takenAt: d("2026-06-10T18:00:00Z"),
}),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z");
});
it("behaves identically in a non-European zone (Pacific/Auckland)", () => {
const AUCKLAND = "Pacific/Auckland"; // UTC+12 in June (NZST)
// 08:00 / 20:00 Auckland on 2026-06-10 = 2026-06-09T20:00Z / 2026-06-10T08:00Z.
const adhoc = d("2026-06-10T02:30:00Z"); // 14:30 Auckland
const now = d("2026-06-10T09:00:00Z"); // 21:00 Auckland
const due = computeDisplayDue({
medication,
schedules: [schedule()],
now,
userTz: AUCKLAND,
lastIntakeAt: adhoc,
resolvedSlots: [
mark(d("2026-06-09T20:00:00Z")),
toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(true);
expect(due!.at.toISOString()).toBe("2026-06-10T08:00:00.000Z");
});
});
+35 -2
View File
@@ -39,10 +39,27 @@ export interface SlotWindowInput {
lateGraceMs: number;
}
/**
* v1.16.9 — bounded early grace ahead of the on-time band. A user who
* configures a window to start AT the dose time ("09:00–10:00") would
* otherwise have an 08:42 take refused and orphaned ad-hoc while the
* slot read missed. A take up to this far before `onTimeStart` still
* credits the slot (as on-time); the reach is capped at the PREVIOUS
* slot's `overdueEnd` so it can never claim a take that belongs to the
* prior dose's late tail.
*/
export const EARLY_GRACE_MS = 60 * 60 * 1000;
/** A slot window with its (capped) late-tail cutoff resolved. */
export interface SlotBand extends SlotWindowInput {
/** Cutoff past which a take no longer pairs to this slot (capped). */
overdueEnd: Date;
/**
* Earliest instant a take still credits this slot (the bounded early
* grace before `onTimeStart`, capped at the previous slot's
* `overdueEnd`).
*/
earlyStart: Date;
}
export type AttributionStatus = "on_time" | "late";
@@ -59,7 +76,7 @@ export interface AttributionResult {
*/
export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] {
const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime());
return sorted.map((slot, i) => {
const withTails = sorted.map((slot, i) => {
let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs;
const next = sorted[i + 1];
if (next) {
@@ -71,6 +88,19 @@ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] {
overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime());
return { ...slot, overdueEnd: new Date(overdueEnd) };
});
// Second pass — the early reach is bounded by the PREVIOUS slot's
// resolved tail, so it never claims a take inside the prior dose's
// band. The previous `overdueEnd` is already capped at this slot's
// `onTimeStart`, so `earlyStart <= onTimeStart` holds by construction.
return withTails.map((band, i) => {
let earlyStart = band.onTimeStart.getTime() - EARLY_GRACE_MS;
const prev = withTails[i - 1];
if (prev) {
earlyStart = Math.max(earlyStart, prev.overdueEnd.getTime());
}
earlyStart = Math.min(earlyStart, band.onTimeStart.getTime());
return { ...band, earlyStart: new Date(earlyStart) };
});
}
/**
@@ -90,7 +120,10 @@ export function attributeIntakeToSlot(
null;
for (const band of bands) {
const onStart = band.onTimeStart.getTime();
// v1.16.9 — the bounded early grace extends the on-time capture below
// `onTimeStart` (never into the previous slot's band — `buildSlotBands`
// caps it). Hand-built bands without the field keep the strict start.
const onStart = (band.earlyStart ?? band.onTimeStart).getTime();
const onEnd = band.onTimeEnd.getTime();
const overdueEnd = band.overdueEnd.getTime();
+63 -19
View File
@@ -29,6 +29,63 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d
*/
const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
/**
* v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors
* `scheduledFor = takenAt` on its own instant, so it can only resolve a
* slot it actually sits on (sub-minute drift absorbed); letting it use
* the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved
* tonight's 20:00 dose while the ledger still counted that slot missed.
*/
const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
/**
* A row that resolves a slot (taken / deliberately skipped /
* cron-auto-missed), with its anchoring shape preserved.
*
* `slotAnchored: false` marks the ad-hoc shape (`scheduledFor ===
* takenAt`): such a row resolves a slot only on a near-exact anchor
* match, never across the ±6h radius. Slot-anchored rows (the write
* paths snap their `scheduledFor` to the canonical slot instant) keep
* the defensive radius.
*/
export interface ResolvedSlotMark {
at: Date;
slotAnchored: boolean;
}
/**
* Map a resolved intake row to its `ResolvedSlotMark`. The ad-hoc shape
* is detectable only for taken rows (`scheduledFor === takenAt` to the
* millisecond — the documented standalone-insert contract); skips and
* auto-misses anchor on their slot by construction.
*/
export function toResolvedSlotMark(row: {
scheduledFor: Date;
takenAt: Date | null;
}): ResolvedSlotMark {
return {
at: row.scheduledFor,
slotAnchored:
row.takenAt === null ||
row.takenAt.getTime() !== row.scheduledFor.getTime(),
};
}
function buildIsResolved(
resolved: ResolvedSlotMark[],
): (slotAt: Date) => boolean {
return (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
const radius = r.slotAnchored
? RESOLVE_RADIUS_MS
: ADHOC_RESOLVE_EPSILON_MS;
if (Math.abs(r.at.getTime() - slot) <= radius) return true;
}
return false;
};
}
/**
* v1.16.4 — how far back the open-overdue search mints bands. The widest
* possible band reach is a weekly slot's on-time half-width plus its
@@ -57,21 +114,15 @@ export function computeNextDueAt(input: {
* these so a twice-daily med whose remaining slots today are all logged
* advances to the next genuinely-open slot (tomorrow's first dose) instead
* of re-surfacing a resolved present/past slot. Omit for the legacy
* purely-time-anchored next-due.
* purely-time-anchored next-due. v1.16.9 — each mark carries its
* anchoring shape; ad-hoc rows only resolve on a near-exact match.
*/
resolvedSlots?: Date[];
resolvedSlots?: ResolvedSlotMark[];
}): Date | null {
const { medication, schedules, now, userTz, lastIntakeAt } = input;
if (schedules.length === 0) return null;
const resolved = input.resolvedSlots ?? [];
const isResolved = (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true;
}
return false;
};
const isResolved = buildIsResolved(input.resolvedSlots ?? []);
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
let earliest: Date | null = null;
@@ -113,7 +164,7 @@ export interface ComputeDisplayDueInput {
now: Date;
userTz: string;
lastIntakeAt: Date | null;
resolvedSlots?: Date[];
resolvedSlots?: ResolvedSlotMark[];
/**
* Floor of the CURRENT schedule era (the newest revision's `validUntil`),
* when the medication has archived revisions. The overdue search mints
@@ -148,14 +199,7 @@ function findOpenOverdueSlot(input: ComputeDisplayDueInput): Date | null {
const { medication, schedules, now, userTz, lastIntakeAt } = input;
if (schedules.length === 0) return null;
const resolved = input.resolvedSlots ?? [];
const isResolved = (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true;
}
return false;
};
const isResolved = buildIsResolved(input.resolvedSlots ?? []);
let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS);
if (input.eraStart && input.eraStart.getTime() > floor.getTime()) {
@@ -117,6 +117,18 @@ export interface RecurrenceContext {
* Latest `MedicationIntakeEvent.takenAt` for this medication.
* Used only by rolling schedules. Null when the user has never
* logged an intake (rolling then anchors on `startsOn ?? createdAt`).
*
* Skip semantics (documented decision, v1.16.9): a deliberate SKIP
* does NOT advance the rolling anchor. A skipped row carries
* `takenAt: null`, so every `lastIntakeAt` feeder query
* (`takenAt: { not: null }`) excludes it by construction, and the
* next due stays `previous take + N` — the skipped dose keeps
* surfacing as due until a real take re-anchors the grid. Advancing
* the anchor from the skipped instant instead would silently shift
* the whole future grid off the user's established rhythm; if that
* trade-off is ever revisited, every `lastIntakeAt` feeder (the
* projector, the reminder worker, the list route, the write-path
* resolvers, the dedup pass) must change together.
*/
lastIntakeAt: Date | null;
}
@@ -54,8 +54,13 @@ export interface ApplyCanonicalSlotWriteInput {
isExplicitTaken: boolean;
isExplicitSkip: boolean;
idempotencyKey: string | null;
/** Source to stamp on a freshly-created slot row. */
createSource: "WEB" | "API";
/**
* Source to stamp on a freshly-created slot row. `REMINDER` is the
* Telegram-webhook path — its take / skip confirmations converge onto
* the worker-minted pending row like every other write, and a fresh
* create keeps the historical reminder provenance.
*/
createSource: "WEB" | "API" | "REMINDER";
/**
* v1.8.5 — resolved + server-validated injection site to persist on a
* taken write. `null` = no site (the column stays / is set NULL). Only
+125 -3
View File
@@ -87,6 +87,16 @@ export interface CurrentWindowStatus<Schedule extends ScheduleWindowInput> {
schedule: Schedule | null;
/** Non-null exactly when `status` is non-null. */
window: MatchedDoseWindow | null;
/**
* v1.16.9 — true when the matched schedule is a DAY-SCALE cadence
* (weekly / N-weekly injectable) and a recent actioned take exists
* inside the current cadence period but on an earlier day (an early
* shot the band attribution recorded ad-hoc). The dose is already on
* board: the card must render taken-early context instead of the
* "take now" / overdue prompt — a full take prompt on the slot day is
* a double-dose prompt. Always false when `status` is null.
*/
takenEarly: boolean;
}
/**
@@ -303,11 +313,25 @@ function getScheduleStatus(
return best;
}
/**
* v1.16.9 — mirror of the attribution module's bounded early grace: a
* take slightly before the on-time band still credits the slot, so the
* pill suppression must accept it too (an 08:42 take for a 09:00-window
* slot must not leave "take now" burning at 09:05).
*/
const EARLY_GRACE_MINUTES = 60;
function isLastIntakeInBand(
lastTakenAt: string | null,
band: DoseWindowBounds,
nowLocal: Date,
tz: string,
/**
* Lower bound (minutes-of-day) the early grace may not cross — the end
* of the schedule's previous dose band, so a take belonging to the
* prior dose never suppresses this one. Defaults to 0 (no earlier band).
*/
earlyFloorMins = 0,
): boolean {
if (!lastTakenAt) return false;
@@ -330,7 +354,77 @@ function isLastIntakeInBand(
? intakeMins + 24 * 60
: intakeMins;
return adjustedIntake >= band.startMins && adjustedIntake <= band.endMins;
const startWithGrace = Math.max(
band.startMins - EARLY_GRACE_MINUTES,
earlyFloorMins,
0,
);
return adjustedIntake >= startWithGrace && adjustedIntake <= band.endMins;
}
/**
* v1.16.9 — day-scale cadence detection for the taken-early downgrade.
*
* A schedule whose dose days sit ≥ 2 days apart (a single weekday, a
* sparse Mo/Th pattern, an N-weekly interval) is day-scale: one dose
* covers a multi-day period, so a take earlier in the period must inform
* the slot-day pill. Daily / near-daily patterns (minute-scale) return
* `dayScale: false` — their period is a day and the same-day band checks
* already cover them. `periodDays` is the smallest gap between
* consecutive dose days (the span one dose covers).
*/
function dayScaleCadence(daysOfWeek: string | null): {
dayScale: boolean;
periodDays: number;
} {
const recurrence = parseScheduleRecurrence(daysOfWeek);
const days = [...new Set(recurrence.daysOfWeek)].sort((a, b) => a - b);
if (days.length === 0) return { dayScale: false, periodDays: 0 };
const intervalWeeks = Math.max(1, recurrence.intervalWeeks ?? 1);
if (days.length === 1) {
return { dayScale: true, periodDays: 7 * intervalWeeks };
}
let minGap = Infinity;
for (let i = 1; i < days.length; i++) {
minGap = Math.min(minGap, days[i] - days[i - 1]);
}
// Wrap from the week's last dose day to the next cycle's first.
const wrapGap = days[0] + 7 * intervalWeeks - days[days.length - 1];
minGap = Math.min(minGap, wrapGap);
return { dayScale: minGap >= 2, periodDays: minGap };
}
/**
* True when the last actioned take lies on an EARLIER local day inside
* the current cadence period — the early-shot shape the taken-early
* downgrade fires on. A same-day take is the in-band suppression's job;
* a take a full period (or more) ago is the previous cycle's dose.
*/
function hasEarlyTakeInPeriod(
lastTakenAt: string | null,
nowLocal: Date,
tz: string,
periodDays: number,
): boolean {
if (!lastTakenAt || periodDays < 2) return false;
const intake = toZonedDate(new Date(lastTakenAt), tz);
const dayMs = 24 * 60 * 60 * 1000;
const intakeDay = Math.floor(
new Date(
intake.getFullYear(),
intake.getMonth(),
intake.getDate(),
).getTime() / dayMs,
);
const nowDay = Math.floor(
new Date(
nowLocal.getFullYear(),
nowLocal.getMonth(),
nowLocal.getDate(),
).getTime() / dayMs,
);
const diff = nowDay - intakeDay;
return diff >= 1 && diff < periodDays;
}
/**
@@ -396,6 +490,13 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
missedMinutes: number;
active: boolean;
lastTakenAt: string | null;
/**
* v1.16.9 — number of ACTIONED intake events today (taken or
* explicitly skipped). The server list feeder counts only actioned
* rows: the dashboard projector mints a pending row for every slot of
* the day, so an all-rows count covered every passed dose after any
* dashboard visit and the overdue pill went dark nondeterministically.
*/
todayEventCount: number;
/** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */
tz?: string;
@@ -430,6 +531,7 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
status: null,
schedule: null,
window: null,
takenEarly: false,
};
if (!active) return none;
if (nextDue === null) return none;
@@ -465,14 +567,33 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
// intake events for the day.
if (hit.status !== "in_window" && !hasUncoveredOverdue) return best;
// Don't show in_window if last intake is already within this band
// today.
// today (including the bounded early grace, floored at the end of
// the schedule's previous dose band so a prior dose's take never
// suppresses this one).
const earlyFloorMins = resolveDoseWindows(s).reduce(
(floor, b) =>
b.endMins <= hit.band.startMins && b.endMins > floor
? b.endMins
: floor,
0,
);
if (
hit.status === "in_window" &&
isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz)
isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz, earlyFloorMins)
) {
return best;
}
if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) {
// v1.16.9 — day-scale early-take downgrade: a weekly injectable
// taken days before its slot day is already on board, so the pill
// must carry taken-early context instead of prompting a full take
// (a "Jetzt einnehmen" / overdue prompt here is a double-dose
// prompt). The server next-due stays canonical — only the pill's
// framing changes.
const cadence = dayScaleCadence(s.daysOfWeek);
const takenEarly =
cadence.dayScale &&
hasEarlyTakeInPeriod(lastTakenAt, nowBerlin, tz, cadence.periodDays);
return {
status: hit.status,
schedule: s,
@@ -481,6 +602,7 @@ export function reduceCurrentWindowStatus<Schedule extends ScheduleWindowInput>(
start: hit.band.startHm,
end: hit.band.endHm,
},
takenEarly,
};
}
return best;
+2 -2
View File
@@ -217,7 +217,7 @@ const medicationListEntry = medicationResource
.int()
.nonnegative()
.describe(
"Number of intake events scheduled for today (user-local day window).",
"Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.",
),
})
.meta({
@@ -619,7 +619,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "List medications for the calling user",
description:
"Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.",
"Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.",
responses: {
"200": {
description: "Medication list.",
@@ -11,7 +11,11 @@
*/
import { describe, expect, it } from "vitest";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import {
externalIntakeSchema,
intakeSchema,
updateIntakeEventSchema,
} from "@/lib/validations/medication";
const HOUR_MS = 60 * 60 * 1000;
const YEAR_MS = 365 * 24 * HOUR_MS;
@@ -74,3 +78,70 @@ describe("updateIntakeEventSchema — takenAt bounds (P0-4)", () => {
expect(result.success).toBe(true);
});
});
/**
* v1.16.9 — the same bounds on every CREATE path. The retro-add dialog
* posts through `intakeSchema`, the iOS sync through the bulk entry
* shape, external integrations through `externalIntakeSchema`; none of
* them may insert a row the edit path would refuse.
*/
describe("intakeSchema — takenAt bounds on create (v1.16.9)", () => {
it("rejects a future takenAt beyond the skew allowance", () => {
const result = intakeSchema.safeParse({
medicationId: "med-1",
takenAt: new Date(Date.now() + HOUR_MS).toISOString(),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("future");
}
});
it("rejects a takenAt more than 5 years in the past", () => {
const result = intakeSchema.safeParse({
medicationId: "med-1",
takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("5 years");
}
});
it("accepts a recent takenAt and a body that omits it", () => {
expect(
intakeSchema.safeParse({
medicationId: "med-1",
takenAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
}).success,
).toBe(true);
expect(intakeSchema.safeParse({ medicationId: "med-1" }).success).toBe(
true,
);
});
});
describe("externalIntakeSchema — takenAt bounds on ingest (v1.16.9)", () => {
it("rejects future and implausibly-old instants, accepts recent ones", () => {
const future = externalIntakeSchema.safeParse({
medicationName: "Metformin",
idempotencyKey: "k-1",
takenAt: new Date(Date.now() + HOUR_MS).toISOString(),
});
expect(future.success).toBe(false);
const ancient = externalIntakeSchema.safeParse({
medicationName: "Metformin",
idempotencyKey: "k-2",
takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(),
});
expect(ancient.success).toBe(false);
const ok = externalIntakeSchema.safeParse({
medicationName: "Metformin",
idempotencyKey: "k-3",
takenAt: new Date(Date.now() - HOUR_MS).toISOString(),
});
expect(ok.success).toBe(true);
});
});
+29 -22
View File
@@ -163,6 +163,30 @@ function hhmmToMinutes(hhmm: string): number {
return Number(h) * 60 + Number(m);
}
/**
* v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path
* (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the
* per-medication intake POST, the bulk endpoint, the external ingest) so
* the retro-add dialog and a hand-rolled API call can't insert a row a
* decade in the past or hours in the future. Future allowance covers
* client clock skew; the 5-year floor matches the GLP-1 dose-change
* validator's window. Slot-distance / medication-start checks stay in
* the routes — the schema cannot see the medication.
*/
export const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000;
export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
/** ISO `takenAt` → `Date` with the shared plausibility bounds applied. */
export const boundedTakenAtSchema = z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, {
message: "takenAt must not be in the future",
})
.refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, {
message: "takenAt must be within the last 5 years",
});
export const scheduleSchema = z
.object({
windowStart: z
@@ -598,12 +622,10 @@ export const intakeSchema = z
.describe(
"Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.",
),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
takenAt: boundedTakenAtSchema
.optional()
.describe(
"When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes.",
"When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.",
),
skipped: z
.boolean()
@@ -673,10 +695,8 @@ export const intakeSchema = z
export const externalIntakeSchema = z.object({
medicationName: z.string().min(1).max(200),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — same plausibility bounds as the interactive create paths.
takenAt: boundedTakenAtSchema.optional(),
idempotencyKey: z.string().max(128),
});
@@ -718,22 +738,9 @@ export const listIntakeEventsSchema = z.object({
* checks stay out of the schema — it cannot see the medication — and live
* in the route (start-date guard) + the edit dialog (non-blocking hint).
*/
const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000;
const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
export const updateIntakeEventSchema = z
.object({
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, {
message: "takenAt must not be in the future",
})
.refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, {
message: "takenAt must be within the last 5 years",
})
.nullable()
.optional(),
takenAt: boundedTakenAtSchema.nullable().optional(),
skipped: z.boolean().optional(),
scheduledFor: z.iso
.datetime({ offset: true })