mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(vaccinations): show the planned booster's renewal state on the log
Whether a vaccination is still current was known only on the checkups page. The vaccination log now shows it on each antigen's heading: the booster the person planned when they logged a dose, marked as overdue, due soon (within 30 days) or scheduled, tinted only in the first two cases. The state is resolved on the server from the person's own confirmed booster reminder and published on the list response as renewals, so the web page and the native client read the same answer. Nothing is worked out from age, history or the catalogue: an antigen without a planned booster shows no state. Renewals are null for a grant that does not reach the measurements section the reminders live in. Refs #1005
This commit is contained in:
1 parent
b480ac4543
commit
15d6251ad2
17 files changed
+672
-15
No files matched your search
@@ -60594,8 +60594,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Vaccination"
|
||||
renewals:
|
||||
anyOf:
|
||||
- type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/VaccinationRenewal"
|
||||
- type: "null"
|
||||
description: Each antigen's renewal state, earliest due first. Null when the caller acts under a grant that does not
|
||||
cover the measurements section the booster reminders live in.
|
||||
required:
|
||||
- vaccinations
|
||||
- renewals
|
||||
additionalProperties: false
|
||||
description: The caller's immunization history, newest dose first, with every entry's series already resolved over the
|
||||
whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever
|
||||
@@ -60831,6 +60840,37 @@ components:
|
||||
grant does not cover the document vault: a dose lives in the health background but points into the vault, and a
|
||||
page's filename is itself the sensitive part. The link is still listed — the caller may know the dose was
|
||||
transcribed from a page — and `redacted` tells a withheld name apart from a page that simply has no date."
|
||||
VaccinationRenewal:
|
||||
type: object
|
||||
properties:
|
||||
antigen:
|
||||
type: string
|
||||
reminderId:
|
||||
type: string
|
||||
dueAt:
|
||||
type: string
|
||||
daysUntil:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- current
|
||||
- dueSoon
|
||||
- overdue
|
||||
required:
|
||||
- antigen
|
||||
- reminderId
|
||||
- dueAt
|
||||
- daysUntil
|
||||
- state
|
||||
additionalProperties: false
|
||||
description: "One antigen's renewal state, read from the booster reminder the person confirmed for it (`dueAt` is that
|
||||
reminder's next due instant). `daysUntil` counts calendar days on the person's own clock and is negative once
|
||||
the date has passed. `state` is `overdue` once it has passed, `dueSoon` within 30 days, `current` otherwise.
|
||||
Render from `state`; do not recompute it. Nothing here is derived from age, history or the catalogue: an antigen
|
||||
without a confirmed booster has no entry."
|
||||
CreateVaccinationEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -9034,6 +9034,11 @@
|
||||
"mmrv": "MMRV (Masern, Mumps, Röteln, Varizellen)",
|
||||
"hepa-hepb": "Hepatitis A und B",
|
||||
"hepa-typhoid": "Hepatitis A und Typhus"
|
||||
},
|
||||
"renewal": {
|
||||
"overdue": "Auffrischung fällig seit {date}",
|
||||
"dueSoon": "Auffrischung fällig am {date}",
|
||||
"current": "Nächste Auffrischung {date}"
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
|
||||
@@ -9034,6 +9034,11 @@
|
||||
"mmrv": "MMRV (measles, mumps, rubella, varicella)",
|
||||
"hepa-hepb": "Hepatitis A and B",
|
||||
"hepa-typhoid": "Hepatitis A and typhoid"
|
||||
},
|
||||
"renewal": {
|
||||
"overdue": "Booster overdue since {date}",
|
||||
"dueSoon": "Booster due {date}",
|
||||
"current": "Next booster {date}"
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
|
||||
@@ -9034,6 +9034,11 @@
|
||||
"mmrv": "Tetravírica (sarampión, paperas, rubéola, varicela)",
|
||||
"hepa-hepb": "Hepatitis A y B",
|
||||
"hepa-typhoid": "Hepatitis A y fiebre tifoidea"
|
||||
},
|
||||
"renewal": {
|
||||
"overdue": "Refuerzo pendiente desde el {date}",
|
||||
"dueSoon": "Refuerzo previsto el {date}",
|
||||
"current": "Próximo refuerzo el {date}"
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
|
||||
@@ -9034,6 +9034,11 @@
|
||||
"mmrv": "RORV (rougeole, oreillons, rubéole, varicelle)",
|
||||
"hepa-hepb": "Hépatite A et B",
|
||||
"hepa-typhoid": "Hépatite A et typhoïde"
|
||||
},
|
||||
"renewal": {
|
||||
"overdue": "Rappel en retard depuis le {date}",
|
||||
"dueSoon": "Rappel prévu le {date}",
|
||||
"current": "Prochain rappel le {date}"
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
|
||||
@@ -9034,6 +9034,11 @@
|
||||
"mmrv": "MPRV (morbillo, parotite, rosolia, varicella)",
|
||||
"hepa-hepb": "Epatite A e B",
|
||||
"hepa-typhoid": "Epatite A e febbre tifoide"
|
||||
},
|
||||
"renewal": {
|
||||
"overdue": "Richiamo scaduto dal {date}",
|
||||
"dueSoon": "Richiamo previsto il {date}",
|
||||
"current": "Prossimo richiamo il {date}"
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
|
||||
@@ -9034,6 +9034,11 @@
|
||||
"mmrv": "MMRV(홍역, 유행성이하선염, 풍진, 수두)",
|
||||
"hepa-hepb": "A형·B형간염",
|
||||
"hepa-typhoid": "A형간염·장티푸스"
|
||||
},
|
||||
"renewal": {
|
||||
"overdue": "{date}부터 추가접종 기한 지남",
|
||||
"dueSoon": "{date} 추가접종 예정",
|
||||
"current": "다음 추가접종 {date}"
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
|
||||
@@ -9034,6 +9034,11 @@
|
||||
"mmrv": "MMRV (odra, świnka, różyczka, ospa wietrzna)",
|
||||
"hepa-hepb": "WZW typu A i B",
|
||||
"hepa-typhoid": "WZW typu A i dur brzuszny"
|
||||
},
|
||||
"renewal": {
|
||||
"overdue": "Dawka przypominająca zaległa od {date}",
|
||||
"dueSoon": "Dawka przypominająca {date}",
|
||||
"current": "Następna dawka przypominająca {date}"
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
|
||||
@@ -49,9 +49,11 @@ import {
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
|
||||
import { resolveRenewals } from "@/lib/vaccinations/renewal";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = vaccinationListQuerySchema.safeParse({
|
||||
@@ -96,6 +98,31 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
resolveSeriesFor(prisma, user.id),
|
||||
]);
|
||||
|
||||
// v1.39 (#1005) — the renewal state of each antigen, from the booster the
|
||||
// person confirmed. That reminder lives in the measurements section, so a
|
||||
// grant that opened only the health background gets null rather than a
|
||||
// reminder it was never given.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const renewals = visible("measurements")
|
||||
? resolveRenewals(
|
||||
await prisma.measurementReminder.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
vaccinationAntigen: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
vaccinationAntigen: true,
|
||||
nextDueAt: true,
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
new Date(),
|
||||
await resolveOwnerTimezone(user.id),
|
||||
)
|
||||
: null;
|
||||
|
||||
annotate({
|
||||
action: { name: "vaccination.record.list", entity_type: "vaccination" },
|
||||
meta: { count: rows.length, filtered: Boolean(query.antigenSlug) },
|
||||
@@ -105,6 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
vaccinations: rows.map((row) =>
|
||||
toVaccinationDTO(row, series.get(row.id) ?? []),
|
||||
),
|
||||
renewals,
|
||||
};
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
@@ -133,3 +133,111 @@ describe("VaccinationList grouping", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VaccinationList renewal state (#1005)", () => {
|
||||
const tdap = record({
|
||||
id: "tdap-1",
|
||||
antigenSlug: "tdap",
|
||||
catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" },
|
||||
series: [
|
||||
{ antigen: "tetanus", position: 3, total: 3, booster: false },
|
||||
{ antigen: "pertussis", position: 1, total: 3, booster: false },
|
||||
],
|
||||
});
|
||||
|
||||
function renderWith(
|
||||
renewals: Parameters<typeof VaccinationList>[0]["renewals"],
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<VaccinationList records={[tdap]} renewals={renewals} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("marks an overdue booster on its antigen's heading, and only there", () => {
|
||||
const html = renderWith([
|
||||
{
|
||||
antigen: "tetanus",
|
||||
reminderId: "rem-1",
|
||||
dueAt: "2026-01-10T09:00:00.000Z",
|
||||
daysUntil: -30,
|
||||
state: "overdue",
|
||||
},
|
||||
]);
|
||||
expect(html.match(/data-slot="vaccination-renewal"/g)).toHaveLength(1);
|
||||
expect(html).toContain('data-state="overdue"');
|
||||
expect(html).toContain("Booster overdue since");
|
||||
expect(html).toContain("text-warning");
|
||||
// It sits inside the tetanus group, not the pertussis one.
|
||||
const tetanusGroup = html.slice(
|
||||
html.indexOf('data-antigen="tetanus"'),
|
||||
html.indexOf('data-antigen="pertussis"') >
|
||||
html.indexOf('data-antigen="tetanus"')
|
||||
? html.indexOf('data-antigen="pertussis"')
|
||||
: undefined,
|
||||
);
|
||||
expect(tetanusGroup).toContain('data-slot="vaccination-renewal"');
|
||||
});
|
||||
|
||||
it("tints a booster that is due soon and leaves a distant one as plain meta", () => {
|
||||
const soon = renderWith([
|
||||
{
|
||||
antigen: "tetanus",
|
||||
reminderId: "rem-1",
|
||||
dueAt: "2026-10-10T09:00:00.000Z",
|
||||
daysUntil: 16,
|
||||
state: "dueSoon",
|
||||
},
|
||||
]);
|
||||
expect(soon).toContain("Booster due");
|
||||
expect(soon).toContain("text-info");
|
||||
const later = renderWith([
|
||||
{
|
||||
antigen: "tetanus",
|
||||
reminderId: "rem-1",
|
||||
dueAt: "2034-10-10T09:00:00.000Z",
|
||||
daysUntil: 2900,
|
||||
state: "current",
|
||||
},
|
||||
]);
|
||||
expect(later).toContain("Next booster");
|
||||
expect(later).not.toContain("text-warning");
|
||||
expect(later).not.toContain("text-info");
|
||||
});
|
||||
|
||||
it("gives each antigen its own renewal when several are planned", () => {
|
||||
const html = renderWith([
|
||||
{
|
||||
antigen: "pertussis",
|
||||
reminderId: "rem-p",
|
||||
dueAt: "2034-10-10T09:00:00.000Z",
|
||||
daysUntil: 2900,
|
||||
state: "current",
|
||||
},
|
||||
{
|
||||
antigen: "tetanus",
|
||||
reminderId: "rem-t",
|
||||
dueAt: "2026-01-10T09:00:00.000Z",
|
||||
daysUntil: -30,
|
||||
state: "overdue",
|
||||
},
|
||||
]);
|
||||
const pertussisAt = html.indexOf('data-antigen="pertussis"');
|
||||
const tetanusAt = html.indexOf('data-antigen="tetanus"');
|
||||
const [first, second] =
|
||||
pertussisAt < tetanusAt
|
||||
? [html.slice(pertussisAt, tetanusAt), html.slice(tetanusAt)]
|
||||
: [html.slice(tetanusAt, pertussisAt), html.slice(pertussisAt)];
|
||||
const pertussis = pertussisAt < tetanusAt ? first : second;
|
||||
const tetanus = pertussisAt < tetanusAt ? second : first;
|
||||
expect(pertussis).toContain('data-state="current"');
|
||||
expect(pertussis).not.toContain('data-state="overdue"');
|
||||
expect(tetanus).toContain('data-state="overdue"');
|
||||
});
|
||||
|
||||
it("shows nothing when there is no renewal or the grant withholds it", () => {
|
||||
expect(renderWith([])).not.toContain("vaccination-renewal");
|
||||
expect(renderWith(null)).not.toContain("vaccination-renewal");
|
||||
});
|
||||
});
|
||||
@@ -18,19 +18,25 @@
|
||||
* `vaccineName` instead — the degrade the DTO's `catalogEntry: null` signals,
|
||||
* now visible.
|
||||
*
|
||||
* ── Neutral, retrospective ─────────────────────────────────────────────────
|
||||
* ── Neutral, retrospective — and the booster the person planned ───────────
|
||||
*
|
||||
* No due status anywhere on this page. A booster the user set shows on
|
||||
* `/checkups` like every other reminder; this surface reproduces the record
|
||||
* and does not adjudicate it. Cards are neutral — content in foreground, meta
|
||||
* in muted, no tint.
|
||||
* The page reproduces the record and does not adjudicate it: nothing here
|
||||
* works out what a person is due from their age or history. The one status it
|
||||
* shows is the booster the person confirmed themselves — the reminder planned
|
||||
* when a dose was logged — resolved server-side into a renewal state
|
||||
* (`src/lib/vaccinations/renewal.ts`) and rendered on the antigen's heading,
|
||||
* tinted only when it has passed or is close. Dose cards stay neutral —
|
||||
* content in foreground, meta in muted, no tint.
|
||||
*/
|
||||
import { Syringe } from "lucide-react";
|
||||
import { AlarmClock, Syringe } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { SeriesPosition } from "@/lib/vaccinations/series";
|
||||
import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Vaccination } from "./use-vaccinations";
|
||||
import { CatalogInfo, catalogInfoAvailable } from "./catalog-info";
|
||||
|
||||
@@ -208,15 +214,56 @@ function DoseRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The booster the person planned for this antigen, on its heading. Overdue
|
||||
* and due-soon are tinted so they are seen at a glance; a booster years away
|
||||
* reads as plain meta. Composed from the server's resolved state — the client
|
||||
* never compares dates itself.
|
||||
*/
|
||||
function RenewalBadge({ renewal }: { renewal: VaccinationRenewalDTO }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const date = format.date(renewal.dueAt);
|
||||
const text =
|
||||
renewal.state === "overdue"
|
||||
? t("vaccinations.renewal.overdue", { date })
|
||||
: renewal.state === "dueSoon"
|
||||
? t("vaccinations.renewal.dueSoon", { date })
|
||||
: t("vaccinations.renewal.current", { date });
|
||||
const tone =
|
||||
renewal.state === "overdue"
|
||||
? "border-warning/30 bg-warning/10 text-warning"
|
||||
: renewal.state === "dueSoon"
|
||||
? "border-info/30 bg-info/10 text-info"
|
||||
: "text-muted-foreground";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-slot="vaccination-renewal"
|
||||
data-state={renewal.state}
|
||||
className={cn("tracking-normal normal-case", tone)}
|
||||
>
|
||||
<AlarmClock aria-hidden />
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function VaccinationList({
|
||||
records,
|
||||
renewals,
|
||||
onEdit,
|
||||
}: {
|
||||
records: readonly Vaccination[];
|
||||
/** Resolved per antigen by the server; null when the grant withholds them. */
|
||||
renewals?: readonly VaccinationRenewalDTO[] | null;
|
||||
onEdit?: (record: Vaccination) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const groups = groupByAntigen(records);
|
||||
const renewalByAntigen = new Map(
|
||||
(renewals ?? []).map((renewal) => [renewal.antigen, renewal]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -227,7 +274,7 @@ export function VaccinationList({
|
||||
data-slot="vaccination-group"
|
||||
data-antigen={group.antigen ?? "free"}
|
||||
>
|
||||
<h2 className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
|
||||
<h2 className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase">
|
||||
<Syringe className="size-3.5" aria-hidden />
|
||||
{group.antigen
|
||||
? t(`vaccinations.catalog.${group.antigen}`)
|
||||
@@ -241,6 +288,9 @@ export function VaccinationList({
|
||||
triggerDataSlot="vaccination-info-trigger"
|
||||
/>
|
||||
) : null}
|
||||
{group.antigen && renewalByAntigen.has(group.antigen) ? (
|
||||
<RenewalBadge renewal={renewalByAntigen.get(group.antigen)!} />
|
||||
) : null}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{group.rows.map((row, index) => (
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* doses by their component antigen and shows where each series stands, with the
|
||||
* numbers resolved server-side (`src/lib/vaccinations/series.ts`) so this
|
||||
* client never re-derives "N von M". Neutral cards, status through a discreet
|
||||
* badge only — no red card, no overdue tint. It reproduces the record; it does
|
||||
* not adjudicate what is due.
|
||||
* badge only — no red card. It reproduces the record; it does not adjudicate
|
||||
* what is due. The one status shown is the booster the person planned, on the
|
||||
* antigen's heading (see `vaccination-list.tsx`).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Plus, Syringe } from "lucide-react";
|
||||
@@ -121,6 +122,7 @@ export function VaccinationsView() {
|
||||
) : records.length > 0 ? (
|
||||
<VaccinationList
|
||||
records={records}
|
||||
renewals={data?.renewals}
|
||||
onEdit={canManageProfile ? openEdit : undefined}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -175,11 +175,33 @@ const vaccination = z
|
||||
"One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never times. `series` carries one entry per component antigen, so a combined preparation reports several at once and each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one matched. `documents` is present on the detail response and absent from the list.",
|
||||
});
|
||||
|
||||
const vaccinationList = z.object({ vaccinations: z.array(vaccination) }).meta({
|
||||
id: "VaccinationList",
|
||||
description:
|
||||
"The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.",
|
||||
});
|
||||
const vaccinationRenewal = z
|
||||
.object({
|
||||
antigen: z.string(),
|
||||
reminderId: z.string(),
|
||||
dueAt: z.string(),
|
||||
daysUntil: z.number().int(),
|
||||
state: z.enum(["current", "dueSoon", "overdue"]),
|
||||
})
|
||||
.meta({
|
||||
id: "VaccinationRenewal",
|
||||
description:
|
||||
"One antigen's renewal state, read from the booster reminder the person confirmed for it (`dueAt` is that reminder's next due instant). `daysUntil` counts calendar days on the person's own clock and is negative once the date has passed. `state` is `overdue` once it has passed, `dueSoon` within 30 days, `current` otherwise. Render from `state`; do not recompute it. Nothing here is derived from age, history or the catalogue: an antigen without a confirmed booster has no entry.",
|
||||
});
|
||||
|
||||
const vaccinationList = z
|
||||
.object({
|
||||
vaccinations: z.array(vaccination),
|
||||
renewals: z.array(vaccinationRenewal).nullable().meta({
|
||||
description:
|
||||
"Each antigen's renewal state, earliest due first. Null when the caller acts under a grant that does not cover the measurements section the booster reminders live in.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "VaccinationList",
|
||||
description:
|
||||
"The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.",
|
||||
});
|
||||
|
||||
const vaccinationSuggestion = z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
VACCINATION_RENEWAL_SOON_DAYS,
|
||||
resolveRenewals,
|
||||
} from "@/lib/vaccinations/renewal";
|
||||
|
||||
const NOW = new Date("2026-09-24T10:00:00.000Z");
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function reminder(
|
||||
over: Partial<{
|
||||
id: string;
|
||||
vaccinationAntigen: string | null;
|
||||
nextDueAt: Date | null;
|
||||
enabled: boolean;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: "rem-1",
|
||||
vaccinationAntigen: "tetanus",
|
||||
nextDueAt: new Date("2030-01-01T08:00:00.000Z"),
|
||||
enabled: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveRenewals", () => {
|
||||
it("reads a booster far off as current", () => {
|
||||
expect(resolveRenewals([reminder()], NOW, TZ)).toEqual([
|
||||
{
|
||||
antigen: "tetanus",
|
||||
reminderId: "rem-1",
|
||||
dueAt: "2030-01-01T08:00:00.000Z",
|
||||
daysUntil: expect.any(Number),
|
||||
state: "current",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a booster inside the soon window as due soon, edges included", () => {
|
||||
const soon = new Date(
|
||||
NOW.getTime() + VACCINATION_RENEWAL_SOON_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const [renewal] = resolveRenewals([reminder({ nextDueAt: soon })], NOW, TZ);
|
||||
expect(renewal!.daysUntil).toBe(VACCINATION_RENEWAL_SOON_DAYS);
|
||||
expect(renewal!.state).toBe("dueSoon");
|
||||
const [today] = resolveRenewals([reminder({ nextDueAt: NOW })], NOW, TZ);
|
||||
expect(today!.state).toBe("dueSoon");
|
||||
});
|
||||
|
||||
it("reads a booster whose date has passed as overdue", () => {
|
||||
const [renewal] = resolveRenewals(
|
||||
[reminder({ nextDueAt: new Date("2026-09-01T08:00:00.000Z") })],
|
||||
NOW,
|
||||
TZ,
|
||||
);
|
||||
expect(renewal!.state).toBe("overdue");
|
||||
expect(renewal!.daysUntil).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("ignores a switched-off reminder, one with no antigen, and one with no date", () => {
|
||||
expect(
|
||||
resolveRenewals(
|
||||
[
|
||||
reminder({ enabled: false }),
|
||||
reminder({ vaccinationAntigen: null }),
|
||||
reminder({ nextDueAt: null }),
|
||||
],
|
||||
NOW,
|
||||
TZ,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers one renewal per antigen, the earliest due", () => {
|
||||
const renewals = resolveRenewals(
|
||||
[
|
||||
reminder({ id: "late", nextDueAt: new Date("2031-01-01T08:00:00Z") }),
|
||||
reminder({ id: "early", nextDueAt: new Date("2029-01-01T08:00:00Z") }),
|
||||
reminder({
|
||||
id: "flu",
|
||||
vaccinationAntigen: "influenza",
|
||||
nextDueAt: new Date("2026-10-01T08:00:00Z"),
|
||||
}),
|
||||
],
|
||||
NOW,
|
||||
TZ,
|
||||
);
|
||||
expect(renewals.map((r) => [r.antigen, r.reminderId])).toEqual([
|
||||
["influenza", "flu"],
|
||||
["tetanus", "early"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/lib/practitioners/dto";
|
||||
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
|
||||
import type { SeriesPosition } from "@/lib/vaccinations/series";
|
||||
import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal";
|
||||
|
||||
/**
|
||||
* One page a dose was transcribed from, as the caller is allowed to see it.
|
||||
@@ -162,4 +163,11 @@ export function toVaccinationDTO(
|
||||
/** The list response: one array, newest dose first. */
|
||||
export interface VaccinationListDTO {
|
||||
vaccinations: VaccinationDTO[];
|
||||
/**
|
||||
* v1.39 — each antigen's renewal state, from the booster reminder the
|
||||
* person confirmed (`src/lib/vaccinations/renewal.ts`). An antigen with no
|
||||
* confirmed booster is absent. `null` when the caller acts under a grant
|
||||
* that does not cover the measurements section the reminders live in.
|
||||
*/
|
||||
renewals: VaccinationRenewalDTO[] | null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Whether a vaccination is still current, surfaced where the person looks.
|
||||
*
|
||||
* The data already exists and is the person's own: when a dose is logged, the
|
||||
* app offers to plan the booster from the catalogue's interval, and what the
|
||||
* person confirms becomes an ordinary reminder keyed on the antigen
|
||||
* (`MeasurementReminder.vaccinationAntigen`, minted in `booster-mint.ts`).
|
||||
* Logging the next dose re-anchors it. Its `nextDueAt` is the renewal date.
|
||||
* Until now it lived only on `/checkups`, and the vaccination page itself said
|
||||
* nothing about it.
|
||||
*
|
||||
* This module reads that date and resolves it to a state, once, on the
|
||||
* server, so the web page and the native client agree. It does NOT compute a
|
||||
* due date from age, history or the catalogue: an antigen with no confirmed
|
||||
* booster has no renewal here. That line is the one `series.ts` draws — due
|
||||
* computation from a person's history is a different kind of product — and it
|
||||
* is why the source is the confirmed reminder and nothing else.
|
||||
*
|
||||
* Pure: no DB read. The route owns the read and the grant check.
|
||||
*/
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
|
||||
/**
|
||||
* How close a renewal has to be before it counts as due soon. A month is
|
||||
* enough to book an appointment for a ten-year booster and still meaningful
|
||||
* for a seasonal one.
|
||||
*/
|
||||
export const VACCINATION_RENEWAL_SOON_DAYS = 30;
|
||||
|
||||
export type VaccinationRenewalState = "current" | "dueSoon" | "overdue";
|
||||
|
||||
/** One antigen's renewal, resolved. */
|
||||
export interface VaccinationRenewalDTO {
|
||||
/** The component antigen slug the booster reminder keys on. */
|
||||
antigen: string;
|
||||
/** The reminder behind it, for a deep link to `/checkups`. */
|
||||
reminderId: string;
|
||||
/** ISO-8601 instant the booster is due. */
|
||||
dueAt: string;
|
||||
/**
|
||||
* Calendar days from today to `dueAt` on the person's own clock; negative
|
||||
* once it has passed. The same count the checkups page shows.
|
||||
*/
|
||||
daysUntil: number;
|
||||
state: VaccinationRenewalState;
|
||||
}
|
||||
|
||||
export interface RenewalReminderRow {
|
||||
id: string;
|
||||
vaccinationAntigen: string | null;
|
||||
nextDueAt: Date | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function resolveRenewals(
|
||||
reminders: readonly RenewalReminderRow[],
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): VaccinationRenewalDTO[] {
|
||||
const byAntigen = new Map<string, RenewalReminderRow & { due: Date }>();
|
||||
for (const reminder of reminders) {
|
||||
if (!reminder.enabled) continue;
|
||||
if (!reminder.vaccinationAntigen || !reminder.nextDueAt) continue;
|
||||
// The mint keeps one live reminder per antigen; should two exist anyway,
|
||||
// the earlier date is the one that needs the person first.
|
||||
const current = byAntigen.get(reminder.vaccinationAntigen);
|
||||
if (!current || reminder.nextDueAt < current.due) {
|
||||
byAntigen.set(reminder.vaccinationAntigen, {
|
||||
...reminder,
|
||||
due: reminder.nextDueAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byAntigen.values()]
|
||||
.map((reminder) => {
|
||||
const daysUntil = calendarDaysUntil(reminder.due, now, timeZone);
|
||||
const state: VaccinationRenewalState =
|
||||
daysUntil < 0
|
||||
? "overdue"
|
||||
: daysUntil <= VACCINATION_RENEWAL_SOON_DAYS
|
||||
? "dueSoon"
|
||||
: "current";
|
||||
return {
|
||||
antigen: reminder.vaccinationAntigen!,
|
||||
reminderId: reminder.id,
|
||||
dueAt: reminder.due.toISOString(),
|
||||
daysUntil,
|
||||
state,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.dueAt.localeCompare(b.dueAt));
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* The vaccination list carries each antigen's renewal state, read from the
|
||||
* booster reminder the person confirmed, and only to a caller whose grant
|
||||
* covers the measurements section that reminder lives in.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "renewal-owner";
|
||||
const DELEGATE_ID = "renewal-delegate";
|
||||
|
||||
/** A catalogue antigen that carries a booster interval. */
|
||||
const ANTIGEN = "tetanus";
|
||||
|
||||
let doseId: string;
|
||||
|
||||
async function seedRecords() {
|
||||
const prisma = getPrismaClient();
|
||||
for (const [id, name] of [
|
||||
[OWNER_ID, "owner"],
|
||||
[DELEGATE_ID, "delegate"],
|
||||
] as const) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: `renewal-${name}`,
|
||||
email: `renewal-${name}@example.test`,
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
const dose = await prisma.vaccinationRecord.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
occurredAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
antigenSlug: ANTIGEN,
|
||||
},
|
||||
});
|
||||
doseId = dose.id;
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function switchInto(
|
||||
access: "WRITE" | "MANAGE",
|
||||
scope: ShareDomain[] | null,
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: OWNER_ID,
|
||||
granteeId: DELEGATE_ID,
|
||||
access,
|
||||
scope,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
|
||||
const session = await signIn(DELEGATE_ID);
|
||||
await switchSessionTo(session.id, OWNER_ID);
|
||||
}
|
||||
|
||||
async function planBooster(): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/vaccinations/[id]/booster/route");
|
||||
return POST(
|
||||
new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: doseId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function listVaccinations(): Promise<{
|
||||
renewals: Array<{ antigen: string; state: string; daysUntil: number }> | null;
|
||||
}> {
|
||||
const { GET } = await import("@/app/api/vaccinations/route");
|
||||
const res = await GET(new NextRequest("http://localhost/api/vaccinations"));
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedRecords();
|
||||
});
|
||||
|
||||
describe("the vaccination list shows each antigen's renewal state (#1005)", () => {
|
||||
it("reads the booster the owner confirmed as current", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
const { renewals } = await listVaccinations();
|
||||
expect(renewals).toHaveLength(1);
|
||||
expect(renewals![0]).toMatchObject({ antigen: ANTIGEN, state: "current" });
|
||||
});
|
||||
|
||||
it("reads a booster whose date has passed as overdue", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
await getPrismaClient().measurementReminder.updateMany({
|
||||
where: { userId: OWNER_ID },
|
||||
data: { nextDueAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
const { renewals } = await listVaccinations();
|
||||
expect(renewals![0]).toMatchObject({ antigen: ANTIGEN, state: "overdue" });
|
||||
expect(renewals![0]!.daysUntil).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("claims nothing for an antigen without a confirmed booster", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
const { renewals } = await listVaccinations();
|
||||
expect(renewals).toEqual([]);
|
||||
});
|
||||
|
||||
it("never shows another account's booster", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
cookieJar.clear();
|
||||
await signIn(DELEGATE_ID);
|
||||
const { renewals } = await listVaccinations();
|
||||
expect(renewals).toEqual([]);
|
||||
});
|
||||
|
||||
it("withholds renewals from a grant that opened only the health background", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
cookieJar.clear();
|
||||
await switchInto("WRITE", ["profile"]);
|
||||
const { renewals } = await listVaccinations();
|
||||
expect(renewals).toBeNull();
|
||||
});
|
||||
|
||||
it("shows them to a grant that also covers measurements", async () => {
|
||||
await signIn(OWNER_ID);
|
||||
expect((await planBooster()).status).toBe(201);
|
||||
cookieJar.clear();
|
||||
await switchInto("WRITE", ["profile", "measurements"]);
|
||||
const { renewals } = await listVaccinations();
|
||||
expect(renewals).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user