Merge branch 'feat/v139-targets-and-links' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:53:41 +02:00
commit e89f41710a
58 files changed
+2785 -214

No files matched your search

+130 -16
View File
@@ -27652,12 +27652,20 @@ components:
type: string
minLength: 1
maxLength: 40
vaccinationIds:
maxItems: 100
type: array
items:
type: string
minLength: 1
maxLength: 40
description: "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category),
`documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links —
the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode
of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the
caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the
Prisma `where` with the row id."
caller's visits), `vaccinationIds` (the same replace-set semantics over the caller's vaccination doses, up to
100 so one childhood record can be filed against a whole primary schedule). At least one field required. No
`userId` field — narrowed from the session and fed to the Prisma `where` with the row id."
InboundExtractRequest:
anyOf:
- type: object
@@ -30216,20 +30224,20 @@ components:
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout; encounter — encounter.invalid, encounter.link.invalid,
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment
— environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found;
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest —
ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened,
insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound,
labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType,
labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound; encounter — encounter.invalid,
encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict,
encounter.reminder-not-found; environment — environment.invalid, environment.no_home,
environment.range_too_large, environment.travel.not_found; export — export.selection.empty,
export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid;
health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness —
illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid,
illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import —
import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
insights.generate.outboundScreened, insights.generate.profileScopeChanged,
insights.generate.scopeChanged; labs — labs.biomarker.create.invalid, labs.biomarker.duplicate,
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid,
labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected,
labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile —
@@ -50748,6 +50756,35 @@ components:
- sources
additionalProperties: false
- type: "null"
weightTrend:
description: Which way a weight change counts as progress, judged against the person's own stored target rather than
assuming a fall is good. Render the arrow, 7-day delta and comparison caption from `direction`; do not
recompute it. Optional for older cached snapshots; when absent, treat it as `up-bad`.
type: object
properties:
direction:
type: string
enum:
- up-good
- up-bad
- hold
description: "How the weight tile colours a change. `up-good`: a rise is progress (below the target band). `up-bad`: a
fall is progress (above the band, and the reading when no target is stored). `hold`: the weight is
inside the band, so a flat change is progress and a move either way is neutral, never a setback."
targetPosition:
anyOf:
- type: string
enum:
- below
- inside
- above
- type: "null"
description: Where the 7-day average (else the latest reading) sits against the person's own stored weight target. Null
when no target is stored or there is no reading.
required:
- direction
- targetPosition
additionalProperties: false
required:
- summaries
- lastSeenByType
@@ -60579,8 +60616,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
@@ -60816,6 +60862,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:
@@ -62803,6 +62880,15 @@ components:
type: array
items:
$ref: "#/components/schemas/ExtractedFact"
vaccinationLinks:
anyOf:
- type: array
items:
$ref: "#/components/schemas/DocumentVaccinationLink"
- type: "null"
description: "Vaccination doses this document is filed against. Null when the caller acts inside somebody else's record
under a grant that does not cover the health background the doses live in: the page is shared, the doses are
not, and an empty list would claim the page records none."
summary:
anyOf:
- type: string
@@ -62844,6 +62930,7 @@ components:
- createdAt
- updatedAt
- facts
- vaccinationLinks
- summary
- summaryGeneratedAt
- summaryState
@@ -62932,6 +63019,33 @@ components:
with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are
present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact
scored below the confidence floor (it cannot be approved until edited).
DocumentVaccinationLink:
type: object
properties:
vaccinationId:
type: string
occurredAt:
anyOf:
- type: string
- type: "null"
catalogSlug:
anyOf:
- type: string
- type: "null"
vaccineName:
anyOf:
- type: string
- type: "null"
required:
- vaccinationId
- occurredAt
- catalogSlug
- vaccineName
additionalProperties: false
description: A link to one of the caller's vaccination doses — "this page records that dose". `catalogSlug` is the
catalogue entry when this release still knows the dose's slug, for the reader's own bundle to name; null is the
signal to show `vaccineName`, the person's own wording. The same table the dose's own
`/api/vaccinations/{id}/links` writes from the other end.
InboundDocumentUpdateEnvelope:
type: object
properties:
+12 -2
View File
@@ -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": {
@@ -9881,7 +9886,10 @@
"uploadedMeta": "Hinzugefügt am {date} · {size}",
"share": "Teilen",
"labValuesLink": "{count} Laborwerte ansehen",
"visitsLabel": "Gehört zu Termin"
"visitsLabel": "Gehört zu Termin",
"vaccinationsLabel": "Impfungen",
"noVisitsToLink": "Noch keine Termine erfasst.",
"noVaccinationsToLink": "Noch keine Impfungen erfasst."
},
"dropOverlay": {
"title": "Zum Hochladen ablegen",
@@ -10825,7 +10833,9 @@
"selectAll": "Alle auswählen",
"searchPlaceholder": "Suchen",
"searchNoMatch": "Kein Treffer",
"remove": "Entfernen"
"remove": "Entfernen",
"suggested": "Um dieses Datum",
"loadError": "Die Liste konnte nicht geladen werden."
}
}
}
+12 -2
View File
@@ -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": {
@@ -9881,7 +9886,10 @@
"uploadedMeta": "Added {date} · {size}",
"share": "Share",
"labValuesLink": "View {count} lab values",
"visitsLabel": "Belongs to visit"
"visitsLabel": "Belongs to visit",
"vaccinationsLabel": "Vaccinations",
"noVisitsToLink": "No visits recorded yet.",
"noVaccinationsToLink": "No vaccinations recorded yet."
},
"dropOverlay": {
"title": "Drop files to upload",
@@ -10825,7 +10833,9 @@
"selectAll": "Select all",
"searchPlaceholder": "Search",
"searchNoMatch": "No match",
"remove": "Remove"
"remove": "Remove",
"suggested": "Around this date",
"loadError": "The list could not be loaded."
}
}
}
+12 -2
View File
@@ -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": {
@@ -9881,7 +9886,10 @@
"uploadedMeta": "Añadido el {date} · {size}",
"share": "Compartir",
"labValuesLink": "Ver {count} valores de laboratorio",
"visitsLabel": "Pertenece a la cita"
"visitsLabel": "Pertenece a la cita",
"vaccinationsLabel": "Vacunas",
"noVisitsToLink": "Aún no hay consultas registradas.",
"noVaccinationsToLink": "Aún no hay vacunas registradas."
},
"dropOverlay": {
"title": "Suelta los archivos para subirlos",
@@ -10825,7 +10833,9 @@
"selectAll": "Seleccionar todo",
"searchPlaceholder": "Buscar",
"searchNoMatch": "Sin coincidencias",
"remove": "Quitar"
"remove": "Quitar",
"suggested": "En torno a esta fecha",
"loadError": "No se pudo cargar la lista."
}
}
}
+12 -2
View File
@@ -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": {
@@ -9881,7 +9886,10 @@
"uploadedMeta": "Ajouté le {date} · {size}",
"share": "Partager",
"labValuesLink": "Voir {count} valeurs de laboratoire",
"visitsLabel": "Rattaché au rendez-vous"
"visitsLabel": "Rattaché au rendez-vous",
"vaccinationsLabel": "Vaccinations",
"noVisitsToLink": "Aucune consultation enregistrée pour l’instant.",
"noVaccinationsToLink": "Aucune vaccination enregistrée pour l’instant."
},
"dropOverlay": {
"title": "Déposez les fichiers pour les téléverser",
@@ -10825,7 +10833,9 @@
"selectAll": "Tout sélectionner",
"searchPlaceholder": "Rechercher",
"searchNoMatch": "Aucun résultat",
"remove": "Retirer"
"remove": "Retirer",
"suggested": "Autour de cette date",
"loadError": "Impossible de charger la liste."
}
}
}
+12 -2
View File
@@ -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": {
@@ -9881,7 +9886,10 @@
"uploadedMeta": "Aggiunto il {date} · {size}",
"share": "Condividi",
"labValuesLink": "Vedi {count} valori di laboratorio",
"visitsLabel": "Appartiene all'appuntamento"
"visitsLabel": "Appartiene all'appuntamento",
"vaccinationsLabel": "Vaccinazioni",
"noVisitsToLink": "Nessuna visita registrata finora.",
"noVaccinationsToLink": "Nessuna vaccinazione registrata finora."
},
"dropOverlay": {
"title": "Rilascia i file per caricarli",
@@ -10825,7 +10833,9 @@
"selectAll": "Seleziona tutto",
"searchPlaceholder": "Cerca",
"searchNoMatch": "Nessun risultato",
"remove": "Rimuovi"
"remove": "Rimuovi",
"suggested": "Intorno a questa data",
"loadError": "Impossibile caricare l’elenco."
}
}
}
+12 -2
View File
@@ -9034,6 +9034,11 @@
"mmrv": "MMRV(홍역, 유행성이하선염, 풍진, 수두)",
"hepa-hepb": "A형·B형간염",
"hepa-typhoid": "A형간염·장티푸스"
},
"renewal": {
"overdue": "{date}부터 추가접종 기한 지남",
"dueSoon": "{date} 추가접종 예정",
"current": "다음 추가접종 {date}"
}
},
"illness": {
@@ -9881,7 +9886,10 @@
"uploadedMeta": "추가 {date} · {size}",
"share": "공유",
"labValuesLink": "검사 수치 {count}개 보기",
"visitsLabel": "연결된 진료"
"visitsLabel": "연결된 진료",
"vaccinationsLabel": "예방접종",
"noVisitsToLink": "아직 기록된 진료가 없습니다.",
"noVaccinationsToLink": "아직 기록된 예방접종이 없습니다."
},
"dropOverlay": {
"title": "파일을 놓아 업로드",
@@ -10825,7 +10833,9 @@
"selectAll": "전체 선택",
"searchPlaceholder": "검색",
"searchNoMatch": "결과 없음",
"remove": "제거"
"remove": "제거",
"suggested": "이 날짜 전후",
"loadError": "목록을 불러오지 못했습니다."
}
}
}
+12 -2
View File
@@ -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": {
@@ -9881,7 +9886,10 @@
"uploadedMeta": "Dodano {date} · {size}",
"share": "Udostępnij",
"labValuesLink": "Zobacz {count} wyników laboratoryjnych",
"visitsLabel": "Należy do wizyty"
"visitsLabel": "Należy do wizyty",
"vaccinationsLabel": "Szczepienia",
"noVisitsToLink": "Nie zapisano jeszcze żadnych wizyt.",
"noVaccinationsToLink": "Nie zapisano jeszcze żadnych szczepień."
},
"dropOverlay": {
"title": "Upuść pliki, aby je przesłać",
@@ -10825,7 +10833,9 @@
"selectAll": "Zaznacz wszystko",
"searchPlaceholder": "Szukaj",
"searchNoMatch": "Brak wyników",
"remove": "Usuń"
"remove": "Usuń",
"suggested": "Wokół tej daty",
"loadError": "Nie udało się wczytać listy."
}
}
}
@@ -0,0 +1,68 @@
/**
* The dashboard weight tile colours its trend by the resolved target
* direction, never by a literal (#1006).
*
* Every other tile on the strip passes a literal `directionSentiment`, because
* for resting pulse or steps the good direction is a property of the metric.
* Weight is the exception: which way is progress depends on the person's own
* target, and it is resolved on the server (`tiles.weightTrend` on the
* snapshot, `src/lib/targets/weight-trend.ts`). A literal `"up-bad"` here is
* exactly the defect the issue reported — a person below their target saw
* every gain coloured as a setback — and it would type-check and render
* without complaint, so this pins the attribute rather than the pixels.
*
* Structural rather than rendered because the dashboard page early-returns a
* loading gate under SSR, and the property is about the source: the weight
* `<TrendCard>` names `weightTrend.direction`, or it does not.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const PAGE = join(process.cwd(), "src/app/page-client.tsx");
function weightTileDirections(): string[] {
const text = readFileSync(PAGE, "utf8");
const file = ts.createSourceFile(
PAGE,
text,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const found: string[] = [];
const visit = (node: ts.Node) => {
if (
ts.isJsxSelfClosingElement(node) &&
node.tagName.getText(file) === "TrendCard"
) {
const attrs = node.attributes.properties.filter(ts.isJsxAttribute);
const key = attrs.find((a) => a.name.getText(file) === "key");
const isWeight =
key?.initializer &&
ts.isStringLiteral(key.initializer) &&
key.initializer.text === "weight";
if (isWeight) {
const direction = attrs.find(
(a) => a.name.getText(file) === "directionSentiment",
);
found.push(direction?.initializer?.getText(file) ?? "<absent>");
}
}
ts.forEachChild(node, visit);
};
visit(file);
return found;
}
describe("dashboard weight tile direction", () => {
it("finds exactly one weight tile, so an empty match cannot pass", () => {
expect(weightTileDirections()).toHaveLength(1);
});
it("reads the resolved target direction rather than a literal", () => {
expect(weightTileDirections()).toEqual(["{weightTrend.direction}"]);
});
});
@@ -28,6 +28,14 @@ vi.mock("@/lib/db", () => ({
deleteMany: vi.fn(),
createMany: vi.fn(),
},
vaccinationDocumentLink: {
findMany: vi.fn(),
deleteMany: vi.fn(),
createMany: vi.fn(),
},
vaccinationRecord: {
findMany: vi.fn(),
},
documentContentIndex: {
findUnique: vi.fn(),
},
@@ -73,6 +81,10 @@ vi.mock("@/lib/rate-limit", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// Owner by default (every section open); the redaction case narrows it.
vi.mock("@/lib/sharing/acting-domains", () => ({
actingDomainVisibility: vi.fn(async () => () => true),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -89,10 +101,12 @@ vi.mock("next/headers", () => ({
})),
}));
import { PATCH } from "../route";
import { GET, PATCH } from "../route";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -141,6 +155,16 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.vaccinationDocumentLink.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.vaccinationDocumentLink.createMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
@@ -266,4 +290,141 @@ describe("PATCH /api/documents/inbound/[id]", () => {
expect(prisma.documentConditionLink.deleteMany).not.toHaveBeenCalled();
expect(prisma.documentConditionLink.createMany).not.toHaveBeenCalled();
});
it("replace-sets vaccination links from vaccinationIds and audits the change", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
} as never);
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([
{ id: "dose-1" },
{ id: "dose-2" },
] as never);
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
docRow() as never,
);
const res = await PATCH(
mkReq("doc-1", { vaccinationIds: ["dose-1", "dose-2"] }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
// The owner-narrowing read is scoped by the session user and the
// tombstone, never by anything the body carried.
const narrowWhere = vi.mocked(prisma.vaccinationRecord.findMany).mock
.calls[0]![0]!.where!;
expect(narrowWhere).toMatchObject({ userId: "user-1", deletedAt: null });
expect(prisma.vaccinationDocumentLink.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
documentId: "doc-1",
vaccinationId: { notIn: ["dose-1", "dose-2"] },
},
});
expect(prisma.vaccinationDocumentLink.createMany).toHaveBeenCalledWith({
data: [
{ documentId: "doc-1", vaccinationId: "dose-1", userId: "user-1" },
{ documentId: "doc-1", vaccinationId: "dose-2", userId: "user-1" },
],
skipDuplicates: true,
});
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
"documents.inbound.update",
expect.objectContaining({
details: expect.objectContaining({ fields: ["vaccinationIds"] }),
}),
);
const body = await res.json();
expect(body.data.vaccinationLinks).toEqual([]);
});
it("refuses a foreign dose with a 404-shaped response and writes nothing", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
} as never);
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
const res = await PATCH(
mkReq("doc-1", { vaccinationIds: ["dose-foreign"] }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(404);
expect(prisma.vaccinationDocumentLink.deleteMany).not.toHaveBeenCalled();
expect(prisma.vaccinationDocumentLink.createMany).not.toHaveBeenCalled();
});
it("refuses more dose links than one request may carry", async () => {
const ids = Array.from({ length: 101 }, (_, i) => `dose-${i}`);
const res = await PATCH(
mkReq("doc-1", { vaccinationIds: ids }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(422);
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
});
});
describe("GET /api/documents/inbound/[id] — vaccination links", () => {
function getReq(id: string): NextRequest {
return new NextRequest(
new URL(`http://localhost/api/documents/inbound/${id}`),
);
}
beforeEach(() => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
docRow() as never,
);
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue([
{ documentId: "doc-1", vaccinationId: "dose-1" },
{ documentId: "doc-1", vaccinationId: "dose-2" },
] as never);
// First read: the link service's target re-read; second: the identity
// read that lets the client name each dose.
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([
{
id: "dose-1",
antigenSlug: "tetanus",
vaccineName: null,
occurredAt: new Date("1991-04-02T00:00:00.000Z"),
},
{
id: "dose-2",
antigenSlug: "no-longer-in-catalogue",
vaccineName: "DTP",
occurredAt: new Date("1991-06-02T00:00:00.000Z"),
},
] as never);
});
it("lists every dose the page is filed against, named for the reader's bundle", async () => {
const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.vaccinationLinks).toEqual([
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: "tetanus",
vaccineName: null,
},
{
vaccinationId: "dose-2",
occurredAt: "1991-06-02T00:00:00.000Z",
catalogSlug: null,
vaccineName: "DTP",
},
]);
});
it("withholds the doses from a grant that does not cover the health background", async () => {
vi.mocked(actingDomainVisibility).mockResolvedValueOnce(
(domain) => domain !== "profile",
);
const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.vaccinationLinks).toBeNull();
expect(prisma.vaccinationDocumentLink.findMany).not.toHaveBeenCalled();
});
});
+69 -25
View File
@@ -23,11 +23,15 @@ import { prisma } from "@/lib/db";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
loadDocumentVaccinationLinks,
narrowOwnedEncounterIds,
narrowOwnedEpisodeIds,
narrowOwnedVaccinationIds,
replaceConditionLinks,
replaceEncounterLinks,
replaceVaccinationLinks,
} from "@/lib/documents/links";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -81,7 +85,7 @@ async function enforceWriteRateLimit(userId: string): Promise<Response | null> {
*/
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read", "documents");
const { user, grantId } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
@@ -97,18 +101,26 @@ export const GET = apiHandler(
});
}
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
}),
prisma.documentThumbnail.findUnique({
where: { documentId: document.id },
select: { id: true },
}),
]);
// The doses a page records live in the health background, not in the
// vault. A grant that opened the vault was never consent for that side
// of the seam, so the list is withheld (null) rather than read.
const visible = await actingDomainVisibility(prisma, grantId);
const [links, visitLinks, doseLinks, contentIndex, thumbnail] =
await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
visible("profile")
? loadDocumentVaccinationLinks(user.id, [document.id])
: Promise.resolve(null),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
}),
prisma.documentThumbnail.findUnique({
where: { documentId: document.id },
select: { id: true },
}),
]);
annotate({
action: { name: "documents.inbound.get" },
@@ -124,6 +136,7 @@ export const GET = apiHandler(
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
visitLinks.get(document.id) ?? [],
doseLinks === null ? null : (doseLinks.get(document.id) ?? []),
),
);
},
@@ -196,6 +209,22 @@ export const PATCH = apiHandler(
nextEncounterIds = narrowed;
}
// Replace-set vaccination links, same semantics and same refusal. The
// table is the one the dose's own form writes from the other end.
let nextVaccinationIds: string[] | undefined;
if (parsed.data.vaccinationIds !== undefined) {
const narrowed = await narrowOwnedVaccinationIds(
user.id,
parsed.data.vaccinationIds,
);
if (narrowed === null) {
return apiError("Vaccination not found", 404, {
errorCode: "documents.inbound.vaccinationNotFound",
});
}
nextVaccinationIds = narrowed;
}
// No mass assignment — each editable column is set explicitly only when
// the client sent it. `userId` is never a body field.
const data: Prisma.InboundDocumentUpdateInput = {};
@@ -225,28 +254,42 @@ export const PATCH = apiHandler(
);
}
if (nextVaccinationIds !== undefined) {
await replaceVaccinationLinks(
prisma,
user.id,
existing.id,
nextVaccinationIds,
);
}
const document = await prisma.inboundDocument.findFirstOrThrow({
where: { id: existing.id },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
}),
prisma.documentThumbnail.findUnique({
where: { documentId: document.id },
select: { id: true },
}),
]);
// PATCH is owner-only (bare `requireAuth()`), so every section is the
// caller's own and the dose list is always readable here.
const [links, visitLinks, doseLinks, contentIndex, thumbnail] =
await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
loadDocumentVaccinationLinks(user.id, [document.id]),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
}),
prisma.documentThumbnail.findUnique({
where: { documentId: document.id },
select: { id: true },
}),
]);
const touched = [
...Object.keys(data),
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
...(nextEncounterIds !== undefined ? ["encounterIds"] : []),
...(nextVaccinationIds !== undefined ? ["vaccinationIds"] : []),
];
await auditLog("documents.inbound.update", {
@@ -272,6 +315,7 @@ export const PATCH = apiHandler(
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
visitLinks.get(document.id) ?? [],
doseLinks.get(document.id) ?? [],
),
);
},
+29 -1
View File
@@ -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);
});
+30 -14
View File
@@ -157,6 +157,10 @@ import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
import { buildDashboardBands, viewerBandProfile } from "@/lib/dashboard/bands";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import {
resolveWeightTrend,
weightTrendReferenceKg,
} from "@/lib/targets/weight-trend";
import { toProfileSex } from "@/lib/profile/sex";
import { apiGet } from "@/lib/api/api-fetch";
@@ -766,24 +770,36 @@ export default function DashboardPageClient({
// client-side band at all and waits for the snapshot's, which resolve
// against the record.
const inSharedRecord = user?.accountAccess?.active != null;
const clientBands = useMemo(
const clientBandProfile = useMemo(
() =>
buildDashboardBands(
viewerBandProfile(
{
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
weightTargetOverride: resolveWeightTargetOverride(
thresholdsData?.overrides ?? null,
),
},
inSharedRecord,
),
viewerBandProfile(
{
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
weightTargetOverride: resolveWeightTargetOverride(
thresholdsData?.overrides ?? null,
),
},
inSharedRecord,
),
[user, thresholdsData, inSharedRecord],
);
const clientBands = useMemo(
() => buildDashboardBands(clientBandProfile),
[clientBandProfile],
);
const bands = serverBands ?? clientBands;
// v1.39 — which way a weight change counts as progress, judged against the
// stored target. The snapshot carries the server's answer; the
// snapshot-disabled fallback asks the same resolver from the same profile
// the band fallback above reads, so the two paths cannot disagree.
const weightTrend =
(snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ??
resolveWeightTrend(
clientBandProfile.weightTargetOverride,
weightTrendReferenceKg(w),
);
const bpTargets = bands.bpTargets;
const weightRange = bands.weightRange;
// The range hint tooltip prints its band bounds in the display unit, so scale
@@ -1028,7 +1044,7 @@ export default function DashboardPageClient({
slope30={w?.slope30 ?? null}
trend7Delta={tdd("WEIGHT", summaryToTrend7Delta(w))}
icon={Activity}
directionSentiment="up-bad"
directionSentiment={weightTrend.direction}
compareBaseline={compareBaseline}
compareDelta={tdd("WEIGHT", tileCompareDelta(w))}
staleDays={tileStaleDays("WEIGHT")}
@@ -68,6 +68,31 @@ describe("<TrendCard> directionSentiment", () => {
expect(html).toContain("text-success");
});
// #1006 — weight inside the person's own target band: steady is the win,
// and a move either way is neither celebrated nor scolded.
it("paints a hold metric that stayed flat as green", () => {
const html = render(
<TrendCard
{...baseProps}
slope30={STABLE}
trend7Delta={0}
directionSentiment="hold"
/>,
);
expect(html).toContain("text-success");
expect(html).not.toContain("text-warning");
});
it("never paints a hold metric orange when it moves", () => {
for (const slope of [RISING, FALLING]) {
const html = render(
<TrendCard {...baseProps} slope30={slope} directionSentiment="hold" />,
);
expect(html).not.toContain("text-warning");
expect(html).not.toContain("text-success");
}
});
it("paints up-good metric ↓ as orange", () => {
const html = render(
<TrendCard
@@ -82,6 +82,7 @@ function doc(
pendingCount: 1,
conditionLinks: [],
encounterLinks: [],
vaccinationLinks: [],
servingClass: "inline",
hasContentIndex: true,
contentIndexSource: "vision",
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: { vaccinations: true } } }),
}));
vi.mock("@/hooks/use-encounters", () => ({
useEncounters: () => ({
data: { upcoming: [], past: [] },
isPending: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/components/vaccinations/use-vaccinations", () => ({
useVaccinations: () => ({
data: {
vaccinations: [
{
id: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogEntry: {
slug: "tetanus",
atc: "J07AM01",
category: "standard",
},
vaccineName: null,
},
],
},
isPending: false,
isError: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
import { DocumentRecordLinks, recordOptions } from "../document-record-links";
function doc(
over: Partial<InboundDocumentDetailDto> = {},
): InboundDocumentDetailDto {
return {
id: "doc-1",
kind: "VACCINATION",
title: "Childhood record",
filename: null,
mimeType: "application/pdf",
byteSize: 1,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "1991-04-02",
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
facts: [],
vaccinationLinks: [],
summary: null,
summaryGeneratedAt: null,
summaryState: "NONE",
...over,
} as InboundDocumentDetailDto;
}
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("recordOptions", () => {
it("puts records near the document first and groups the rest by year", () => {
const options = recordOptions(
[
{
id: "late",
label: "Tetanus",
occurredAt: "2010-06-01T00:00:00.000Z",
},
{ id: "near", label: "Polio", occurredAt: "1991-04-05T00:00:00.000Z" },
],
"1991-04-02",
{ suggested: "Around this date", date: (iso) => iso.slice(0, 10) },
);
expect(options.map((o) => o.id)).toEqual(["near", "late"]);
expect(options[0]!.group?.key).toBe("suggested");
expect(options[1]!.group).toEqual({ key: "2010", label: "2010" });
});
});
describe("<DocumentRecordLinks>", () => {
it("lets a manager link the page to doses and visits", () => {
const html = render(
<DocumentRecordLinks doc={doc()} canManage onChange={() => undefined} />,
);
expect(html).toContain('data-slot="document-vaccination-links"');
expect(html).toContain('data-slot="document-vaccination-links-add"');
expect(html).toContain('data-slot="document-visit-links"');
});
it("shows a read-only reader the doses the page is filed against", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
vaccinationLinks: [
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: null,
vaccineName: "DTP",
},
],
})}
canManage={false}
onChange={() => undefined}
/>,
);
expect(html).toContain('data-slot="document-vaccination-link"');
expect(html).toContain("DTP");
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
});
it("shows no dose block at all when the grant withholds the doses", () => {
const html = render(
<DocumentRecordLinks
doc={doc({ vaccinationLinks: null })}
canManage
onChange={() => undefined}
/>,
);
expect(html).not.toContain("document-vaccination-links");
});
});
@@ -61,9 +61,7 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
@@ -80,6 +78,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentRecordLinks } from "./document-record-links";
import { DocumentFactsSection } from "./document-facts-review";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
@@ -102,6 +101,7 @@ type PatchInput = {
documentDate?: string | null;
episodeIds?: string[];
encounterIds?: string[];
vaccinationIds?: string[];
};
/**
@@ -408,6 +408,8 @@ export function DocumentDetailSheet({
const indexDoc = useIndexDocument();
const [mutationError, setMutationError] = useState<string | null>(null);
/** Bumped on a failed write; remounts the link block onto server state. */
const [linkResets, setLinkResets] = useState(0);
const [shareOpen, setShareOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
/**
@@ -463,11 +465,22 @@ export function DocumentDetailSheet({
`/api/documents/inbound/${documentId}`,
input,
),
onSuccess: () => {
onSuccess: (_data, input) => {
setMutationError(null);
void invalidateKeys(queryClient, [queryKeys.documents()]);
// A link written from this side changes what the visit's and the
// dose's own views list, so their reads refresh with the vault's.
void invalidateKeys(queryClient, [
queryKeys.documents(),
...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []),
...(input.vaccinationIds !== undefined
? [queryKeys.vaccinations()]
: []),
]);
},
onError: () => {
setMutationError(t("documents.detail.saveError"));
setLinkResets((n) => n + 1);
},
onError: () => setMutationError(t("documents.detail.saveError")),
});
const restore = useMutation({
@@ -1074,36 +1087,16 @@ export function DocumentDetailSheet({
/>
) : null}
{/* "Belongs to visit" — read-only here on purpose. The filing
happens at the moment the document arrives, or from the
visit's own sheet; a second editor for the same link would
be a second place the set can be changed. Each row deep-links
to the vault filtered to that visit. */}
{doc.encounterLinks.length > 0 ? (
<div className="space-y-1.5" data-slot="document-visit-links">
<p className="text-sm leading-none font-medium">
{t("documents.detail.visitsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.encounterLinks.map((link) => (
<Link
key={link.encounterId}
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
{encounterKindText(t, link.kind as EncounterKind)}
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
</span>
) : null}
</Link>
))}
</div>
</div>
) : null}
{/* Which visits and doses this page is filed against, and — for
someone who may manage the vault — the links themselves.
Keyed on the write-failure count so a refused write drops
the local selection back to what the server confirmed. */}
<DocumentRecordLinks
key={`${doc.id}:${linkResets}`}
doc={doc}
canManage={canManageDocuments}
onChange={(part) => patch.mutate(part)}
/>
{/* Staged-facts review + the stored-text extract recovery. The
existing extract/confirm chain finally gets its control on
@@ -0,0 +1,246 @@
"use client";
/**
* "What is this page filed against?" — the document side of the visit and
* vaccination links, on the vault's detail sheet.
*
* Both links already existed as tables, but only one end could write them:
* a visit's form picked its documents, a dose's form picked its pages, and
* the document itself could show its visits read-only and its doses not at
* all. A childhood record covering a dozen doses had to be attached dose by
* dose, from twelve forms. Here the page lists what it is filed against and,
* for someone who may manage the vault, links and unlinks it in one place.
* Both ends write the SAME table through the link service, so the dose's
* form and this sheet cannot disagree about what is filed where.
*
* Every name and date comes from the server: the visit kind as its enum
* constant for the reader's bundle, the dose as its catalogue slug or the
* person's own wording. The pick lists come from the visit and vaccination
* list routes, with the records dated near the document on top under the
* same seven-day window the upload suggestion uses.
*
* A module that is off leaves no block behind, and a grant that does not
* reach the doses (`vaccinationLinks: null`) shows no dose block rather than
* an empty one that would claim the page records none.
*/
import { useState } from "react";
import Link from "next/link";
import { CalendarDays, Syringe } from "lucide-react";
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
import { isNearAnchor } from "@/components/links/vault-document-options";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
import { useEncounters } from "@/hooks/use-encounters";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
type Translate = ReturnType<typeof useTranslations>["t"];
/** Records near the document first, then the rest by year, newest first. */
export function recordOptions(
records: ReadonlyArray<{
id: string;
label: string;
meta?: string | null;
occurredAt: string;
}>,
anchor: string | null,
labels: { suggested: string; date: (iso: string) => string },
): EntityLinkOption[] {
const near: EntityLinkOption[] = [];
const rest: EntityLinkOption[] = [];
for (const record of records) {
const option = {
id: record.id,
label: record.label,
meta: record.meta ?? null,
dateLabel: labels.date(record.occurredAt),
};
if (isNearAnchor(record.occurredAt, anchor)) {
near.push({
...option,
group: { key: "suggested", label: labels.suggested },
});
} else {
const year = record.occurredAt.slice(0, 4);
rest.push({ ...option, group: { key: year, label: year } });
}
}
return [...near, ...rest];
}
/** A dose named the way the vaccination page names it. */
function doseName(
t: Translate,
catalogSlug: string | null,
vaccineName: string | null,
): string {
if (catalogSlug) return t(`vaccinations.catalog.${catalogSlug}`);
return vaccineName ?? t("vaccinations.suggestion.unnamed");
}
/**
* The caller mounts this keyed on the document and on its write-failure
* count, so the local selection below starts over from the server's answer
* whenever the sheet moves to another document or a write fails.
*/
export function DocumentRecordLinks({
doc,
canManage,
onChange,
}: {
doc: InboundDocumentDetailDto;
canManage: boolean;
onChange: (part: {
encounterIds?: string[];
vaccinationIds?: string[];
}) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
const vaccinationsOn = user?.modules?.vaccinations !== false;
const anchor = doc.documentDate ?? doc.reportDate;
const encounters = useEncounters(canManage);
const vaccinations = useVaccinations(
null,
canManage && vaccinationsOn && doc.vaccinationLinks !== null,
);
// The selection the person is editing, ahead of the server. Each change is
// sent as the whole set (a replace-set PATCH), so a quick second tap never
// computes from a list the first tap has not refreshed yet.
const [visitIds, setVisitIds] = useState<string[] | null>(null);
const [doseIds, setDoseIds] = useState<string[] | null>(null);
const labels = {
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
};
const visitOptions = recordOptions(
[
...(encounters.data?.upcoming ?? []),
...(encounters.data?.past ?? []),
].map((visit) => ({
id: visit.id,
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
})),
anchor,
labels,
);
const doseOptions = recordOptions(
(vaccinations.data?.vaccinations ?? []).map((dose) => ({
id: dose.id,
label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName),
occurredAt: dose.occurredAt,
})),
anchor,
labels,
);
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
return (
<>
{canManage ? (
<EntityLinkPicker
icon={CalendarDays}
title={t("documents.detail.visitsLabel")}
slot="document-visit-links"
pending={encounters.isPending}
error={encounters.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void encounters.refetch()}
selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)}
onChange={(ids) => {
setVisitIds(ids);
onChange({ encounterIds: ids });
}}
options={visitOptions}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("documents.detail.noVisitsToLink")}
/>
) : doc.encounterLinks.length > 0 ? (
<div className="space-y-1.5" data-slot="document-visit-links">
<p className="text-sm leading-none font-medium">
{t("documents.detail.visitsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.encounterLinks.map((link) => (
<Link
key={link.encounterId}
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
{encounterKindText(t, link.kind as EncounterKind)}
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
</span>
) : null}
</Link>
))}
</div>
</div>
) : null}
{showDoses && canManage ? (
<EntityLinkPicker
icon={Syringe}
title={t("documents.detail.vaccinationsLabel")}
slot="document-vaccination-links"
pending={vaccinations.isPending}
error={vaccinations.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void vaccinations.refetch()}
selected={
doseIds ??
(doc.vaccinationLinks ?? []).map((link) => link.vaccinationId)
}
onChange={(ids) => {
setDoseIds(ids);
onChange({ vaccinationIds: ids });
}}
options={doseOptions}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("documents.detail.noVaccinationsToLink")}
/>
) : showDoses && (doc.vaccinationLinks?.length ?? 0) > 0 ? (
<div className="space-y-1.5" data-slot="document-vaccination-links">
<p className="text-sm leading-none font-medium">
{t("documents.detail.vaccinationsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{(doc.vaccinationLinks ?? []).map((link) => (
<Link
key={link.vaccinationId}
href="/vaccinations"
data-slot="document-vaccination-link"
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
{doseName(t, link.catalogSlug, link.vaccineName)}
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
</span>
) : null}
</Link>
))}
</div>
</div>
) : null}
</>
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
import { listLabResultsSchema } from "@/lib/validations/labs";
import {
EPISODE_FETCH_LIMIT,
PICKER_FETCH_LIMIT,
} from "../encounter-link-pickers";
/**
* Each picker's page size must be one its list route accepts. A refused size
* comes back as a 422, and a picker that treats a failed read as an empty
* list tells the person there is nothing to link. That is how the visit
* form's condition picker and both document pickers showed nothing at all.
*/
describe("visit link picker page sizes", () => {
it("the condition picker asks for a page the episode route accepts", () => {
expect(
illnessEpisodeListQuerySchema.safeParse({
limit: String(EPISODE_FETCH_LIMIT),
includeResolved: "true",
}).success,
).toBe(true);
});
it("the lab picker asks for a page the lab route accepts", () => {
expect(
listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) })
.success,
).toBe(true);
});
});
@@ -273,6 +273,7 @@ export function EncounterForm({
<EncounterLinkPickers
modules={user?.modules}
anchor={draft.occurredAt || null}
documentIds={draft.documentIds}
labResultIds={draft.labResultIds}
episodeIds={draft.episodeIds}
@@ -27,14 +27,20 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { ModuleKey } from "@/lib/modules/registry";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
EntityLinkPicker,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
const PICKER_FETCH_LIMIT = 200;
export const PICKER_FETCH_LIMIT = 200;
/**
* The episode list route caps `limit` at 100 and answers anything above it
* with a 422, which the picker used to read as "nothing here to link".
*/
export const EPISODE_FETCH_LIMIT = 100;
interface LabListPage {
results: Array<{
@@ -45,18 +51,17 @@ interface LabListPage {
}>;
}
interface DocumentListPage {
documents: InboundDocumentDto[];
}
export function EncounterLinkPickers({
modules,
anchor,
documentIds,
labResultIds,
episodeIds,
onChange,
}: {
modules: Partial<Record<ModuleKey, boolean>> | undefined;
/** The visit's own date, for the document suggestions on top. */
anchor: string | null;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
@@ -73,13 +78,12 @@ export function EncounterLinkPickers({
const labsOn = modules?.labs === true;
const illnessOn = modules?.illness === true;
const documents = useQuery({
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
// The whole vault, suggestions near the visit's date on top — see
// `useVaultDocumentOptions` for why this no longer asks for one oversized
// page.
const documents = useVaultDocumentOptions({
enabled: documentsOn,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
),
anchor,
});
const labs = useQuery({
@@ -103,30 +107,12 @@ export function EncounterLinkPickers({
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=true&limit=${PICKER_FETCH_LIMIT}`,
`/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`,
),
});
if (!documentsOn && !labsOn && !illnessOn) return null;
// Documents group by month; the caller localizes the heading.
const documentOptions: EntityLinkOption[] = (
documents.data?.documents ?? []
).map((doc) => {
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
return {
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
dateLabel: date ? format.date(date) : null,
group: date
? {
key: date.slice(0, 7),
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
}
: null,
};
});
// Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte
// underneath. A visit links the whole panel from a day in one "select all".
const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map(
@@ -163,10 +149,13 @@ export function EncounterLinkPickers({
icon={FolderOpen}
title={t("encounters.form.linkDocuments")}
slot="encounter-link-documents"
pending={documents.isPending}
pending={documents.pending}
error={documents.error}
errorLabel={t("links.picker.loadError")}
onRetry={documents.retry}
selected={documentIds}
onChange={(ids) => onChange({ documentIds: ids })}
options={documentOptions}
options={documents.options}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
@@ -178,6 +167,9 @@ export function EncounterLinkPickers({
title={t("encounters.form.linkLabResults")}
slot="encounter-link-labs"
pending={labs.isPending}
error={labs.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void labs.refetch()}
selected={labResultIds}
onChange={(ids) => onChange({ labResultIds: ids })}
options={labOptions}
@@ -192,6 +184,9 @@ export function EncounterLinkPickers({
title={t("encounters.form.linkConditions")}
slot="encounter-link-conditions"
pending={episodes.isPending}
error={episodes.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void episodes.refetch()}
selected={episodeIds}
onChange={(ids) => onChange({ episodeIds: ids })}
options={episodeOptions}
@@ -64,12 +64,16 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
// and feed it to both the wellness strip and this grid. Imported above.
/** Up-is-bad for the vitals where a rise is unfavourable. */
/**
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
* them: which way it should move depends on the person's own target (#1006,
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
* it stays neutral rather than assuming a fall is good.
*/
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
"BODY_TEMPERATURE",
"BLOOD_GLUCOSE",
"WEIGHT",
]);
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
@@ -140,6 +140,24 @@ describe("<EntityLinkPicker> inline summary", () => {
expect(html).not.toContain('data-slot="test-link-add"');
});
it("a failed read shows an error row with retry, never the empty label", () => {
const html = render(
<EntityLinkPicker
{...base}
options={[]}
pending={true}
error
errorLabel="Could not load documents"
onRetry={() => undefined}
selected={[]}
/>,
);
expect(html).toContain('data-slot="test-link-error"');
expect(html).toContain("Could not load documents");
expect(html).toContain('role="alert"');
expect(html).not.toContain("Nothing to link");
});
it("renders a skeleton while the options load", () => {
const html = render(
<EntityLinkPicker {...base} options={[]} pending={true} selected={[]} />,
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import {
documentOptionsWithSuggestions,
isNearAnchor,
type VaultDocument,
} from "../vault-document-options";
function doc(
id: string,
documentDate: string | null,
extra: Partial<VaultDocument> = {},
): VaultDocument {
return {
id,
title: `Doc ${id}`,
filename: null,
documentDate,
reportDate: null,
createdAt: "2026-01-01T10:00:00.000Z",
...extra,
};
}
const LABELS = {
suggested: "Suggested",
month: (iso: string) => iso.slice(0, 7),
date: (iso: string) => iso.slice(0, 10),
};
describe("isNearAnchor", () => {
const anchor = "2026-05-10T00:00:00.000Z";
it("is inclusive at seven days either side", () => {
expect(isNearAnchor("2026-05-03", anchor)).toBe(true);
expect(isNearAnchor("2026-05-17", anchor)).toBe(true);
expect(isNearAnchor("2026-05-02", anchor)).toBe(false);
expect(isNearAnchor("2026-05-18", anchor)).toBe(false);
});
it("never matches without an anchor or a date", () => {
expect(isNearAnchor(null, anchor)).toBe(false);
expect(isNearAnchor("2026-05-10", null)).toBe(false);
});
});
describe("documentOptionsWithSuggestions", () => {
it("offers the WHOLE vault, not only the documents near the record's date", () => {
// A childhood record dated years before the dose it also covers is the
// case the old picker could not reach.
const options = documentOptionsWithSuggestions(
[doc("near", "2026-05-12"), doc("old", "1991-03-01")],
"2026-05-10T00:00:00.000Z",
LABELS,
);
expect(options.map((o) => o.id)).toEqual(["near", "old"]);
});
it("puts the documents within the window on top under one suggestion group", () => {
const options = documentOptionsWithSuggestions(
[
doc("recent", "2026-09-01"),
doc("near", "2026-05-12"),
doc("old", "1991-03-01"),
],
"2026-05-10T00:00:00.000Z",
LABELS,
);
expect(options[0]).toMatchObject({
id: "near",
group: { key: "suggested", label: "Suggested" },
});
expect(options.slice(1).map((o) => o.id)).toEqual(["recent", "old"]);
expect(options[1]!.group).toEqual({ key: "2026-09", label: "2026-09" });
});
it("reads a report date when the filing date is missing", () => {
const options = documentOptionsWithSuggestions(
[doc("lab", null, { reportDate: "2026-05-09" })],
"2026-05-10T00:00:00.000Z",
LABELS,
);
expect(options[0]!.group?.key).toBe("suggested");
});
it("without an anchor keeps the plain month grouping", () => {
const options = documentOptionsWithSuggestions(
[doc("a", "2026-05-12")],
null,
LABELS,
);
expect(options[0]!.group?.key).toBe("2026-05");
});
});
describe("fetchWholeVault", () => {
it("asks only for pages the list route accepts, and walks every cursor", async () => {
const { documentListQuerySchema } =
await import("@/lib/validations/inbound-documents");
const apiFetch = await import("@/lib/api/api-fetch");
const urls: string[] = [];
const pages = [
{ documents: [doc("a", "2026-05-01")], nextCursor: "a" },
{ documents: [doc("b", "1991-03-01")], nextCursor: null },
];
const spy = vi
.spyOn(apiFetch, "apiGet")
.mockImplementation(async (url: string) => {
urls.push(url);
return pages.shift() as never;
});
try {
const { fetchWholeVault } = await import("../vault-document-options");
const documents = await fetchWholeVault();
expect(documents.map((d) => d.id)).toEqual(["a", "b"]);
expect(urls).toHaveLength(2);
for (const url of urls) {
// The route parses its query with this schema and answers a 422 to
// anything it refuses; the picker used to read that 422 as an empty
// vault.
const params = Object.fromEntries(
new URL(url, "http://local").searchParams,
);
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
}
expect(urls[1]).toContain("cursor=a");
} finally {
spy.mockRestore();
}
});
});
+19 -1
View File
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -131,6 +132,9 @@ export function EntityLinkPicker({
onChange,
searchPlaceholder,
emptyLabel,
error = false,
errorLabel,
onRetry,
}: {
icon: LucideIcon;
title: string;
@@ -142,6 +146,14 @@ export function EntityLinkPicker({
onChange: (ids: string[]) => void;
searchPlaceholder: string;
emptyLabel: string;
/**
* The option read failed. Renders an error row with Retry instead of the
* empty label: a failed read must never pass for "nothing to link", which
* is exactly how a refused page size once hid a whole vault.
*/
error?: boolean;
errorLabel?: string;
onRetry?: () => void;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -175,8 +187,14 @@ export function EntityLinkPicker({
) : null}
</div>
{pending ? (
{pending && !error ? (
<Skeleton className="h-9 w-full rounded-md" />
) : error ? (
<QueryErrorRow
message={errorLabel}
onRetry={onRetry}
slot={`${slot}-error`}
/>
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
) : (
@@ -0,0 +1,170 @@
"use client";
/**
* The vault as a link-picker option list, with the documents near the
* record's own date on top.
*
* The visit and vaccination forms used to fetch their document options with a
* page size the list route refuses (`limit=200` against a ceiling of 100). The
* 422 read as an empty list, so the picker said "No documents yet." to a
* person with a full vault, and the only way a scan ever got attached was the
* ±7-day suggestion offered at upload. A childhood record covering eight doses
* from one sheet of paper could therefore be filed against exactly one of
* them.
*
* Now the whole vault is offered, paged through at the route's own ceiling,
* and a read failure says so instead of pretending the vault is empty. The
* documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day
* window the upload suggestion uses stay on top under their own heading, so
* the common case is still one tap; everything else follows, grouped by month
* and searchable.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
import { apiGet } from "@/lib/api/api-fetch";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DOCUMENT_LIST_MAX_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
/**
* How many pages the picker walks. At the route's ceiling of 100 that is a
* thousand documents, past which the vault's own search is the better tool.
*/
const VAULT_PICKER_MAX_PAGES = 10;
const DAY_MS = 24 * 60 * 60 * 1000;
/** The fields the option list reads off a document. */
export type VaultDocument = Pick<
InboundDocumentDto,
"id" | "title" | "filename" | "documentDate" | "reportDate" | "createdAt"
>;
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
/** Walk the vault newest filing date first, at the route's own page ceiling. */
export async function fetchWholeVault(): Promise<VaultDocument[]> {
const documents: VaultDocument[] = [];
let cursor: string | null = null;
for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) {
const params = new URLSearchParams({
sort: "documentDate",
order: "desc",
limit: String(DOCUMENT_LIST_MAX_LIMIT),
});
if (cursor) params.set("cursor", cursor);
const result: ListPage = await apiGet<ListPage>(
`/api/documents/inbound?${params.toString()}`,
);
documents.push(...result.documents);
cursor = result.nextCursor;
if (!cursor) break;
}
return documents;
}
/**
* Is a document dated within the suggestion window of the record's anchor?
* Day-granular and inclusive on both edges, like the upload suggestion. A
* document with no date of its own is never "near" anything.
*/
export function isNearAnchor(
documentDay: string | null,
anchorIso: string | null,
): boolean {
if (!documentDay || !anchorIso) return false;
const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`);
const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`);
if (Number.isNaN(day) || Number.isNaN(anchor)) return false;
return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
}
/**
* Build the option list: suggestions first, under one heading, then the rest
* of the vault in the route's order (newest filing date first), by month.
*
* `labels` carries the already-localised strings, so this stays a pure
* function a test can call without a provider.
*/
export function documentOptionsWithSuggestions(
documents: readonly VaultDocument[],
anchorIso: string | null,
labels: {
suggested: string;
month: (iso: string) => string;
date: (iso: string) => string;
},
): EntityLinkOption[] {
const suggested: EntityLinkOption[] = [];
const rest: EntityLinkOption[] = [];
for (const doc of documents) {
const own = doc.documentDate ?? doc.reportDate;
const date = own ?? doc.createdAt;
const base = {
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
dateLabel: date ? labels.date(date) : null,
};
if (isNearAnchor(own, anchorIso)) {
suggested.push({
...base,
group: { key: "suggested", label: labels.suggested },
});
} else {
rest.push({
...base,
group: date
? { key: date.slice(0, 7), label: labels.month(date) }
: null,
});
}
}
return [...suggested, ...rest];
}
/**
* The picker's data: the whole vault as options, the suggestion group on top
* when the record has a date, and the query state an error row needs.
*/
export function useVaultDocumentOptions({
enabled,
anchor,
}: {
enabled: boolean;
/** The record's own date (ISO), or null when it has none yet. */
anchor: string | null;
}) {
const { t } = useTranslations();
const format = useFormatters();
const query = useQuery({
queryKey: queryKeys.inboundDocumentVaultPicker(),
enabled,
queryFn: fetchWholeVault,
});
const options = useMemo(
() =>
documentOptionsWithSuggestions(query.data ?? [], anchor, {
suggested: t("links.picker.suggested"),
month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`,
date: (iso) => format.date(iso),
}),
[query.data, anchor, t, format],
);
return {
options,
pending: query.isPending,
error: query.isError,
retry: () => void query.refetch(),
};
}
@@ -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");
});
});
@@ -158,6 +158,9 @@ export function useLinkDocumentToVaccination() {
onSuccess: () =>
Promise.all([
invalidateKeys(qc, vaccinationDependentKeys),
// The document's own sheet lists the doses it is filed against, so
// a link made from the suggestion there has to repaint it.
invalidateKeys(qc, [queryKeys.documents()]),
// The linked-document badge can surface on the daily reads' checkup
// items; force the unmounted snapshot/digest to refetch like the
// sibling mutations do (via `invalidateReminderReads`).
@@ -5,6 +5,11 @@
* transcribed from. The shared {@link EntityLinkPicker} — an inline summary
* (removable chips + an add button) over a searchable, month-grouped sheet.
*
* The whole vault is offered, not only the pages dated near the dose: one
* childhood record commonly covers a dozen doses given years apart, and it
* belongs on every one of them. The pages dated within the upload
* suggestion's window of this dose sit on top as suggestions.
*
* **The gate blanks the block, it does not post-filter it.** When the
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
* list — because an empty picker for a switched-off module advertises a feature
@@ -13,75 +18,43 @@
* Nothing here can block a save: the list starts empty and stays valid empty,
* and linking is optional, capped and idempotent behind the link facade.
*/
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
EntityLinkPicker,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
const PICKER_FETCH_LIMIT = 200;
interface DocumentListPage {
documents: InboundDocumentDto[];
}
import { useTranslations } from "@/lib/i18n/context";
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
export function VaccinationDocumentPicker({
enabled,
anchor,
documentIds,
onChange,
}: {
/** The `inboundDocuments` module flag — false blanks the block entirely. */
enabled: boolean;
/** The dose's own date (ISO), for the suggestions on top. */
anchor: string | null;
documentIds: string[];
onChange: (documentIds: string[]) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const documents = useQuery({
queryKey: queryKeys.inboundDocumentPicker("vaccination-form"),
enabled,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
),
});
const vault = useVaultDocumentOptions({ enabled, anchor });
if (!enabled) return null;
const options: EntityLinkOption[] = (documents.data?.documents ?? []).map(
(doc) => {
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
return {
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
dateLabel: date ? format.date(date) : null,
group: date
? {
key: date.slice(0, 7),
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
}
: null,
};
},
);
return (
<div className="border-t pt-4" data-slot="vaccination-document-picker">
<EntityLinkPicker
icon={FolderOpen}
title={t("vaccinations.form.linkDocuments")}
slot="vaccination-document"
pending={documents.isPending}
pending={vault.pending}
error={vault.error}
errorLabel={t("links.picker.loadError")}
onRetry={vault.retry}
selected={documentIds}
onChange={onChange}
options={options}
options={vault.options}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("vaccinations.form.linkNothingToOffer")}
/>
@@ -305,6 +305,7 @@ export function VaccinationForm({
<VaccinationDocumentPicker
enabled={user?.modules?.inboundDocuments === true}
anchor={anchor}
documentIds={draft.documentIds}
onChange={(documentIds) => patch({ documentIds })}
/>
@@ -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}
/>
) : (
+2
View File
@@ -7,6 +7,7 @@ const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
- Der Snapshot trägt weight.signal (der fertige Vergleich) + weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages.
- Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier.
- Wochen-/Monatstrend zählt, nicht der Einzelwert: Führe mit weight.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund.
- Eigenes Ziel: Trägt der Snapshot weight.target, ist das der Zielbereich, den die Person selbst gesetzt hat; weight.target.progress sagt, welche Richtung Fortschritt ist (gaining = zunehmen, losing = abnehmen, holding steady = halten). Beurteile jede Veränderung GEGEN dieses Ziel: Liegt die Person darunter, ist eine Zunahme Fortschritt und nie ein Rückschritt. Die Hinweise zu Tempo und Meilensteinen beim Abnehmen unten gelten nur, wenn Abnehmen die Fortschrittsrichtung ist oder kein Ziel vorliegt.
- Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen.
- Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen.
- Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen.
@@ -18,6 +19,7 @@ const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
- The snapshot carries weight.signal (the finished comparison) + weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
- This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here.
- The weekly/monthly trend matters, not the single value: lead from weight.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding.
- Own target: when the snapshot carries weight.target, that is the target band the person set themselves, and weight.target.progress says which way is progress (gaining, losing, or holding steady). Judge every change AGAINST that target: below it, a gain is progress and never a setback or a regression. The loss pace and milestone guidance below applies only when losing is the progress direction or no target is set.
- Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging.
- Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously.
- Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached.
@@ -306,6 +306,59 @@ describe("buildDashboardSnapshot — envelope shape", () => {
});
});
describe("buildDashboardSnapshot — weight trend judged against the stored target (#1006)", () => {
// The summary mock serves WEIGHT latest 80 (no avg7), so the reference
// weight is 80 kg in every case below.
beforeEach(() => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
isFullyCovered.mockReturnValue(false);
});
it("below the target: a gain is progress", async () => {
const snap = await buildDashboardSnapshot(
fakePrisma,
baseUser({ thresholdsJson: { WEIGHT: { min: 85, max: 90 } } }),
);
expect(snap.tiles.weightTrend).toEqual({
direction: "up-good",
targetPosition: "below",
});
});
it("inside the target: holding steady is progress", async () => {
const snap = await buildDashboardSnapshot(
fakePrisma,
baseUser({ thresholdsJson: { WEIGHT: { min: 75, max: 82 } } }),
);
expect(snap.tiles.weightTrend).toEqual({
direction: "hold",
targetPosition: "inside",
});
});
it("above the target: a loss is progress", async () => {
const snap = await buildDashboardSnapshot(
fakePrisma,
baseUser({ thresholdsJson: { WEIGHT: { min: 70, max: 75 } } }),
);
expect(snap.tiles.weightTrend).toEqual({
direction: "up-bad",
targetPosition: "above",
});
});
it("no stored target keeps today's reading, even with a height-derived band", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
// The WHO band from height still shades the chart…
expect(snap.targetBands.weightRange).not.toBeNull();
// …but nobody chose it, so it does not decide the direction.
expect(snap.tiles.weightTrend).toEqual({
direction: "up-bad",
targetPosition: null,
});
});
});
describe("buildDashboardSnapshot — two-phase null extras", () => {
it("returns extras: null on a rollup-coverage miss and never runs the thick read", async () => {
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
+23 -1
View File
@@ -59,6 +59,11 @@ import {
resolveWeightTargetOverride,
} from "@/lib/analytics/effective-range";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import {
resolveWeightTrend,
weightTrendReferenceKg,
type WeightTrendJudgement,
} from "@/lib/targets/weight-trend";
import {
probeRollupCoverage,
type RollupCoverageMap,
@@ -436,6 +441,15 @@ export interface DashboardSnapshot {
* (additive contract) so older cached snapshots / fixtures stay valid.
*/
sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null;
/**
* v1.39 — which way a weight change counts as progress, judged against
* the person's own stored target (`src/lib/targets/weight-trend.ts`).
* The weight tile colours its arrow, delta and comparison caption by
* `direction`; a client never re-derives it. Optional on the type
* (additive contract) so older cached snapshots stay valid; the live
* builder always sets it.
*/
weightTrend?: WeightTrendJudgement;
};
/** Thick phase — null on a rollup-coverage miss. */
extras: DashboardSnapshotExtras | null;
@@ -1413,6 +1427,8 @@ export async function buildDashboardSnapshot(
const gender = toProfileSex(user.gender);
const weightTargetOverride = resolveWeightTargetOverride(user.thresholdsJson);
return {
user: {
username: user.displayName?.trim() || user.username,
@@ -1433,7 +1449,7 @@ export async function buildDashboardSnapshot(
// v1.34 — the user's own weight target, when they set one, replaces the
// height-derived band on the tile + the chart. Resolved off the same
// `thresholdsJson` blob the health score reads.
weightTargetOverride: resolveWeightTargetOverride(user.thresholdsJson),
weightTargetOverride,
}),
tiles: {
summaries: slim.summaries,
@@ -1446,6 +1462,12 @@ export async function buildDashboardSnapshot(
// disabled sleep module carries no sleep annotation either.
sleepSourceDiscrepancy:
modules.sleep === false ? null : slimRaw.sleepSourceDiscrepancy,
// v1.39 — the weight tile's progress direction, judged against the
// stored target from the same summary the tile renders.
weightTrend: resolveWeightTrend(
weightTargetOverride,
weightTrendReferenceKg(slim.summaries.WEIGHT),
),
},
extras: extrasResult?.extras ?? null,
medsToday,
+86
View File
@@ -11,7 +11,9 @@ import { listTargetsBySource, replaceTargets } from "@/lib/links";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
DocumentVaccinationLinkDto,
} from "@/lib/validations/inbound-documents";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
/**
* Load the condition links for a page of documents in ONE grouped query.
@@ -162,3 +164,87 @@ export async function replaceConditionLinks(
targetIds: episodeIds,
});
}
/**
* Load the VACCINATION links for one or more documents in two grouped
* queries: the links through the link service, then the doses' identity so
* the client can name each one from its own bundle.
*
* Tombstoned doses drop out, the same honest default the visit links take.
*/
export async function loadDocumentVaccinationLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentVaccinationLinkDto[]>> {
const map = new Map<string, DocumentVaccinationLinkDto[]>();
if (documentIds.length === 0) return map;
const byDocument = await listTargetsBySource(prisma, {
userId,
sourceKind: "document",
sourceIds: documentIds,
targetKind: "vaccination",
});
const doseIds = [
...new Set([...byDocument.values()].flat().map((target) => target.id)),
];
if (doseIds.length === 0) return map;
const doses = await prisma.vaccinationRecord.findMany({
where: { id: { in: doseIds }, userId },
select: { id: true, antigenSlug: true, vaccineName: true },
});
const doseById = new Map(doses.map((dose) => [dose.id, dose]));
for (const [documentId, targets] of byDocument) {
map.set(
documentId,
targets.map((target) => {
const dose = doseById.get(target.id);
return {
vaccinationId: target.id,
occurredAt: target.date,
catalogSlug:
resolveCatalogEntry(dose?.antigenSlug ?? null)?.slug ?? null,
vaccineName: dose?.vaccineName ?? null,
};
}),
);
}
return map;
}
/**
* Narrow a client-sent dose-id list to the caller's LIVE doses. Same shape
* and same refusal as {@link narrowOwnedEncounterIds}.
*/
export async function narrowOwnedVaccinationIds(
userId: string,
vaccinationIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(vaccinationIds)];
if (unique.length === 0) return [];
const owned = await prisma.vaccinationRecord.findMany({
where: { id: { in: unique }, userId, deletedAt: null },
select: { id: true },
});
if (owned.length !== unique.length) return null;
return unique;
}
/**
* Replace-set a document's vaccination links through the link service, in
* the `document → vaccination` direction of the table the dose's own form
* writes.
*/
export async function replaceVaccinationLinks(
tx: Prisma.TransactionClient,
userId: string,
documentId: string,
vaccinationIds: string[],
): Promise<void> {
await replaceTargets(tx, {
userId,
sourceKind: "document",
sourceId: documentId,
targetKind: "vaccination",
targetIds: vaccinationIds,
});
}
+3
View File
@@ -21,6 +21,7 @@ import { toIndexOutcome } from "@/lib/validations/inbound-documents";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
DocumentVaccinationLinkDto,
DocumentContentIndexSourceValue,
DocumentSummaryStateValue,
ExtractedFactDto,
@@ -275,6 +276,7 @@ export function serialiseDocumentDetail(
contentIndexSource: DocumentContentIndexSourceValue | null = null,
hasThumbnail = false,
encounterLinks: DocumentEncounterLinkDto[] = [],
vaccinationLinks: DocumentVaccinationLinkDto[] | null = [],
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
@@ -324,6 +326,7 @@ export function serialiseDocumentDetail(
encounterLinks,
),
facts: facts.map(serialiseFact),
vaccinationLinks,
summary,
summaryGeneratedAt: doc.summaryGeneratedAt
? doc.summaryGeneratedAt.toISOString()
+2
View File
@@ -73,6 +73,8 @@ const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
labResult: "labs",
conditionEpisode: "illness",
encounter: "profile",
// A dose lives in the health background, like the visit itself.
vaccination: "profile",
};
/**
@@ -326,6 +326,39 @@ describe("extractFeatures — v1.25.1 clinical-depth aggregate blocks", () => {
});
});
describe("extractFeatures — weight target direction (#1006)", () => {
const weightRows = [
measurementRow("WEIGHT", 60, 1),
measurementRow("WEIGHT", 59.6, 3),
measurementRow("WEIGHT", 59.2, 5),
];
it("tells the model gaining is progress when the person is below their own target", async () => {
prismaMock.user.findUnique.mockResolvedValue({
heightCm: 180,
dateOfBirth: new Date("1980-01-01"),
gender: "MALE",
thresholdsJson: { WEIGHT: { min: 65, max: 70 } },
});
prismaMock.measurement.findMany.mockResolvedValue(weightRows);
const f = await extractFeatures("user-1", false);
expect(f.weight?.target).toMatchObject({
minKg: 65,
maxKg: 70,
position: "below",
progress: "gaining",
});
expect(f.weight?.target?.reading).toMatch(/gaining weight is progress/);
});
it("says nothing about direction when no target is stored", async () => {
prismaMock.measurement.findMany.mockResolvedValue(weightRows);
const f = await extractFeatures("user-1", false);
expect(f.weight).toBeDefined();
expect(f.weight?.target).toBeUndefined();
});
});
describe("extractFeatures — v1.22 labs briefing block", () => {
it("surfaces an abnormal (out-of-range) biomarker", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
@@ -396,3 +396,43 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", (
expect(notes[0].text).not.toContain("metric:");
});
});
describe("generateWeightStatusForUser — judged against the stored target (#1006)", () => {
async function snapshotFor(thresholdsJson: unknown) {
const now = new Date();
const records = Array.from({ length: 40 }, (_, day) => ({
type: "WEIGHT",
// Rising from 58 toward a 65–70 target over the last forty days.
value: 60 - day * 0.05,
measuredAt: new Date(now.getTime() - day * dayMs),
}));
vi.mocked(prisma.user.findUnique).mockResolvedValue({
thresholdsJson,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
await generateWeightStatusForUser("user-1", { locale: "en" });
return JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
}
it("below the target, the model reads gaining as progress", async () => {
const snapshot = await snapshotFor({ WEIGHT: { min: 65, max: 70 } });
expect(snapshot.weight.target).toMatchObject({
position: "below",
progress: "gaining",
});
expect(snapshot.weight.signal.direction).toBe("higher-better");
});
it("without a target, no direction is claimed", async () => {
const snapshot = await snapshotFor(null);
expect(snapshot.weight.target).toBeUndefined();
expect(snapshot.weight.signal.direction).toBe("target-band");
});
});
+18
View File
@@ -53,6 +53,11 @@ import {
} from "@/lib/insights/signals-of-day";
import type { RollupGranularity } from "@/generated/prisma/client";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import {
buildWeightTargetFeature,
type WeightTargetFeature,
} from "@/lib/targets/weight-trend";
// The briefing read blocks and the signals-of-day builder moved to
// sibling modules; re-exported so every existing call site keeps
// importing from here.
@@ -442,6 +447,13 @@ export interface AggregatedFeatures {
outlierCount: number;
bmi: number | null;
coverage: DataCoverage;
/**
* v1.39 (#1006) — the person's own stored weight target and which way is
* progress from where they are now. Absent when no target is stored, and
* then nothing here says which way is better. Present so a narrative
* never calls a gain a regression when gaining is exactly the goal.
*/
target?: WeightTargetFeature;
};
bloodPressure?: {
avgSys30: number | null;
@@ -952,6 +964,7 @@ export async function extractFeatures(
heightCm: true,
dateOfBirth: true,
gender: true,
thresholdsJson: true,
},
});
@@ -1105,6 +1118,11 @@ export async function extractFeatures(
bmi,
coverage: computeCoverage(weightData, now),
};
const weightTarget = buildWeightTargetFeature(
resolveWeightTargetOverride(user?.thresholdsJson),
{ avg7: summary.avg7, latest: summary.latest },
);
if (weightTarget) features.weight.target = weightTarget;
}
// Blood Pressure
+14 -2
View File
@@ -16,8 +16,13 @@
* ↑ orange, ↓ green.
* - `neutral` — direction carries no value judgement (the change renders
* muted).
* - `hold` — the value already sits where the person wants it (weight
* inside their own target band). Staying put is progress
* and renders green; a move either way renders muted, never
* orange, because a small drift inside the band is not a
* setback. See `src/lib/targets/weight-trend.ts`.
*/
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral";
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral" | "hold";
/**
* The resolved sentiment of a signed change under a metric's direction.
@@ -33,7 +38,14 @@ export function getTrendSentiment(
change: number | null | undefined,
sentiment: TrendDirectionSentiment,
): TrendSentimentDirection {
if (change == null || Math.abs(change) < 0.05) return "neutral";
if (change == null) return "neutral";
// Steady inside the target: the one case where "no change" is the news.
// Uses the same noise floor that draws the flat arrow, so a green arrow is
// always the flat one.
if (sentiment === "hold") {
return Math.abs(change) < 0.05 ? "positive" : "neutral";
}
if (Math.abs(change) < 0.05) return "neutral";
if (sentiment === "neutral") return "neutral";
const isUp = change > 0;
const isGood =
+39 -3
View File
@@ -10,6 +10,8 @@ import {
type PairedPoint,
} from "@/lib/analytics/correlations";
import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import { buildWeightTargetFeature } from "@/lib/targets/weight-trend";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
@@ -230,9 +232,24 @@ export async function prepareWeightStatusForUser(
// correlation math + the provider call) is skipped. A forced run never
// gates — but still computes the fingerprint so the persisted row carries a
// current one for the next day's gate.
// v1.39 (#1006) — the person's own weight target decides which way is
// progress. Folded into the input fingerprint so editing the target flips
// the gate even when no weight row moved.
const weightTarget = resolveWeightTargetOverride(
(
await prisma.user.findUnique({
where: { id: userId },
select: { thresholdsJson: true },
})
)?.thresholdsJson,
);
const inputHash = await computeStatusInputFingerprint({
userId,
types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
extra: {
weightTargetMin: weightTarget?.min ?? null,
weightTargetMax: weightTarget?.max ?? null,
},
includeMood: true,
// v1.18.11 (P6-tighten) — WEIGHT is an FDR discovery OUTCOME channel and
// the prompt folds `getRelevantCorrelationsForMetric(userId, "WEIGHT")`, so
@@ -444,12 +461,30 @@ export async function prepareWeightStatusForUser(
// v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline
// comparison + normal-swing verdict instead of asking it to derive them.
// Weight has no universal favourable direction or population band, so the
// signal carries the neutral "target-band" framing and no normalRange.
// Weight has no universal favourable direction or population band, so
// without a stored target the signal carries the neutral "target-band"
// framing and no normalRange. v1.39 (#1006) — with one, the direction is
// the person's own: below the band a rise is favourable, above it a fall.
const recentWeights = weightSeries.daily
.filter((bucket) => bucket.dayOffset < 7)
.map((bucket) => bucket.value);
const weightTargetFeature = buildWeightTargetFeature(weightTarget, {
avg7:
recentWeights.length > 0
? recentWeights.reduce((sum, value) => sum + value, 0) /
recentWeights.length
: null,
latest: latestWeight?.value ?? null,
});
const weightSignal = buildMetricSignal({
metric: locale === "en" ? "your weight" : "dein Gewicht",
unit: "kg",
direction: "target-band",
direction:
weightTargetFeature?.position === "below"
? "higher-better"
: weightTargetFeature?.position === "above"
? "lower-better"
: "target-band",
graded: weightGraded,
newestDaysAgo: newestMeasurementDaysAgo,
});
@@ -465,6 +500,7 @@ export async function prepareWeightStatusForUser(
},
weight: {
...(weightSignal ? { signal: weightSignal } : {}),
...(weightTargetFeature ? { target: weightTargetFeature } : {}),
summary: summarizeSeries(
weightSeries.daily.map((bucket) => ({ value: bucket.value })),
),
+25 -1
View File
@@ -16,7 +16,7 @@ export type LinkSourceKind = "encounter" | "document" | "vaccination";
/** The record a link points at. */
export type LinkTargetKind =
"document" | "labResult" | "conditionEpisode" | "encounter";
"document" | "labResult" | "conditionEpisode" | "encounter" | "vaccination";
export interface LinkRequest {
userId: string;
@@ -187,6 +187,18 @@ const ENCOUNTER_ENDPOINT: EndpointSpec = {
date: (row) => asDate(row.occurredAt),
};
const VACCINATION_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.vaccinationRecord),
select: { id: true, antigenSlug: true, vaccineName: true, occurredAt: true },
// The person's own wording first, then the catalogue slug. A caller that
// renders a dose resolves the slug through its own bundle instead (see
// `loadDocumentVaccinationLinks`); this label is the fallback every
// generic consumer of the service gets.
label: (row) =>
asString(row.vaccineName) ?? asString(row.antigenSlug) ?? String(row.id),
date: (row) => asDate(row.occurredAt),
};
const CONDITION_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.illnessEpisode),
select: { id: true, label: true, onsetAt: true },
@@ -255,6 +267,18 @@ const LINK_TABLES: Partial<
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
// The SAME table as `vaccination:document`, read from the other end, like
// `document:encounter` beside it. A childhood record covering a dozen doses
// is filed from the document's own sheet in one pass rather than dose by
// dose; the dose's form and the document's sheet write one table, so they
// cannot disagree about what is filed where. A second direction, not a
// second vaccination table.
"document:vaccination": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.vaccinationDocumentLink),
sourceColumn: "documentId",
targetColumn: "vaccinationId",
target: VACCINATION_ENDPOINT,
},
};
function tableFor(
+1
View File
@@ -128,6 +128,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"documents.inbound.restoreGone",
"documents.inbound.uploadBusy",
"documents.inbound.uploadTimeout",
"documents.inbound.vaccinationNotFound",
],
encounter: [
"encounter.invalid",
+18 -1
View File
@@ -98,7 +98,7 @@ const inboundFactEditRequest = inboundFactEditSchema.meta({
const documentUpdateRequest = documentUpdateSchema.meta({
id: "DocumentUpdateRequest",
description:
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits), `vaccinationIds` (the same replace-set semantics over the caller's vaccination doses, up to 100 so one childhood record can be filed against a whole primary schedule). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
});
const documentBulkRequest = documentBulkSchema.meta({
@@ -156,6 +156,19 @@ const encounterLink = z
"A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words, a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the constant is what lets a German reader and an English one each see it named correctly.",
});
const vaccinationLink = z
.object({
vaccinationId: z.string(),
occurredAt: z.string().nullable(),
catalogSlug: z.string().nullable(),
vaccineName: z.string().nullable(),
})
.meta({
id: "DocumentVaccinationLink",
description:
"A link to one of the caller's vaccination doses — \"this page records that dose\". `catalogSlug` is the catalogue entry when this release still knows the dose's slug, for the reader's own bundle to name; null is the signal to show `vaccineName`, the person's own wording. The same table the dose's own `/api/vaccinations/{id}/links` writes from the other end.",
});
const inboundDocument = z
.object({
id: z.string(),
@@ -203,6 +216,10 @@ const inboundDocument = z
const inboundDocumentDetail = inboundDocument
.extend({
facts: z.array(extractedFact),
vaccinationLinks: z.array(vaccinationLink).nullable().meta({
description:
"Vaccination doses this document is filed against. Null when the caller acts inside somebody else's record under a grant that does not cover the health background the doses live in: the page is shared, the doses are not, and an empty list would claim the page records none.",
}),
summary: z.string().nullable(),
summaryGeneratedAt: z.string().nullable(),
summaryState: z.enum(DOCUMENT_SUMMARY_STATES),
@@ -1201,6 +1201,24 @@ export const dashboardSnapshotResponse = z
.describe(
"Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep tile's headline. Null when the writers agree or the sleep module is off; optional for older cached snapshots.",
),
weightTrend: z
.object({
direction: z
.enum(["up-good", "up-bad", "hold"])
.describe(
"How the weight tile colours a change. `up-good`: a rise is progress (below the target band). `up-bad`: a fall is progress (above the band, and the reading when no target is stored). `hold`: the weight is inside the band, so a flat change is progress and a move either way is neutral, never a setback.",
),
targetPosition: z
.enum(["below", "inside", "above"])
.nullable()
.describe(
"Where the 7-day average (else the latest reading) sits against the person's own stored weight target. Null when no target is stored or there is no reading.",
),
})
.optional()
.describe(
"Which way a weight change counts as progress, judged against the person's own stored target rather than assuming a fall is good. Render the arrow, 7-day delta and comparison caption from `direction`; do not recompute it. Optional for older cached snapshots; when absent, treat it as `up-bad`.",
),
}),
extras: z
.object({
+27 -5
View File
@@ -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({
+6 -5
View File
@@ -46,6 +46,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { PersonalRecordDirection } from "@/generated/prisma/client";
import { weightTargetPosition } from "@/lib/targets/weight-trend";
/**
* v1.34 — the user-goal facts the resolver needs for the goal-dependent
@@ -78,11 +79,11 @@ export function resolveWeightGoalDirection(
target: { min: number; max: number } | null,
latestWeightKg: number | null,
): "lower" | "higher" | null {
if (!target || latestWeightKg === null || !Number.isFinite(latestWeightKg)) {
return null;
}
if (latestWeightKg > target.max) return "lower";
if (latestWeightKg < target.min) return "higher";
// Same placement the weight tile's trend colour reads, so a record and an
// arrow can never disagree about which side of the target a person is on.
const position = weightTargetPosition(target, latestWeightKg);
if (position === "above") return "lower";
if (position === "below") return "higher";
return null;
}
+7
View File
@@ -54,6 +54,13 @@ export const documentKeys = {
*/
inboundDocumentPicker: (q: string) =>
["documents", "inbound", "picker", q] as const,
/**
* The whole vault as a link-picker option list (every page, walked at the
* list route's ceiling), shared by the visit and vaccination forms. Own
* segment because the shape is a flat document array, not a page.
*/
inboundDocumentVaultPicker: () =>
["documents", "inbound", "vault-picker"] as const,
/**
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
* Invalidated through the `["documents"]` prefix after every upload /
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
buildWeightTargetFeature,
resolveWeightTrend,
weightTargetPosition,
weightTrendReferenceKg,
} from "@/lib/targets/weight-trend";
import { getTrendSentiment } from "@/lib/insights/trend-sentiment";
const TARGET = { min: 70, max: 75 };
describe("weightTargetPosition", () => {
it("places a reading below, inside or above the stored target", () => {
expect(weightTargetPosition(TARGET, 65)).toBe("below");
expect(weightTargetPosition(TARGET, 70)).toBe("inside");
expect(weightTargetPosition(TARGET, 72.5)).toBe("inside");
expect(weightTargetPosition(TARGET, 75)).toBe("inside");
expect(weightTargetPosition(TARGET, 80)).toBe("above");
});
it("answers null without a target or without a usable reading", () => {
expect(weightTargetPosition(null, 80)).toBeNull();
expect(weightTargetPosition(TARGET, null)).toBeNull();
expect(weightTargetPosition(TARGET, Number.NaN)).toBeNull();
});
});
describe("resolveWeightTrend", () => {
it("below the target, gaining is progress", () => {
const trend = resolveWeightTrend(TARGET, 62);
expect(trend).toEqual({ direction: "up-good", targetPosition: "below" });
expect(getTrendSentiment(0.6, trend.direction)).toBe("positive");
expect(getTrendSentiment(-0.6, trend.direction)).toBe("negative");
});
it("above the target, losing is progress", () => {
const trend = resolveWeightTrend(TARGET, 84);
expect(trend).toEqual({ direction: "up-bad", targetPosition: "above" });
expect(getTrendSentiment(-0.6, trend.direction)).toBe("positive");
expect(getTrendSentiment(0.6, trend.direction)).toBe("negative");
});
it("inside the target, holding steady is progress and no move is a setback", () => {
const trend = resolveWeightTrend(TARGET, 72);
expect(trend).toEqual({ direction: "hold", targetPosition: "inside" });
expect(getTrendSentiment(0, trend.direction)).toBe("positive");
expect(getTrendSentiment(0.01, trend.direction)).toBe("positive");
expect(getTrendSentiment(0.6, trend.direction)).toBe("neutral");
expect(getTrendSentiment(-0.6, trend.direction)).toBe("neutral");
});
it("without a stored target keeps the historical reading: a fall reads as good", () => {
const trend = resolveWeightTrend(null, 84);
expect(trend).toEqual({ direction: "up-bad", targetPosition: null });
expect(getTrendSentiment(-0.6, trend.direction)).toBe("positive");
});
it("with a target but no reading yet keeps the historical reading too", () => {
expect(resolveWeightTrend(TARGET, null)).toEqual({
direction: "up-bad",
targetPosition: null,
});
});
});
describe("weightTrendReferenceKg", () => {
it("prefers the 7-day average so one noisy reading at the band edge does not flip the colour", () => {
expect(weightTrendReferenceKg({ avg7: 71, latest: 69 })).toBe(71);
});
it("falls back to the latest reading, then to nothing", () => {
expect(weightTrendReferenceKg({ avg7: null, latest: 69 })).toBe(69);
expect(weightTrendReferenceKg({ avg7: null, latest: null })).toBeNull();
expect(weightTrendReferenceKg(undefined)).toBeNull();
});
});
describe("getTrendSentiment — existing directions unchanged", () => {
it("still colours up-good, up-bad and neutral exactly as before", () => {
expect(getTrendSentiment(1, "up-good")).toBe("positive");
expect(getTrendSentiment(-1, "up-good")).toBe("negative");
expect(getTrendSentiment(1, "up-bad")).toBe("negative");
expect(getTrendSentiment(0.01, "up-bad")).toBe("neutral");
expect(getTrendSentiment(1, "neutral")).toBe("neutral");
expect(getTrendSentiment(null, "up-good")).toBe("neutral");
});
it("a hold direction with no signal at all stays neutral rather than claiming progress", () => {
expect(getTrendSentiment(null, "hold")).toBe("neutral");
});
});
describe("buildWeightTargetFeature", () => {
it("names the progress direction for each side of the band", () => {
expect(
buildWeightTargetFeature(TARGET, { avg7: 66, latest: 66 })?.progress,
).toBe("gaining");
expect(
buildWeightTargetFeature(TARGET, { avg7: 72, latest: 72 })?.progress,
).toBe("holding steady");
expect(
buildWeightTargetFeature(TARGET, { avg7: 80, latest: 80 })?.progress,
).toBe("losing");
});
it("claims nothing without a stored target or a reading", () => {
expect(buildWeightTargetFeature(null, { avg7: 80, latest: 80 })).toBeNull();
expect(
buildWeightTargetFeature(TARGET, { avg7: null, latest: null }),
).toBeNull();
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* Which way a weight change counts as progress, judged against the person's
* own stored target rather than against gravity.
*
* The trend arrow, the 7-day delta and the comparison caption on the weight
* tile used to be hard-wired `up-bad`: a falling number read green whatever
* the person was aiming for. Someone below their target who was gaining, which
* is exactly what they set out to do, saw every step coloured as a setback.
*
* The rule here is the whole answer, with no setting of its own:
*
* - below the target band → gaining is progress (`up-good`)
* - above the target band → losing is progress (`up-bad`)
* - inside the target band → holding steady is (`hold`)
* - no stored target → the historical reading (`up-bad`)
*
* The target is the one the person entered on `/targets`, stored as
* `User.thresholdsJson.WEIGHT` and narrowed by `resolveWeightTargetOverride`.
* The height-derived WHO band is deliberately NOT a target here: nobody chose
* it, and a band nobody chose is not a statement about which way they want to
* move.
*
* Resolved on the server and published on the dashboard snapshot
* (`tiles.weightTrend`) so the web tile and the native client colour the same
* reading the same way. The web page's snapshot-disabled fallback calls this
* same function; nothing else re-derives it.
*
* Pure and client-safe: no DB read, no clock.
*/
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
/** Where a weight sits against the person's own target band. */
export type WeightTargetPosition = "below" | "inside" | "above";
export interface WeightTrendJudgement {
/** The sentiment direction the tile's arrow, delta and caption colour by. */
direction: Extract<TrendDirectionSentiment, "up-good" | "up-bad" | "hold">;
/**
* Where the reference weight sits against the stored target. Null when no
* target is stored or there is no reading yet — the two cases where the
* historical `up-bad` reading applies.
*/
targetPosition: WeightTargetPosition | null;
}
/**
* Place a weight against the target band. The band edges count as inside: a
* person sitting exactly on the number they typed has arrived.
*/
export function weightTargetPosition(
target: { min: number; max: number } | null,
weightKg: number | null,
): WeightTargetPosition | null {
if (!target || weightKg === null || !Number.isFinite(weightKg)) return null;
if (weightKg < target.min) return "below";
if (weightKg > target.max) return "above";
return "inside";
}
/**
* The weight the position is read from: the 7-day average when there is one,
* else the latest reading. A single morning reading near a band edge would
* otherwise flip the arrow colour back and forth from one day to the next,
* while the average moves at the pace the tile's own trend does.
*/
export function weightTrendReferenceKg(
summary: { avg7: number | null; latest: number | null } | null | undefined,
): number | null {
if (!summary) return null;
return summary.avg7 ?? summary.latest ?? null;
}
export function resolveWeightTrend(
target: { min: number; max: number } | null,
referenceKg: number | null,
): WeightTrendJudgement {
const targetPosition = weightTargetPosition(target, referenceKg);
switch (targetPosition) {
case "below":
return { direction: "up-good", targetPosition };
case "above":
return { direction: "up-bad", targetPosition };
case "inside":
return { direction: "hold", targetPosition };
default:
return { direction: "up-bad", targetPosition: null };
}
}
/**
* The same judgement in the shape a narrative model reads: the band, where
* the person is against it, and a plain sentence saying which way is
* progress. Rides `features.weight.target` (briefing, overview analysis,
* Coach) and the weight status snapshot, so an AI note never calls a gain a
* regression when the person is below the target they set.
*
* Null without a stored target or a reading: then no direction is claimed at
* all, which is the honest default for a model (the tile keeps its historical
* colour, the prose makes no promise about which way is better).
*/
export interface WeightTargetFeature {
minKg: number;
maxKg: number;
position: WeightTargetPosition;
progress: "gaining" | "losing" | "holding steady";
reading: string;
}
const PROGRESS_BY_POSITION: Record<
WeightTargetPosition,
WeightTargetFeature["progress"]
> = {
below: "gaining",
above: "losing",
inside: "holding steady",
};
export function buildWeightTargetFeature(
target: { min: number; max: number } | null,
summary: { avg7: number | null; latest: number | null } | null | undefined,
): WeightTargetFeature | null {
const position = weightTargetPosition(
target,
weightTrendReferenceKg(summary),
);
if (!target || !position) return null;
const band = `${target.min}–${target.max} kg`;
const reading =
position === "below"
? `The person set their own weight target at ${band} and is currently below it, so gaining weight is progress and losing weight moves away from the goal. Judge weight changes against this target; never describe a gain as a setback or a regression.`
: position === "above"
? `The person set their own weight target at ${band} and is currently above it, so losing weight is progress and gaining moves away from the goal.`
: `The person set their own weight target at ${band} and is currently inside it, so holding steady is progress; a small move either way within the band is not a setback.`;
return {
minKg: target.min,
maxKg: target.max,
position,
progress: PROGRESS_BY_POSITION[position],
reading,
};
}
@@ -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"],
]);
});
});
+8
View File
@@ -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;
}
+92
View File
@@ -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));
}
+46 -1
View File
@@ -366,6 +366,24 @@ export interface DocumentEncounterLinkDto {
occurredAt: string | null;
}
/**
* One vaccination link on a document detail DTO — "this page records that
* dose".
*
* `catalogSlug` is the catalogue entry when this release still knows the
* dose's slug, so the reader's own bundle names it
* (`vaccinations.catalog.<slug>`); `null` is the signal to show
* `vaccineName`, the person's own wording. The same degrade the vaccination
* DTO's `catalogEntry: null` carries.
*/
export interface DocumentVaccinationLinkDto {
vaccinationId: string;
/** ISO-8601 instant of the dose (UTC midnight — a Pass carries dates). */
occurredAt: string | null;
catalogSlug: string | null;
vaccineName: string | null;
}
/** The document DTO (list + detail). */
export interface InboundDocumentDto {
id: string;
@@ -429,6 +447,13 @@ export interface InboundDocumentDto {
export interface InboundDocumentDetailDto extends InboundDocumentDto {
facts: ExtractedFactDto[];
/**
* Doses this document is filed against, newest link last. `null` when the
* caller acts inside somebody else's record under a grant that does not
* cover the health background the doses live in: the page is shared, the
* doses are not, and an empty list would claim the page records none.
*/
vaccinationLinks: DocumentVaccinationLinkDto[] | null;
/**
* Short (3-4 sentence) plain-language summary of WHAT the document is,
* generated once in the background after upload when the `documentsAutoAiRead`
@@ -619,6 +644,20 @@ const encounterIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_ENCOUNTER_LINKS);
/**
* Max vaccination links a single document may carry / receive per request.
*
* Higher than the visit and condition bounds on purpose. One childhood
* record routinely covers every dose of a whole primary schedule, which is
* well past twenty rows, and it is the normal case rather than an edge one.
* The link service's own per-call ceiling is the bound.
*/
export const DOCUMENT_MAX_VACCINATION_LINKS = 100;
const vaccinationIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_VACCINATION_LINKS);
/**
* The store-only upload metadata (the multipart form fields beside the file).
* Every field is optional — a bare file upload is valid and lands as a STORED
@@ -665,6 +704,11 @@ export const documentUpdateSchema = z
episodeIds: episodeIdList.optional(),
/** Replace-set visit links, same semantics as `episodeIds`. */
encounterIds: encounterIdList.optional(),
/**
* Replace-set vaccination links, same semantics. The same table the
* dose's own form writes from the other end.
*/
vaccinationIds: vaccinationIdList.optional(),
})
.refine(
(d) =>
@@ -672,7 +716,8 @@ export const documentUpdateSchema = z
d.kind !== undefined ||
d.documentDate !== undefined ||
d.episodeIds !== undefined ||
d.encounterIds !== undefined,
d.encounterIds !== undefined ||
d.vaccinationIds !== undefined,
{ message: "Provide at least one field to update" },
);
+88
View File
@@ -645,3 +645,91 @@ describe("link service — the vaccination pair", () => {
).rejects.toThrow(/No link table joins a vaccination to a labResult/);
});
});
describe("link service — a document filed against many doses (#1024)", () => {
async function seedDoses(userId: string, count: number) {
const prisma = getPrismaClient();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const dose = await prisma.vaccinationRecord.create({
data: {
userId,
occurredAt: new Date(Date.UTC(1991, i % 12, 1 + i)),
antigenSlug: i % 2 === 0 ? "dtap" : "polio",
},
});
ids.push(dose.id);
}
return ids;
}
it("files one page against a whole childhood schedule from the document side", async () => {
const prisma = getPrismaClient();
const doses = await seedDoses(OWNER, 12);
const result = await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: doses,
});
expect(result.changed).toBe(12);
// One table, read from either end: every dose now lists the page.
for (const dose of doses) {
const pages = await listTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: dose,
targetKind: "document",
});
expect(pages.map((page) => page.id)).toEqual([owner.document.id]);
}
// And the rows landed in the right columns.
const row = await prisma.vaccinationDocumentLink.findFirstOrThrow({
where: { vaccinationId: doses[0] },
});
expect(row.documentId).toBe(owner.document.id);
expect(row.userId).toBe(OWNER);
});
it("refuses another account's dose from the document side, and writes nothing", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: [stranger.vaccination.id],
});
expect(result.unknownTargetIds).toEqual([stranger.vaccination.id]);
expect(await prisma.vaccinationDocumentLink.count()).toBe(0);
});
it("unlinking from the document side leaves the other doses filed", async () => {
const prisma = getPrismaClient();
const doses = await seedDoses(OWNER, 3);
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: doses,
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
targetIds: [doses[0]!, doses[2]!],
});
const linked = await listTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "vaccination",
});
expect(linked.map((dose) => dose.id).sort()).toEqual(
[doses[0]!, doses[2]!].sort(),
);
});
});
@@ -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);
});
});