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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 16:25:26 +02:00
1 parent 6bdd6f1c58
commit bc2d237530
6 files changed
+136 -15

No files matched your search

+11 -6
View File
@@ -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:
+3 -2
View File
@@ -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
+57 -4
View File
@@ -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<string | null> {
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<DayNotableWindowResponse> {
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));
+2 -2
View File
@@ -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
+1 -1
View File
@@ -84,7 +84,7 @@ export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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": {
+62
View File
@@ -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", () => {