mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(medications): record the slot a backfilled dose names
Marking a missed dose taken from the Verlauf ledger showed the row flip to taken and then fall straight back to missed, with the entry nowhere on the day it was made for. The ledger posts the displayed slot's own anchor as `scheduledFor` and means "record THIS dose". The route resolved the slot by window band from `takenAt` instead, which answers a different question: which slot the recording moment falls into. Backfilling an older slot matches no band, so the write fell through to the standalone insert and landed as an ad-hoc row anchored at the recording moment. The named slot stayed unserved, the optimistic row was overwritten by the authoritative refetch, and the dose sat under today with no sign of where the user had put it. A named instant that is a real slot of this medication now decides the binding, validated against the same band anchors the "diesem Slot zuordnen" pin uses, so an arbitrary client instant still records ad-hoc. A slot that lies ahead of the take is still refused by the existing forward guard, so a late-morning dose cannot consume the evening slot. When the naming and the clock disagree the binding rests on the user's choice, so the row stores it as such and the read ledger anchors it there rather than dropping back to ad-hoc; an ordinary on-time take resolves to the same anchor either way and is untouched. The tests drive the shipped POST and the shipped dose-history GET, and assert the persisted row and the ledger read back from it.
This commit is contained in:
1 parent
d5f176ac4d
commit
4e9e8649ec
2 files changed
+325
No files matched your search
@@ -314,6 +314,53 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
|
||||
// stale USER_PIN when this write converges onto a previously-pinned row.
|
||||
attributionSource = "AUTO";
|
||||
|
||||
// The client can NAME the dose it is recording, and when it does, that
|
||||
// answer outranks the band. The Verlauf ledger's "Genommen" posts the
|
||||
// displayed slot's own anchor, and the medication card posts its
|
||||
// display-due slot; both mean "record THIS dose". Band attribution answers
|
||||
// a different question — which slot the take's clock time falls into — so
|
||||
// backfilling a slot from an earlier day matched no band, fell through to
|
||||
// the standalone insert, and left the named slot unserved: the entry
|
||||
// reverted to missed on the next read and the dose the user recorded was
|
||||
// filed on the wrong day.
|
||||
//
|
||||
// Only a REAL slot of this medication is honoured (the same band-anchor
|
||||
// validation the "diesem Slot zuordnen" pin runs), so an arbitrary client
|
||||
// instant still records ad-hoc rather than parking a live row on a
|
||||
// fabricated anchor. And only a slot that does not sit ahead of the take,
|
||||
// by the same forward guard the convergence probe below uses — a
|
||||
// late-morning dose must never consume the evening slot.
|
||||
if (
|
||||
scheduledFor !== undefined &&
|
||||
mayConvergeOntoSuppliedSlot({
|
||||
skipped,
|
||||
takenAt: resolvedTakenAt,
|
||||
suppliedSlot: incomingScheduledFor,
|
||||
})
|
||||
) {
|
||||
const namedSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
slotInstant: incomingScheduledFor,
|
||||
});
|
||||
// Only when the naming actually decides something the clock did not.
|
||||
// A dose taken inside its own window resolves to the same anchor either
|
||||
// way and stays an AUTO binding, so an ordinary on-time take is
|
||||
// unchanged. When the two disagree the binding rests on the user's
|
||||
// choice, which is what USER_PIN records — and the read ledger binds a
|
||||
// USER_PIN take by its stored anchor rather than by the clock, so a
|
||||
// backfilled dose reads under the slot it was recorded for instead of
|
||||
// falling back to ad-hoc.
|
||||
if (
|
||||
namedSlot !== null &&
|
||||
namedSlot.getTime() !== canonicalSlot?.getTime()
|
||||
) {
|
||||
canonicalSlot = namedSlot;
|
||||
attributionSource = "USER_PIN";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Backfilling a missed dose from the Verlauf ledger must record THAT slot.
|
||||
*
|
||||
* The ledger's "Genommen" action posts the displayed slot's own anchor as
|
||||
* `scheduledFor` with `takenAt = now`, meaning "record this dose". The route
|
||||
* resolved the slot by window band from `takenAt` instead, so a backfill of a
|
||||
* slot from an earlier day matched no band, fell through to the standalone
|
||||
* insert, and landed as an ad-hoc row anchored at the recording moment. The
|
||||
* named slot stayed unserved: the optimistic row flipped to taken, the
|
||||
* authoritative refetch read the slot as missed again, and the entry the user
|
||||
* had just made was gone from the day they made it for.
|
||||
*
|
||||
* Everything here goes through the shipped `POST` / `GET` exports so the real
|
||||
* `apiHandler`, the real auth resolution and the real Prisma writes answer.
|
||||
* The assertions are on the persisted row AND on the ledger the tab reads
|
||||
* back — the pipe between them is where the entry disappeared.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser() {
|
||||
const suffix = `backfill-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `bf-${suffix}`,
|
||||
email: `bf-${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: TZ,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slot hours placed so neither dose window can contain the moment the test
|
||||
* runs. A band spans roughly [anchor − 1 h, anchor + 4 h]; +5 h and +12 h from
|
||||
* the current local hour keep "now" outside both on every clock, so the
|
||||
* assertions never depend on when the suite happens to run.
|
||||
*/
|
||||
function slotHours(): { a: number; b: number } {
|
||||
const hour = Number(
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: TZ,
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date()),
|
||||
);
|
||||
return { a: (hour + 5) % 24, b: (hour + 12) % 24 };
|
||||
}
|
||||
|
||||
async function seedTwiceDaily(userId: string, hours: { a: number; b: number }) {
|
||||
const hh = (h: number) => `${String(h).padStart(2, "0")}:00`;
|
||||
const med = await getPrismaClient().medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
startsOn: new Date(Date.now() - 30 * DAY_MS),
|
||||
// The ledger clamps its window floor to the medication's creation, so a
|
||||
// medication minted at test time would have no yesterday to read back.
|
||||
createdAt: new Date(Date.now() - 30 * DAY_MS),
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: hh(hours.a),
|
||||
windowEnd: hh(hours.b),
|
||||
timesOfDay: [hh(hours.a), hh(hours.b)],
|
||||
daysOfWeek: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
async function postIntake(medicationId: string, body: unknown) {
|
||||
const { POST } = await import("@/app/api/medications/[id]/intake/route");
|
||||
return (POST as Handler)(
|
||||
new NextRequest(`http://localhost/api/medications/${medicationId}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: medicationId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function readLedger(medicationId: string, from: Date, to: Date) {
|
||||
const { GET } = await import("@/app/api/medications/[id]/dose-history/route");
|
||||
const search = new URLSearchParams({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
const response = await (GET as Handler)(
|
||||
new NextRequest(
|
||||
`http://localhost/api/medications/${medicationId}/dose-history?${search}`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: medicationId }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
rows: {
|
||||
kind: string;
|
||||
at: string;
|
||||
status: string;
|
||||
intake: { takenAt: string | null } | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
return body.data.rows;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake — backfilling a missed slot", () => {
|
||||
it("records the named slot from an earlier day, not an ad-hoc row at 'now'", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const hours = slotHours();
|
||||
const medicationId = await seedTwiceDaily(user.id, hours);
|
||||
|
||||
// Yesterday's first slot: long past its overdue tail, so the ledger reads
|
||||
// it as missed and the kebab offers "Genommen".
|
||||
const missedSlot = localHmAsUtc(
|
||||
new Date(Date.now() - DAY_MS),
|
||||
TZ,
|
||||
hours.a,
|
||||
0,
|
||||
);
|
||||
|
||||
const response = await postIntake(medicationId, {
|
||||
skipped: false,
|
||||
scheduledFor: missedSlot.toISOString(),
|
||||
takenAt: new Date().toISOString(),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
// The row lands ON the slot the user marked.
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, medicationId, deletedAt: null },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.scheduledFor.getTime()).toBe(missedSlot.getTime());
|
||||
expect(rows[0]?.takenAt).not.toBeNull();
|
||||
expect(rows[0]?.skipped).toBe(false);
|
||||
});
|
||||
|
||||
it("the ledger reads the backfilled slot as taken, not missed", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const hours = slotHours();
|
||||
const medicationId = await seedTwiceDaily(user.id, hours);
|
||||
const missedSlot = localHmAsUtc(
|
||||
new Date(Date.now() - DAY_MS),
|
||||
TZ,
|
||||
hours.a,
|
||||
0,
|
||||
);
|
||||
|
||||
await postIntake(medicationId, {
|
||||
skipped: false,
|
||||
scheduledFor: missedSlot.toISOString(),
|
||||
takenAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const rows = await readLedger(
|
||||
medicationId,
|
||||
new Date(Date.now() - 3 * DAY_MS),
|
||||
new Date(),
|
||||
);
|
||||
const slotRow = rows.find(
|
||||
(r) => r.kind === "slot" && r.at === missedSlot.toISOString(),
|
||||
);
|
||||
expect(slotRow).toBeDefined();
|
||||
expect(slotRow?.status).toMatch(/^taken_/);
|
||||
expect(slotRow?.intake?.takenAt).not.toBeNull();
|
||||
// And no orphan ad-hoc row at the recording moment.
|
||||
expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still refuses to fill a slot that lies ahead of the take", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const hours = slotHours();
|
||||
const medicationId = await seedTwiceDaily(user.id, hours);
|
||||
|
||||
// Tomorrow's first slot — the "late-morning dose must not consume the
|
||||
// evening slot" invariant, stated across a day boundary so it holds
|
||||
// whatever the clock says when the suite runs.
|
||||
const futureSlot = localHmAsUtc(
|
||||
new Date(Date.now() + DAY_MS),
|
||||
TZ,
|
||||
hours.a,
|
||||
0,
|
||||
);
|
||||
|
||||
const response = await postIntake(medicationId, {
|
||||
skipped: false,
|
||||
scheduledFor: futureSlot.toISOString(),
|
||||
takenAt: new Date().toISOString(),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const onFutureSlot = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId,
|
||||
scheduledFor: futureSlot,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
expect(onFutureSlot).toHaveLength(0);
|
||||
|
||||
const all = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, medicationId, deletedAt: null },
|
||||
});
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.scheduledFor.getTime()).toBeLessThan(futureSlot.getTime());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user