From bc2d2375307ccca69ae4c55b39ebad053d41907f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 16:25:26 +0200 Subject: [PATCH] fix(visits): anchor the notable window at the next visit's preparation Without from, GET /api/day/notable started at the last completed visit of any kind, while the web passes the next planned visit's preparation.from, so a client that omits from read a different list for the same appointment. Without from the window now starts at that preparation day (anchor nextVisit, documented in OpenAPI); with nothing booked it keeps the last completed visit or the 90-day fallback. --- docs/api/openapi.yaml | 17 +++++--- src/app/api/day/notable/route.ts | 5 ++- src/lib/day/visit-preparation.ts | 61 +++++++++++++++++++++++++++-- src/lib/day/wire-schemas.ts | 4 +- src/lib/openapi/routes/day.ts | 2 +- tests/integration/day-view.test.ts | 62 ++++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a996574f2..1d1ad431c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -24180,10 +24180,11 @@ paths: description: "What stood out and what changed in a window, for the preparation of the next visit: deterministic observations (a value highest or lowest for at least three months, a first reading, a regular reading that stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, - illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window starts at the last - completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and - the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's - grant does not cover are left out." + illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window is the one the + next planned visit prepares (its `preparation.from` on `GET /api/encounters`, `anchor: nextVisit`); with nothing + booked it starts at the last completed visit the caller may see (else 90 days back); without `to` it ends today; + at most 1096 days. Keys and the record's own names only, no judgement. Days are cut in the record's own time + zone. Sections the caller's grant does not cover are left out." parameters: - in: query name: from @@ -85880,11 +85881,15 @@ components: anchor: type: string enum: + - nextVisit - lastVisit - requested - fallback - description: "Where `from` came from: the last completed visit, the request, or the fallback of 90 days when the record - holds no visit the caller may see." + description: "Where `from` came from. `requested`: the request named it. `nextVisit`: no `from` was sent and a visit is + booked; `from` is that visit's `preparation.from` on `GET /api/encounters` (the soonest PLANNED visit + ahead), so a client that omits `from` reads the window the web shows for it. `lastVisit`: nothing is booked; + the day of the last completed visit. `fallback`: 90 days, when the record holds no visit the caller may see. + Clients should treat an unknown value like `fallback`." observations: type: array items: diff --git a/src/app/api/day/notable/route.ts b/src/app/api/day/notable/route.ts index 74cc8ec9f..685eddcc3 100644 --- a/src/app/api/day/notable/route.ts +++ b/src/app/api/day/notable/route.ts @@ -1,8 +1,9 @@ /** * `GET /api/day/notable?from=&to=` — what changed and what stood out in a * window, for the preparation of the next visit (v1.42, #613). Without - * `from` the window starts at the last completed visit the caller may see; - * without `to` it ends today. The shape is `DayNotableWindowResponse` in + * `from` the window is the one the next planned visit prepares (its + * `preparation.from`), else it starts at the last completed visit the caller + * may see; without `to` it ends today. The shape is `DayNotableWindowResponse` in * `src/lib/day/contract.ts`. * * Observations and changes only, as keys and the record's own names: no diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts index 7040dc585..f3ab26c74 100644 --- a/src/lib/day/visit-preparation.ts +++ b/src/lib/day/visit-preparation.ts @@ -27,6 +27,7 @@ import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { visitPath } from "@/lib/encounters/visit-links"; +import { loadVisitPreparations } from "@/lib/encounters/preparation-read"; /** The window when the record holds no visit the caller may see. */ export const FALLBACK_WINDOW_DAYS = 90; @@ -339,6 +340,43 @@ async function lastVisitDay( return row ? userDayKey(row.occurredAt, tz) : null; } +/** + * The first day of the window the next planned visit prepares, exactly as + * `EncounterDTO.preparation.from` publishes it on that visit: the same visit + * (the soonest PLANNED one still ahead) and the same rule + * (`loadVisitPreparations`). Null when nothing is booked. + */ +async function nextVisitPreparationFrom( + recordId: string, + now: Date, +): Promise { + const next = await prisma.encounter.findFirst({ + where: { + userId: recordId, + deletedAt: null, + status: "PLANNED", + occurredAt: { gt: now }, + }, + select: { + id: true, + occurredAt: true, + kind: true, + status: true, + practitionerId: true, + practitioner: { select: { name: true, specialty: true } }, + }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }], + }); + if (!next) return null; + const preparations = await loadVisitPreparations( + prisma, + recordId, + [next], + now, + ); + return preparations.get(next.id)?.from ?? null; +} + export async function loadSinceLastVisit(args: { recordId: string; access: DayAccess; @@ -346,6 +384,7 @@ export async function loadSinceLastVisit(args: { today: string; from?: string; to?: string; + now?: Date; }): Promise { const { recordId, access, tz } = args; const to = args.to ?? args.today; @@ -355,11 +394,25 @@ export async function loadSinceLastVisit(args: { from = args.from; anchor = "requested"; } else { - const last = access.readable.has("visits") - ? await lastVisitDay(recordId, tz, startOfLocalDayKey(to, tz)) + // Without `from`, the window the web shows: the one the next planned + // visit prepares. The web passes that day as `from`; a client that asks + // without one (the shipped iOS app) got the window since the last + // completed visit of any kind instead, a different list for the same + // appointment. Without a booked visit, the last completed one. + const visitsReadable = access.readable.has("visits"); + const prepared = visitsReadable + ? await nextVisitPreparationFrom(recordId, args.now ?? new Date()) : null; - from = last ?? shiftDateKey(to, -FALLBACK_WINDOW_DAYS); - anchor = last ? "lastVisit" : "fallback"; + if (prepared !== null && prepared <= to) { + from = prepared; + anchor = "nextVisit"; + } else { + const last = visitsReadable + ? await lastVisitDay(recordId, tz, startOfLocalDayKey(to, tz)) + : null; + from = last ?? shiftDateKey(to, -FALLBACK_WINDOW_DAYS); + anchor = last ? "lastVisit" : "fallback"; + } } if (daysBetweenDateKeys(from, to) >= DAY_NOTABLE_MAX_SPAN_DAYS) { from = shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)); diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 150c5ad7e..637327877 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -332,9 +332,9 @@ export const dayNotableResponseSchema = z from: dateKeySchema, to: dateKeySchema, anchor: z - .enum(["lastVisit", "requested", "fallback"]) + .enum(["nextVisit", "lastVisit", "requested", "fallback"]) .describe( - "Where `from` came from: the last completed visit, the request, or the fallback of 90 days when the record holds no visit the caller may see.", + "Where `from` came from. `requested`: the request named it. `nextVisit`: no `from` was sent and a visit is booked; `from` is that visit's `preparation.from` on `GET /api/encounters` (the soonest PLANNED visit ahead), so a client that omits `from` reads the window the web shows for it. `lastVisit`: nothing is booked; the day of the last completed visit. `fallback`: 90 days, when the record holds no visit the caller may see. Clients should treat an unknown value like `fallback`.", ), observations: z.array( z diff --git a/src/lib/openapi/routes/day.ts b/src/lib/openapi/routes/day.ts index 3fb3230af..d0a4e6593 100644 --- a/src/lib/openapi/routes/day.ts +++ b/src/lib/openapi/routes/day.ts @@ -84,7 +84,7 @@ export const dayPaths: NonNullable = { tags: ["Records"], summary: "Since the last visit", description: - "What stood out and what changed in a window, for the preparation of the next visit: deterministic observations (a value highest or lowest for at least three months, a first reading, a regular reading that stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window starts at the last completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's grant does not cover are left out.", + "What stood out and what changed in a window, for the preparation of the next visit: deterministic observations (a value highest or lowest for at least three months, a first reading, a regular reading that stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window is the one the next planned visit prepares (its `preparation.from` on `GET /api/encounters`, `anchor: nextVisit`); with nothing booked it starts at the last completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's grant does not cover are left out.", requestParams: { query: dayNotableQuerySchema }, responses: { "200": { diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index 3486d2907..173db8466 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -881,6 +881,68 @@ describe("notable days", () => { ["2026-04-01", "gap"], ]); }); + it("anchors a window without `from` where the next planned visit's preparation starts", async () => { + // The web passes `preparation.from` of the next booked visit; a client + // that sends no `from` (the shipped iOS app) must read the same window, + // not the one since the last completed visit of any kind. + const db = getPrismaClient(); + const owner = await makeUser("notable-next"); + const cardiologist = await db.practitioner.create({ + data: { userId: owner.id, name: "Practice A", specialty: "Cardiology" }, + }); + const now = Date.now(); + const daysAgo = (n: number) => new Date(now - n * 86_400_000); + // The last cardiology visit, and a later routine one of another kind. + await db.encounter.create({ + data: { + userId: owner.id, + occurredAt: daysAgo(60), + status: "DONE", + kind: "SPECIALIST", + practitionerId: cardiologist.id, + }, + }); + await db.encounter.create({ + data: { + userId: owner.id, + occurredAt: daysAgo(10), + status: "DONE", + kind: "ROUTINE", + }, + }); + const next = await db.encounter.create({ + data: { + userId: owner.id, + occurredAt: new Date(now + 7 * 86_400_000), + status: "PLANNED", + kind: "SPECIALIST", + practitionerId: cardiologist.id, + }, + }); + await signIn(owner.id); + + const { GET: list } = await import("@/app/api/encounters/route"); + const visits = await json<{ + upcoming: Array<{ id: string; preparation: { from: string } | null }>; + }>(await call(list as Handler, "GET", "/api/encounters")); + const prepared = visits.upcoming.find((v) => v.id === next.id)!.preparation; + expect(prepared).not.toBeNull(); + + const { GET: notable } = await import("@/app/api/day/notable/route"); + const window = await json<{ from: string; anchor: string }>( + await call(notable as Handler, "GET", "/api/day/notable"), + ); + expect(window).toMatchObject({ anchor: "nextVisit", from: prepared!.from }); + // With nothing booked, the last completed visit again. + await db.encounter.update({ + where: { id: next.id }, + data: { status: "CANCELLED" }, + }); + const afterCancel = await json<{ anchor: string }>( + await call(notable as Handler, "GET", "/api/day/notable"), + ); + expect(afterCancel.anchor).toBe("lastVisit"); + }); }); describe("timeline", () => {