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

+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": {