mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(visits): cap the window before a visit at the longest report
The window before a booked visit started at the last matching visit however far back it lay, while the health-report export covers 730 days and fell back to its 90-day default for anything longer. The card then offered a report the export did not build. The window now stops at the export's limit, read from one shared constant, and preparation.capped says when it did; the web says "The last two years" for it. The export clamps an over-long explicit range to the last 730 days before its end instead of falling back to 90 days, so a custom range or a linked window cannot shrink silently either, and its day count forgives the hour a clock change adds, so two years of local days count as 730.
This commit is contained in:
1 parent
f39598f2e7
commit
da3d29424d
22 files changed
+282
-32
No files matched your search
@@ -29,6 +29,15 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The report offered for a visit is the report you get.** When the last
|
||||
matching visit was more than two years ago, the next-visit card offered a
|
||||
report from that day, but a report covers two years at most, and the
|
||||
export quietly made a 90-day report instead. The window before a visit
|
||||
now stops at two years and says "The last two years", and the visit list
|
||||
marks it with `preparation.capped`. A report range longer than two years,
|
||||
however it is asked for, now covers the last two years of it rather than
|
||||
falling back to 90 days. (Two years across a clock change also no longer
|
||||
count as one day too many.)
|
||||
- **PDF previews show in Safari.** On macOS and iOS the preview of an
|
||||
uploaded PDF stayed an empty box. The page's security policy told the
|
||||
browser that nothing may frame it, and Safari applied that to the
|
||||
|
||||
@@ -78505,7 +78505,8 @@ components:
|
||||
from:
|
||||
type: string
|
||||
description: "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before
|
||||
today on `default`."
|
||||
today on `default`. Never more than 730 days back from `to`, both days counted: the longest window the
|
||||
health-report export covers (see `capped`)."
|
||||
to:
|
||||
type: string
|
||||
description: The booked visit's own day, `YYYY-MM-DD` on the same calendar as `from` (the record's, not the reader's). A
|
||||
@@ -78525,11 +78526,17 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/EncounterPreparationAnchor"
|
||||
- type: "null"
|
||||
capped:
|
||||
type: boolean
|
||||
description: True when `since` lies further back than a health report covers, so `from` is the first day of the last 730
|
||||
days ending on `to` instead of the day of `since`. A client says the window covers the last two years rather
|
||||
than everything since that visit; a report for `from` to `to` is the one the export builds.
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
- basis
|
||||
- since
|
||||
- capped
|
||||
additionalProperties: false
|
||||
description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report
|
||||
offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Seit deinem letzten Termin ({specialty}) am {date}",
|
||||
"kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}",
|
||||
"default": "Die letzten {days} Tage",
|
||||
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art."
|
||||
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.",
|
||||
"capped": "Die letzten zwei Jahre",
|
||||
"cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Since your last {specialty} visit on {date}",
|
||||
"kind": "Since your last visit of the same kind ({kind}) on {date}",
|
||||
"default": "The last {days} days",
|
||||
"defaultReason": "No earlier visit with the same practice, specialty or kind."
|
||||
"defaultReason": "No earlier visit with the same practice, specialty or kind.",
|
||||
"capped": "The last two years",
|
||||
"cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Desde tu última visita ({specialty}) el {date}",
|
||||
"kind": "Desde tu última visita del mismo tipo ({kind}) el {date}",
|
||||
"default": "Los últimos {days} días",
|
||||
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo."
|
||||
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.",
|
||||
"capped": "Los últimos dos años",
|
||||
"cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Depuis votre dernière visite ({specialty}) le {date}",
|
||||
"kind": "Depuis votre dernière visite du même type ({kind}) le {date}",
|
||||
"default": "Les {days} derniers jours",
|
||||
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type."
|
||||
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.",
|
||||
"capped": "Les deux dernières années",
|
||||
"cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Dalla tua ultima visita ({specialty}) del {date}",
|
||||
"kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}",
|
||||
"default": "Gli ultimi {days} giorni",
|
||||
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo."
|
||||
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.",
|
||||
"capped": "Gli ultimi due anni",
|
||||
"cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "{date} 마지막 {specialty} 방문 이후",
|
||||
"kind": "{date} 같은 유형({kind})의 마지막 방문 이후",
|
||||
"default": "최근 {days}일",
|
||||
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요."
|
||||
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
|
||||
"capped": "최근 2년",
|
||||
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Sinds je laatste bezoek ({specialty}) op {date}",
|
||||
"kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}",
|
||||
"default": "De afgelopen {days} dagen",
|
||||
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort."
|
||||
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.",
|
||||
"capped": "De afgelopen twee jaar",
|
||||
"cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Od ostatniej wizyty ({specialty}) {date}",
|
||||
"kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}",
|
||||
"default": "Ostatnie {days} dni",
|
||||
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju."
|
||||
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.",
|
||||
"capped": "Ostatnie dwa lata",
|
||||
"cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "Desde a sua última consulta de {specialty}, em {date}",
|
||||
"kind": "Desde a sua última consulta do mesmo tipo ({kind}), em {date}",
|
||||
"default": "Os últimos {days} dias",
|
||||
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo."
|
||||
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.",
|
||||
"capped": "Os últimos dois anos",
|
||||
"cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
+3
-1
@@ -13339,7 +13339,9 @@
|
||||
"specialty": "С вашего последнего визита к специалисту «{specialty}» {date}",
|
||||
"kind": "С вашего последнего визита того же вида ({kind}) {date}",
|
||||
"default": "Последние {days} дней",
|
||||
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом."
|
||||
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.",
|
||||
"capped": "Последние два года",
|
||||
"cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
|
||||
@@ -197,6 +197,7 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
to: "2026-12-01",
|
||||
basis: "practitioner",
|
||||
since: sameDoctor,
|
||||
capped: false,
|
||||
},
|
||||
});
|
||||
const unprepared = visit({ ...next, preparation: null });
|
||||
@@ -346,6 +347,7 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
to: "2026-12-01",
|
||||
basis: "default",
|
||||
since: null,
|
||||
capped: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -22,7 +22,10 @@ type Translate = (
|
||||
export interface PreparationText {
|
||||
/** Where the window starts: "Since your last visit with … on …". */
|
||||
title: string;
|
||||
/** Why, when the title alone does not say: only on the default window. */
|
||||
/**
|
||||
* Why, when the title alone does not say: on the default window, and on a
|
||||
* window capped at the longest report.
|
||||
*/
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
@@ -33,6 +36,14 @@ export function preparationText(
|
||||
): PreparationText {
|
||||
const date = formatDay(preparation.from);
|
||||
const since = preparation.since;
|
||||
// The matched visit is further back than a report covers: the window is
|
||||
// the last two years, and the title must not promise everything since.
|
||||
if (preparation.capped) {
|
||||
return {
|
||||
title: t("encounters.preparation.capped"),
|
||||
reason: t("encounters.preparation.cappedReason"),
|
||||
};
|
||||
}
|
||||
switch (preparation.basis) {
|
||||
case "practitioner":
|
||||
return {
|
||||
|
||||
@@ -127,6 +127,7 @@ describe("<ReportVisitOffer>", () => {
|
||||
practitionerName: "Former skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
capped: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -144,6 +145,7 @@ describe("<ReportVisitOffer>", () => {
|
||||
to: "2026-12-01",
|
||||
basis: "default",
|
||||
since: null,
|
||||
capped: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -153,6 +155,31 @@ describe("<ReportVisitOffer>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("says the last two years on a window capped at the longest report", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "derm",
|
||||
preparation: {
|
||||
from: "2024-12-02",
|
||||
to: "2026-12-01",
|
||||
basis: "practitioner",
|
||||
since: {
|
||||
id: "old-derm",
|
||||
occurredAt: "2022-03-02T09:00:00.000Z",
|
||||
kind: "SPECIALIST",
|
||||
practitionerName: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
capped: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("The last two years");
|
||||
expect(html).toContain("a report covers two years at most");
|
||||
// Not "since your last visit", which would promise the whole stretch.
|
||||
expect(html).not.toContain("Since your last visit");
|
||||
});
|
||||
|
||||
it("applies the server's start and the appointment's day on a press", () => {
|
||||
const at = new Date(Date.now() + 5 * DAY_MS);
|
||||
const window = visitReportWindow(
|
||||
@@ -164,6 +191,7 @@ describe("<ReportVisitOffer>", () => {
|
||||
to: "2026-12-01",
|
||||
basis: "default",
|
||||
since: null,
|
||||
capped: false,
|
||||
},
|
||||
}),
|
||||
"Europe/Berlin",
|
||||
@@ -188,6 +216,7 @@ describe("<ReportVisitOffer>", () => {
|
||||
to: "2026-11-12",
|
||||
basis: "default",
|
||||
since: null,
|
||||
capped: false,
|
||||
},
|
||||
});
|
||||
expect(visitReportWindow(early, "America/New_York")).toEqual({
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("normaliseDateRange", () => {
|
||||
expect(range.days).toBe(90);
|
||||
});
|
||||
|
||||
it("falls back when range exceeds 730 days", () => {
|
||||
it("clamps a range longer than 730 days to the last 730 before its end", () => {
|
||||
const range = normaliseDateRange(
|
||||
{
|
||||
startDate: "2023-01-01T00:00:00.000Z",
|
||||
@@ -134,7 +134,25 @@ describe("normaliseDateRange", () => {
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(range.days).toBe(90);
|
||||
// Not the 90-day default: the end stays, the start moves up.
|
||||
expect(range.days).toBe(730);
|
||||
expect(range.end.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
||||
expect(range.start.toISOString()).toBe("2024-01-02T00:00:00.001Z");
|
||||
});
|
||||
|
||||
it("counts 730 local days as 730 across a net autumn clock change", () => {
|
||||
// 2024-10-26 .. 2026-10-25 in Berlin, cut the way the panel cuts it:
|
||||
// the first day starts in summer time, the last ends in winter time, so
|
||||
// the span runs an hour past 730 times 24 hours.
|
||||
const start = new Date("2024-10-25T22:00:00.000Z");
|
||||
const end = new Date("2026-10-25T22:59:59.999Z");
|
||||
expect(end.getTime() - start.getTime()).toBeGreaterThan(730 * 86_400_000);
|
||||
const range = normaliseDateRange(
|
||||
{ startDate: start.toISOString(), endDate: end.toISOString() },
|
||||
NOW,
|
||||
);
|
||||
expect(range.start.toISOString()).toBe(start.toISOString());
|
||||
expect(range.days).toBe(730);
|
||||
});
|
||||
|
||||
it("accepts the maximum 730-day window", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* itself stays in `doctor-report-data.ts`.
|
||||
*/
|
||||
|
||||
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
@@ -247,20 +248,24 @@ export function normaliseDays(value: unknown, fallback = 90): number {
|
||||
}
|
||||
|
||||
const MIN_RANGE_DAYS = 1;
|
||||
/**
|
||||
* Maximum span between `startDate` and `endDate`. Two years balances the
|
||||
* "give me everything I have" case (chronic condition follow-up) against
|
||||
* unbounded server work; the existing `days` fallback is capped at 365 so a
|
||||
* caller asking for a 730-day window must come through the explicit-range
|
||||
* surface and proves they want it.
|
||||
*/
|
||||
const MAX_RANGE_DAYS = 730;
|
||||
// The maximum span between `startDate` and `endDate` is
|
||||
// REPORT_MAX_RANGE_DAYS (730). Two years balances the "give me everything I
|
||||
// have" case (chronic condition follow-up) against unbounded server work;
|
||||
// the `days` fallback is capped at 365, so a 730-day window has to come
|
||||
// through the explicit range.
|
||||
const DEFAULT_RANGE_DAYS = 90;
|
||||
const DAY_MS = 86_400_000;
|
||||
/**
|
||||
* A local day lasts 23 to 25 hours, so a range of whole local days can run
|
||||
* an hour past the same number of 24-hour days. The day count forgives that
|
||||
* hour: two years of local days are 730 days, not 731.
|
||||
*/
|
||||
const DST_SLACK_MS = 3_600_000;
|
||||
|
||||
/**
|
||||
* Default ceiling on the number of `MedicationAdministration` source rows
|
||||
* materialised per export, and the bounds an operator override is clamped
|
||||
* to. The report window is already bounded at {@link MAX_RANGE_DAYS}
|
||||
* to. The report window is already bounded at {@link REPORT_MAX_RANGE_DAYS}
|
||||
* (730 days), but a chronic medication dosed several times a day across
|
||||
* that window — multiplied by several such medications — can still produce
|
||||
* thousands of administration resources in a single Bundle. The default of
|
||||
@@ -321,7 +326,10 @@ function parseIsoDate(value: unknown): Date | null {
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with
|
||||
* `endDate >= startDate` and span <= 730 days → use them.
|
||||
* `endDate >= startDate` and span <= 730 days → use them. A longer span
|
||||
* keeps its end and is clamped to the last 730 days before it; it used
|
||||
* to fall through to the 90-day default, a much shorter report than the
|
||||
* one asked for. The cover line prints the window the report covers.
|
||||
* 2. Neither/invalid range, but a valid `days` integer (1..365) is provided
|
||||
* → fall back to "last `days` days ending now".
|
||||
* 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default).
|
||||
@@ -346,8 +354,16 @@ export function normaliseDateRange(
|
||||
const endMs = endCandidate.getTime();
|
||||
if (endMs >= startMs) {
|
||||
const spanMs = endMs - startMs;
|
||||
const spanDays = Math.ceil(spanMs / 86_400_000) || 1;
|
||||
if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) {
|
||||
// `|| 1` also turns the -0 of a span shorter than the slack into one.
|
||||
const spanDays = Math.ceil((spanMs - DST_SLACK_MS) / DAY_MS) || 1;
|
||||
if (spanDays > REPORT_MAX_RANGE_DAYS) {
|
||||
return {
|
||||
start: new Date(endMs - REPORT_MAX_RANGE_DAYS * DAY_MS + 1),
|
||||
end: endCandidate,
|
||||
days: REPORT_MAX_RANGE_DAYS,
|
||||
};
|
||||
}
|
||||
if (spanDays >= MIN_RANGE_DAYS) {
|
||||
return {
|
||||
start: startCandidate,
|
||||
end: endCandidate,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The longest window a health report covers, in days, inclusive of both end
|
||||
* days. The export clamps a longer range to the last this many days ending
|
||||
* on its end (`normaliseDateRange`), and the window offered before a booked
|
||||
* visit is capped at it (`resolvePreparation`), so the visit card never
|
||||
* promises a report the export will not produce.
|
||||
*
|
||||
* Client-safe: no server import.
|
||||
*/
|
||||
export const REPORT_MAX_RANGE_DAYS = 730;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
import {
|
||||
PREPARATION_DEFAULT_DAYS,
|
||||
resolvePreparation,
|
||||
@@ -76,6 +78,7 @@ describe("the window before a booked visit", () => {
|
||||
practitionerName: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
capped: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +106,7 @@ describe("the window before a booked visit", () => {
|
||||
to: "2026-10-23",
|
||||
basis: "default",
|
||||
since: null,
|
||||
capped: false,
|
||||
});
|
||||
expect(PREPARATION_DEFAULT_DAYS).toBe(90);
|
||||
});
|
||||
@@ -152,6 +156,35 @@ describe("the window before a booked visit", () => {
|
||||
expect(prep.since?.id).toBe("derm-a");
|
||||
});
|
||||
|
||||
it("caps a window older than a report covers, still naming its visit", () => {
|
||||
const derm = (id: string, at: string) =>
|
||||
visit(id, at, {
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: "p-derm",
|
||||
name: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
});
|
||||
const prep = resolve(next, [derm("old", "2023-04-03T09:00:00.000Z")]);
|
||||
expect(prep).toMatchObject({
|
||||
from: shiftDateKey("2026-10-23", -(REPORT_MAX_RANGE_DAYS - 1)),
|
||||
to: "2026-10-23",
|
||||
basis: "practitioner",
|
||||
capped: true,
|
||||
since: { id: "old", occurredAt: "2023-04-03T09:00:00.000Z" },
|
||||
});
|
||||
expect(daysBetweenDateKeys(prep.from, prep.to) + 1).toBe(
|
||||
REPORT_MAX_RANGE_DAYS,
|
||||
);
|
||||
// One day inside the limit is left as it is.
|
||||
const edge = shiftDateKey("2026-10-23", -(REPORT_MAX_RANGE_DAYS - 1));
|
||||
expect(
|
||||
resolve(next, [derm("edge", `${edge}T09:00:00.000Z`)]),
|
||||
).toMatchObject({ from: edge, capped: false });
|
||||
expect(
|
||||
resolve(next, [derm("over", `${shiftDateKey(edge, -1)}T09:00:00.000Z`)]),
|
||||
).toMatchObject({ from: edge, capped: true });
|
||||
});
|
||||
|
||||
it("only counts visits before the booked one", () => {
|
||||
const later = visit("later", "2026-10-24T09:00:00.000Z", {
|
||||
practitionerId: "p-derm",
|
||||
|
||||
@@ -26,7 +26,11 @@
|
||||
* is no better a start than the default.
|
||||
*
|
||||
* The window starts on the matched visit's own day: a dose changed at that
|
||||
* visit belongs to what the next one should know.
|
||||
* visit belongs to what the next one should know. It covers at most
|
||||
* {@link REPORT_MAX_RANGE_DAYS} days, the longest report the export
|
||||
* produces: a matched visit further back moves `from` to the first day of
|
||||
* that stretch and sets `capped`, so the report offered for the visit is the
|
||||
* one the export builds.
|
||||
*
|
||||
* Resolved on the server, published on the visit list, never recomputed by a
|
||||
* client. A person can still change both dates on the report.
|
||||
@@ -34,7 +38,8 @@
|
||||
* Pure and client-safe; the read lives in `preparation-read.ts`.
|
||||
*/
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
|
||||
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/** The window when no earlier visit matches the booked one. */
|
||||
export const PREPARATION_DEFAULT_DAYS = 90;
|
||||
@@ -72,8 +77,17 @@ export interface EncounterPreparationDTO {
|
||||
to: string;
|
||||
/** Which rule chose `from`. */
|
||||
basis: PreparationBasis;
|
||||
/** The visit `from` is the day of; null on `default`. */
|
||||
/**
|
||||
* The visit the window is measured from; null on `default`. `from` is its
|
||||
* day unless `capped`.
|
||||
*/
|
||||
since: PreparationAnchorDTO | null;
|
||||
/**
|
||||
* True when `since` lies further back than a report covers and `from` was
|
||||
* moved to the first day of the last {@link REPORT_MAX_RANGE_DAYS} days
|
||||
* before `to`.
|
||||
*/
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
/** The fields the rule reads, off a booked visit or an earlier one. */
|
||||
@@ -140,10 +154,14 @@ export function resolvePreparation(args: {
|
||||
for (const [basis, matches] of rules) {
|
||||
const hit = earlier.find(matches);
|
||||
if (!hit) continue;
|
||||
const day = userDayKey(hit.occurredAt, tz);
|
||||
// Both end days count: `from` to `to` spans at most the report's days.
|
||||
const capped = daysBetweenDateKeys(day, to) > REPORT_MAX_RANGE_DAYS - 1;
|
||||
return {
|
||||
from: userDayKey(hit.occurredAt, tz),
|
||||
from: capped ? shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1)) : day,
|
||||
to,
|
||||
basis,
|
||||
capped,
|
||||
since: {
|
||||
id: hit.id,
|
||||
occurredAt: hit.occurredAt.toISOString(),
|
||||
@@ -158,5 +176,6 @@ export function resolvePreparation(args: {
|
||||
to,
|
||||
basis: "default",
|
||||
since: null,
|
||||
capped: false,
|
||||
};
|
||||
}
|
||||
@@ -189,7 +189,7 @@ const encounterPreparation = z
|
||||
from: z
|
||||
.string()
|
||||
.describe(
|
||||
"First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before today on `default`.",
|
||||
"First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before today on `default`. Never more than 730 days back from `to`, both days counted: the longest window the health-report export covers (see `capped`).",
|
||||
),
|
||||
to: z
|
||||
.string()
|
||||
@@ -202,6 +202,11 @@ const encounterPreparation = z
|
||||
"The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days).",
|
||||
),
|
||||
since: preparationAnchor.nullable(),
|
||||
capped: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when `since` lies further back than a health report covers, so `from` is the first day of the last 730 days ending on `to` instead of the day of `since`. A client says the window covers the last two years rather than everything since that visit; a report for `from` to `to` is the one the export builds.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "EncounterPreparation",
|
||||
|
||||
@@ -14,8 +14,11 @@ process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { GET as listEncounters } from "@/app/api/encounters/route";
|
||||
import { normaliseDateRange } from "@/lib/doctor-report-helpers";
|
||||
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
|
||||
import type { EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -147,6 +150,7 @@ describe("GET /api/encounters — the window before a booked visit", () => {
|
||||
practitionerName: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
capped: false,
|
||||
});
|
||||
// Only a booked visit carries one.
|
||||
expect(body.past.every((v) => v.preparation === null)).toBe(true);
|
||||
@@ -207,6 +211,7 @@ describe("GET /api/encounters — the window before a booked visit", () => {
|
||||
to: berlinDay(next.occurredAt),
|
||||
basis: "default",
|
||||
since: null,
|
||||
capped: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,6 +235,72 @@ describe("GET /api/encounters — the window before a booked visit", () => {
|
||||
).toBe(anchor.id);
|
||||
});
|
||||
|
||||
it("caps a window older than a report covers, and the export builds that report", async () => {
|
||||
const derm = await practitioner("Skin practice", "Dermatology");
|
||||
// Three years back: further than the 730 days a report covers.
|
||||
const anchor = await encounter({
|
||||
occurredAt: daysFromNow(-1100),
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
const next = await encounter({
|
||||
occurredAt: daysFromNow(6),
|
||||
status: "PLANNED",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
|
||||
const prep = (await list()).upcoming.find(
|
||||
(v) => v.id === next.id,
|
||||
)?.preparation;
|
||||
const to = berlinDay(next.occurredAt);
|
||||
expect(prep).toMatchObject({
|
||||
from: shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1)),
|
||||
to,
|
||||
basis: "practitioner",
|
||||
capped: true,
|
||||
});
|
||||
// The anchor is still named: the window is measured from it.
|
||||
expect(prep?.since?.id).toBe(anchor.id);
|
||||
expect(daysBetweenDateKeys(prep!.from, to) + 1).toBe(REPORT_MAX_RANGE_DAYS);
|
||||
|
||||
// The report the panel requests for that window, cut the way it cuts
|
||||
// it: from the first instant of `from` to the last of `to`. The export
|
||||
// takes it whole instead of falling back to its 90-day default.
|
||||
const startDate = startOfLocalDayKey(prep!.from, "Europe/Berlin");
|
||||
const endDate = new Date(
|
||||
startOfLocalDayKey(shiftDateKey(to, 1), "Europe/Berlin").getTime() - 1,
|
||||
);
|
||||
const range = normaliseDateRange({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
expect(range.start.toISOString()).toBe(startDate.toISOString());
|
||||
expect(range.end.toISOString()).toBe(endDate.toISOString());
|
||||
expect(range.days).toBe(REPORT_MAX_RANGE_DAYS);
|
||||
});
|
||||
|
||||
it("leaves a window that fits a report as it is", async () => {
|
||||
const derm = await practitioner("Skin practice", "Dermatology");
|
||||
const next = await encounter({
|
||||
occurredAt: daysFromNow(6),
|
||||
status: "PLANNED",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
const to = berlinDay(next.occurredAt);
|
||||
// Exactly the longest report, both end days counted.
|
||||
const anchorDay = shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1));
|
||||
await encounter({
|
||||
occurredAt: new Date(
|
||||
startOfLocalDayKey(anchorDay, "Europe/Berlin").getTime() +
|
||||
10 * 60 * 60 * 1000,
|
||||
),
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
const prep = (await list()).upcoming.find(
|
||||
(v) => v.id === next.id,
|
||||
)?.preparation;
|
||||
expect(prep).toMatchObject({ from: anchorDay, capped: false });
|
||||
});
|
||||
|
||||
it("leaves a cancelled upcoming visit without a window", async () => {
|
||||
const cancelled = await encounter({
|
||||
occurredAt: daysFromNow(5),
|
||||
|
||||
Reference in new issue
Block a user