mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(medications): read the dose history's window, not every intake
The history tab asks for a window of at most a year, but the route read every intake the medication ever had: a medication taken three times a day for five years read about 5,500 rows on each open. The full read also served to find the last intake for rolling cadences. The route now reads the rows anchored or taken in the window, and of the rows before it only the latest take, which is the anchor a rolling grid re-starts from and the last intake when none falls in the window. Earlier takes place no slot in the window, so the ledger is the same. The read moves into its own function, which shortens the handler.
This commit is contained in:
1 parent
6a1c91d5d4
commit
e1d143d689
3 files changed
+135
-24
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 339,
|
||||
"total": 110080,
|
||||
"total": 110065,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -27,7 +27,7 @@
|
||||
"app/api/mcp/oauth/token/route.ts :: POST": 250,
|
||||
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
|
||||
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250,
|
||||
"app/api/mental-health/assessments/route.ts :: postAssessment": 249,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -567,3 +567,74 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The history reads the window's rows, not every intake the medication ever
|
||||
* had: for a medication taken for years each open of the tab read thousands
|
||||
* of rows to show at most a year. Of the rows before the window only the
|
||||
* latest take is read, because a rolling grid re-starts from it.
|
||||
*/
|
||||
describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => {
|
||||
const FROM = new Date("2026-06-01T00:00:00Z");
|
||||
const TO = new Date("2026-06-15T00:00:00Z");
|
||||
const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`;
|
||||
|
||||
it("asks only for rows anchored or taken in the window", async () => {
|
||||
await GET(getReq(windowQuery), ROUTE_PARAMS);
|
||||
|
||||
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
|
||||
.calls[0][0]?.where;
|
||||
expect(where).toMatchObject({
|
||||
OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }],
|
||||
});
|
||||
const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock
|
||||
.calls[0][0];
|
||||
expect(before?.where).toMatchObject({
|
||||
skipped: false,
|
||||
scheduledFor: { lt: FROM },
|
||||
takenAt: { lt: FROM },
|
||||
});
|
||||
});
|
||||
|
||||
it("re-starts a rolling grid from the latest take before the window", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: 7,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
// Taken four days before the window opens, nothing since: the next dose
|
||||
// was due inside the window and reads as missed there.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
takenAt: new Date("2026-05-28T06:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(windowQuery), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
const rows = (await res.json()).data.rows as Array<{
|
||||
at: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
}>;
|
||||
|
||||
expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([
|
||||
["2026-06-04", "slot", "missed"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,61 @@ interface SerializedDoseHistoryRow {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The intakes a window needs: the rows anchored in it or taken in it (a late
|
||||
* take on an older slot is an intake instant of the window), and of all the
|
||||
* rows before it only the latest take, the anchor a rolling grid re-starts
|
||||
* from and the last intake when none falls in the window. Earlier takes place
|
||||
* no slot in the window. The whole history used to be read on every open, so
|
||||
* a medication taken for years read thousands of rows to show at most a year.
|
||||
*/
|
||||
async function readWindowIntakes(
|
||||
medicationId: string,
|
||||
userId: string,
|
||||
from: Date,
|
||||
) {
|
||||
const [events, takenBefore] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ scheduledFor: { gte: from } }, { takenAt: { gte: from } }],
|
||||
},
|
||||
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
autoMissed: true,
|
||||
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
|
||||
attributionSource: true,
|
||||
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
|
||||
doseTaken: true,
|
||||
// v1.32.8 (iOS #64) — write provenance so the client can label how
|
||||
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
|
||||
source: true,
|
||||
// v1.42.2 — the history labels an automatically recorded dose.
|
||||
autoRecorded: true,
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
medicationId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
skipped: false,
|
||||
scheduledFor: { lt: from },
|
||||
takenAt: { lt: from },
|
||||
},
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { takenAt: true },
|
||||
}),
|
||||
]);
|
||||
return { events, anchorBefore: takenBefore?.takenAt ?? null };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -165,26 +220,7 @@ export const GET = apiHandler(
|
||||
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
|
||||
);
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { medicationId: id, userId: user.id, deletedAt: null },
|
||||
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
autoMissed: true,
|
||||
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
|
||||
attributionSource: true,
|
||||
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
|
||||
doseTaken: true,
|
||||
// v1.32.8 (iOS #64) — write provenance so the client can label how
|
||||
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
|
||||
source: true,
|
||||
// v1.42.2 — the history labels an automatically recorded dose.
|
||||
autoRecorded: true,
|
||||
},
|
||||
});
|
||||
const { events, anchorBefore } = await readWindowIntakes(id, user.id, from);
|
||||
|
||||
const mapped = events.map((e) => ({
|
||||
id: e.id,
|
||||
@@ -198,7 +234,10 @@ export const GET = apiHandler(
|
||||
autoRecorded: e.autoRecorded,
|
||||
}));
|
||||
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
const lastIntakeAt = lastNonSkippedTakenAt([
|
||||
...mapped,
|
||||
...(anchorBefore ? [{ takenAt: anchorBefore, skipped: false }] : []),
|
||||
]);
|
||||
|
||||
// A dose may be recorded before its slot's own time: the dose window opens
|
||||
// up to two hours early, and a skip can be logged ahead. Such a row is
|
||||
@@ -263,6 +302,7 @@ export const GET = apiHandler(
|
||||
const intakeInstants = mapped
|
||||
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to)
|
||||
.map((e) => e.takenAt as Date)
|
||||
.concat(anchorBefore ? [anchorBefore] : [])
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
|
||||
Reference in new issue
Block a user