diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 22eb9ba0b..c6ff8a5ec 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5961,21 +5961,23 @@ paths: - in: query name: from schema: - description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a - 366-day span floor. + description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses + are returned from this instant; expected slots are not minted before the medication's `createdAt`. 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)))$ - description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a - 366-day span floor. + description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses + are returned from this instant; expected slots are not minted before the medication's `createdAt`. - in: query name: to schema: - description: Window end (inclusive). Defaults to now. Must be ≥ `from`. + description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's + time within a day after `to`) is still returned. 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)))$ - description: Window end (inclusive). Defaults to now. Must be ≥ `from`. + description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's + time within a day after `to`) is still returned. responses: "200": description: Dose-history ledger. @@ -43671,10 +43673,13 @@ components: 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)))$ + description: "Start of the window recorded doses were read over: the requested `from` after the span clamp." to: 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)))$ + description: "End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded + ahead of its slot when that lies later (at most one day after `to`)." family: type: string enum: diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 23e01838e..1cccc72ad 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -344,6 +344,9 @@ describe("GET /api/medications/[id]/dose-history — intakes recorded before cre json.data.rows as Array<{ intake: { id: string | null } | null }> ).map((r) => r.intake?.id); expect(ids).toContain("evt-earlier-today"); + // The response reports the window the recorded doses were read + // over: the requested start, not the medication's creation. + expect(json.data.from).toBe(from); } finally { vi.useRealTimers(); } @@ -410,6 +413,9 @@ describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", status: string; intake: { id: string | null } | null; }>; + // `to` reports how far recorded doses were read: up to the early + // dose's slot, past the request's own now. + expect(json.data.to).toBe(slot.toISOString()); const early = rows.find((r) => r.intake?.id === "evt-early"); expect(early?.status).toBe("taken_on_time"); // The read horizon stretches only to the recorded anchor: no later diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 11256b61f..8b1dae088 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -313,9 +313,13 @@ export const GET = apiHandler( meta: { row_count: serialized.length, family }, }); + // The window the recorded doses were read over (#1028): from the + // requested start (slots additionally start at the medication's + // creation) to the read horizon, which reaches past `to` only for a dose + // recorded ahead of its slot. return apiSuccess({ - from: from.toISOString(), - to: to.toISOString(), + from: intakeFrom.toISOString(), + to: horizon.toISOString(), family, hasExpectedSlots: bands.length > 0, rows: serialized, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 02b1a3908..47a2bd6e3 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -911,12 +911,14 @@ export const doseHistoryQuery = z.object({ .datetime({ offset: true }) .optional() .describe( - "Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a 366-day span floor.", + "Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant; expected slots are not minted before the medication's `createdAt`.", ), to: z.iso .datetime({ offset: true }) .optional() - .describe("Window end (inclusive). Defaults to now. Must be ≥ `from`."), + .describe( + "Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's time within a day after `to`) is still returned.", + ), }); const doseHistoryRow = z @@ -982,8 +984,16 @@ const doseHistoryRow = z export const doseHistoryResponse = z .object({ - from: z.iso.datetime({ offset: true }), - to: z.iso.datetime({ offset: true }), + from: z.iso + .datetime({ offset: true }) + .describe( + "Start of the window recorded doses were read over: the requested `from` after the span clamp.", + ), + to: z.iso + .datetime({ offset: true }) + .describe( + "End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded ahead of its slot when that lies later (at most one day after `to`).", + ), family: z .enum(["daily", "weekly", "one_shot", "none"]) .describe("Cadence family the window's slots were minted under."),