Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:17:40 +02:00
commit 4c2dfa57b4
138 files changed
+12046 -137

No files matched your search

@@ -1,8 +1,11 @@
/**
* Copy-matches-behaviour guard.
*
* Three classes of copy that drifted away from the code they describe, each
* pinned here so the pair fails loudly if either half moves again:
* Four classes of copy that must not drift away from the code they describe,
* each pinned here so the pair fails loudly if either half moves. The first
* three are repairs — copy that had already drifted. The fourth arrives with
* the feature it governs, because a wording rule that depends on review is a
* wording rule that survives until the fifth locale.
*
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
* consequence. Four dialogs promised permanence on surfaces that render
@@ -224,3 +227,150 @@ describe("the glucose reference band control is translated everywhere", () => {
expect(copy).toContain("never inferred");
});
});
/**
* 4. THE MOOD PROGNOSIS. The forecast is a comparison with somebody's own past
* days and it may never be phrased as a cause. "A value around 5.4 would
* have been expected" is what the arithmetic supports; "overtime lowered
* your mood" is not, and the difference is not a matter of taste — the model
* is a correlation over a dozen columns and a causal sentence would be a
* claim it cannot make about somebody's health.
*
* The second half is the count. A forecast built from thirty days and one
* built from three hundred render identically, so every statement that
* quotes a value has to quote what it rests on. Both halves are a test here
* rather than a review habit, because a review habit does not survive the
* fifth locale.
*/
describe("the mood prognosis never states a cause", () => {
/**
* Causal constructions, per locale. Multi-word where a single word would
* over-match — Italian `causa` is a noun in ordinary use and only `a causa
* di` is the construction being banned.
*/
const CAUSAL_PHRASES: Record<string, readonly string[]> = {
de: [
"weil",
"verursacht",
"führt zu",
"sorgt für",
"wegen",
"verbessert deine",
"verschlechtert deine",
"senkt deine",
"hebt deine",
],
en: [
"because",
"causes",
"caused",
"leads to",
"due to",
"makes you",
"lowers your",
"raises your",
"improves your",
"worsens your",
],
es: [
"porque",
"provoca",
"debido a",
"a causa de",
"mejora tu",
"empeora tu",
],
fr: [
"parce que",
"provoque",
"en raison de",
"à cause de",
"améliore ton",
"dégrade ton",
],
it: [
"perché",
"provoca",
"a causa di",
"migliora il tuo",
"peggiora il tuo",
],
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
};
/** Every leaf string under `insights.mood.prognosis`, flattened. */
function prognosisStrings(locale: string): Array<[string, string]> {
const root = (
bundle(locale).insights as Record<string, Record<string, unknown>>
).mood.prognosis as Record<string, unknown>;
expect(
root,
`${locale} carries no insights.mood.prognosis block`,
).toBeTypeOf("object");
return Object.entries(root).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
);
}
for (const locale of LOCALES) {
it(`${locale} carries the block, and every string in it`, () => {
const strings = prognosisStrings(locale);
// The positive control. Every assertion below passes on an empty list,
// so the count is the evidence and the empty offender set is only the
// verdict.
expect(strings.length).toBeGreaterThanOrEqual(17);
for (const [key, value] of strings) {
expect(value.length, `${locale}/${key} is empty`).toBeGreaterThan(0);
}
});
it(`${locale} names no cause`, () => {
const offenders = prognosisStrings(locale)
.filter(([, value]) =>
CAUSAL_PHRASES[locale].some((phrase) =>
value.toLowerCase().includes(phrase),
),
)
.map(([key]) => key);
expect(
offenders,
`${locale} prognosis copy states a cause; the forecast is a comparison with the account's own past days and cannot support one`,
).toEqual([]);
});
}
/**
* Statements that quote a value must quote what it rests on. The list is
* explicit rather than derived, so adding a statement key forces a decision
* here instead of inheriting silence.
*/
const COUNTED_STATEMENTS: Record<string, readonly string[]> = {
statement: ["{value}", "{n}"],
learning: ["{entries}", "{threshold}"],
noPattern: ["{entries}"],
band: ["{low}", "{high}"],
};
for (const locale of LOCALES) {
it(`${locale} keeps the count beside the claim`, () => {
const strings = new Map(prognosisStrings(locale));
for (const [key, placeholders] of Object.entries(COUNTED_STATEMENTS)) {
const copy = strings.get(key);
expect(copy, `${locale}/${key} is missing`).toBeTypeOf("string");
for (const placeholder of placeholders) {
expect(
copy,
`${locale}/${key} quotes a value without ${placeholder}; a forecast without the days behind it cannot be weighed by the reader`,
).toContain(placeholder);
}
}
});
}
it("the forecast is phrased as a counterfactual, not as a measurement (EN + DE)", () => {
const en = resolve(bundle("en"), "insights.mood.prognosis.statement");
expect(en.toLowerCase()).toContain("would have been expected");
const de = resolve(bundle("de"), "insights.mood.prognosis.statement");
expect(de.toLowerCase()).toContain("zu erwarten gewesen");
});
});
@@ -381,6 +381,26 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["AlertDialog"],
},
// ── Visits and the address book ─────────────────────────────────────────
{
file: "hooks/use-encounters.ts",
destroys:
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
triggers: ["components/encounters/encounter-sheet.tsx"],
// The route soft-deletes and a restore route exists, but nothing in the
// product reaches it yet, so from a person's side the visit is gone. This
// becomes "restorable" the day a surface offers the undo, not before.
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "hooks/use-practitioners.ts",
destroys: "one address-book entry",
triggers: ["components/practitioners/practitioner-list.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
// ── Cycle ───────────────────────────────────────────────────────────────
{
file: "components/cycle/use-cycle.ts",
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(91);
expect(ALL_LEAF_IDS.length).toBe(92);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
});
it("places every leaf in exactly one group", () => {
@@ -0,0 +1,158 @@
/**
* `MoodPrediction` has exactly one writer, and it is not a request.
*
* The product rule underneath this guard: a day carries two readings — the
* one the person gave it and the one their own past days imply — and they are
* never merged. The self-assessment leads and is stored exactly as it was
* given; the prognosis is computed, carries a band and a case count, and the
* user cannot overwrite it. A route that wrote `moodPrediction` would make the
* second value editable, and an editable prognosis is not a prognosis: the
* deviation between the two, which is the only thing either number is for,
* would quietly become a number somebody could tune to zero.
*
* The second failure the single writer prevents is the one the cycle module
* already paid for: a forecast written as a side effect of a page view makes
* the output a function of browsing rather than of data
* (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`).
*
* **Every matcher here asserts a non-zero match count.** Legs (a) and (b)
* expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look
* identical from the outside — which is precisely how the bearer-scope guard
* stayed green for months while matching nothing at all. Each leg therefore
* pins a separate positive: the number of files SCANNED, and the write calls
* the legitimate writer really does make. `describe("the matcher sees what it
* looks for")` proves the regex against synthetic sources, including the
* broken-across-lines form that defeated an earlier guard in this repository.
*
* **What this guard structurally cannot see, stated like the session-surface
* guard states its own limit.** The matcher keys on a literal `moodPrediction`
* delegate access. The "Delete All Data" wipe DOES delete this table — the
* model is in `WIPE_MODELS` (`src/lib/data-wipe/wipe-plan.ts`) — but it does so
* through a delegate resolved dynamically from the model name (`client[key]
* .deleteMany`), so no `moodPrediction.deleteMany(` literal exists for the
* matcher to catch, and that deletion is correctly NOT counted as a writer.
* The wipe path's own completeness is proven elsewhere, by
* `data-wipe-completeness.test.ts` enumerating the schema; this guard is only
* the one about request-time writers, and it cannot be the gate on a dynamic
* delegate. A future writer that reached the table through a computed property
* would slip this matcher for the same reason.
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, sep } from "node:path";
import { describe, expect, it } from "vitest";
const SRC = join(process.cwd(), "src");
/**
* A write against the `moodPrediction` delegate, on any receiver.
*
* Whitespace-tolerant in every joint: Prettier breaks a long call across
* lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line
* is a matcher that stops matching the day the line gets long enough. The
* receiver is left open (`prisma`, `tx`, `db`, the worker client) because
* pinning one name would let a rename walk straight past.
*/
const WRITE_CALL =
/\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g;
/** The one file allowed to write, relative to `src/`. */
const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts");
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "generated" || entry.name === "node_modules") continue;
out.push(...sourceFiles(full));
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
function writeCallsIn(source: string): string[] {
return [...source.matchAll(WRITE_CALL)].map((m) => m[1]);
}
const ALL_FILES = sourceFiles(SRC);
const PRODUCTION_FILES = ALL_FILES.filter(
(f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"),
);
describe("MoodPrediction is written by one file", () => {
it("scans a plausible number of source files", () => {
// The positive control for legs (a) and (b) below, both of which pass on
// an empty result. If this collapses, they are asserting nothing.
expect(ALL_FILES.length).toBeGreaterThan(1000);
expect(PRODUCTION_FILES.length).toBeGreaterThan(800);
});
it("(a) no route, page or component writes it", () => {
const offenders = PRODUCTION_FILES.filter(
(f) =>
f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")),
).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0);
expect(
offenders.map((f) => f.slice(SRC.length + 1)),
"a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it",
).toEqual([]);
});
it("(b) the writer set is exactly the refresh job", () => {
const writers = PRODUCTION_FILES.filter(
(f) => writeCallsIn(readFileSync(f, "utf8")).length > 0,
).map((f) => f.slice(SRC.length + 1));
expect(writers.sort()).toEqual([SOLE_WRITER]);
});
it("(c) that writer really does write — the matcher is not matching nothing", () => {
expect(
existsSync(join(SRC, SOLE_WRITER)),
`${SOLE_WRITER} does not exist — the table has a guard and no writer`,
).toBe(true);
const source = readFileSync(join(SRC, SOLE_WRITER), "utf8");
const calls = writeCallsIn(source);
expect(
calls.length,
"the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth",
).toBeGreaterThan(0);
// It replaces rather than accumulates: a pass writes the current window
// and clears whatever fell out of it.
expect(calls).toContain("deleteMany");
});
});
describe("the matcher sees what it looks for", () => {
it("matches a call Prettier broke across lines", () => {
const wrapped = [
"await prisma.moodPrediction",
" .upsert(",
" { where: { userId_date: { userId, date } } },",
" );",
].join("\n");
expect(writeCallsIn(wrapped)).toEqual(["upsert"]);
});
it("matches through an aliased client", () => {
expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([
"deleteMany",
]);
});
it("does not match a read", () => {
expect(
writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"),
).toEqual([]);
});
it("does not match a different model", () => {
expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]);
});
});
@@ -44,12 +44,44 @@ describe("shared-record navigation", () => {
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
const navigation = resolveSharedRecordNavigation(["profile"]);
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
expect(navigation.destinationHrefs).toEqual(["/profile"]);
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([
"/profile",
"/checkups",
]);
expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]);
expect(navigation.allowsPath("/profile")).toBe(true);
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
});
it("presents the checkups page under both domains whose content it shows", () => {
// The page holds two things: the preventive-care list, which is a
// `measurements` read, and the visits section, which is `profile`. A grant
// naming either one must be able to reach it; a grant naming neither must
// not. Before the visits section the `measurements` omission was invisible,
// because a whole-record grant admits every path regardless.
expect(
resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"),
).toBe(true);
expect(
resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"),
).toBe(true);
expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe(
true,
);
expect(
resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"),
).toBe(false);
// Listed twice, offered once: a grant holding both domains must not put
// two identical entries in the navigation.
expect(
resolveSharedRecordNavigation([
"measurements",
"profile",
]).destinationHrefs.filter((href) => href === "/checkups"),
).toEqual(["/checkups"]);
});
it("uses the active record scope instead of the actor's module flags", () => {
const actorModules = {
medications: false,
+14 -2
View File
@@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "record",
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.",
},
"app/api/mood/prognosis/route.ts": {
domain: "record",
why: "The day's two readings — the record's own rating and what its past days imply — read-only. It is NOT a mind read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and the payload names the features that carried weight, so a reader sees that sleep or steps or resting heart rate mattered on a given day. Declared `mind` it would hand a mood-only delegate a derived view of sections the owner declined to share. Admitted on the whole-record grant only, like the linked-day read beside it; a scoped grant is refused rather than served a filtered contribution list, which would falsely read as \"the model weighted only the mood fields\".",
},
"app/api/mood-entries/[id]/route.ts": {
domain: "mind",
why: "One mood entry of the record, fetch-then-guard against the resolved user.",
@@ -935,6 +939,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
},
"app/api/encounters/suggest/route.ts": {
domain: "profile",
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1860,9 +1868,13 @@ const ACTOR_ROUTES: Record<string, string> = {
* managed-record configuration adds one more: 196 → 197. The bounded
* read-only profile summary makes the next explicit admission: 197 → 198.
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
* The mood day's linked figures, read-only, add one: 200 → 201.
* The mood day's linked figures, read-only, add one: 200 -> 201. The visit
* record and its address book bring twelve: 201 -> 213. The mood prognosis,
* read-only and admitted on the whole record for the same reason as the linked
* figures, adds one: 213 -> 214. The visit-suggestion read, which serves the
* three incidental-linking moments from the same rule, adds one: 214 -> 215.
*/
const FROZEN_ENTRY_COUNT = 213;
const FROZEN_ENTRY_COUNT = 215;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({
deleteMany: vi.fn(),
createMany: vi.fn(),
},
encounterDocumentLink: {
findMany: vi.fn(),
deleteMany: vi.fn(),
createMany: vi.fn(),
},
documentContentIndex: {
findUnique: vi.fn(),
},
@@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({
illnessEpisode: {
findMany: vi.fn(),
},
encounter: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
@@ -129,6 +137,10 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
@@ -15,7 +15,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { loadConditionLinks } from "@/lib/documents/links";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
} from "@/lib/documents/links";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -83,9 +86,20 @@ export const POST = apiHandler(
if (row.deletedAt === null) {
// Already live — restoring twice is a no-op, not an error (the undo
// toast may fire after a concurrent restore).
const links = await loadConditionLinks(user.id, [row.id]);
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [row.id]),
loadDocumentEncounterLinks(user.id, [row.id]),
]);
return apiSuccess(
serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []),
serialiseDocumentDetail(
row,
row.facts,
links.get(row.id) ?? [],
false,
null,
false,
visitLinks.get(row.id) ?? [],
),
);
}
@@ -131,7 +145,10 @@ export const POST = apiHandler(
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const links = await loadConditionLinks(user.id, [restored.id]);
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [restored.id]),
loadDocumentEncounterLinks(user.id, [restored.id]),
]);
await auditLog("documents.inbound.restore", {
userId: user.id,
@@ -149,6 +166,10 @@ export const POST = apiHandler(
restored,
restored.facts,
links.get(restored.id) ?? [],
false,
null,
false,
visitLinks.get(restored.id) ?? [],
),
);
},
+33 -2
View File
@@ -20,8 +20,11 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
narrowOwnedEncounterIds,
narrowOwnedEpisodeIds,
replaceConditionLinks,
replaceEncounterLinks,
} from "@/lib/documents/links";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
@@ -92,8 +95,9 @@ export const GET = apiHandler(
});
}
const [links, contentIndex, thumbnail] = await Promise.all([
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 },
@@ -117,6 +121,7 @@ export const GET = apiHandler(
contentIndex !== null,
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
visitLinks.get(document.id) ?? [],
),
);
},
@@ -169,6 +174,21 @@ export const PATCH = apiHandler(
nextEpisodeIds = narrowed;
}
// Replace-set visit links, same semantics and same refusal.
let nextEncounterIds: string[] | undefined;
if (parsed.data.encounterIds !== undefined) {
const narrowed = await narrowOwnedEncounterIds(
user.id,
parsed.data.encounterIds,
);
if (narrowed === null) {
return apiError("Visit not found", 404, {
errorCode: "documents.inbound.encounterNotFound",
});
}
nextEncounterIds = 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 = {};
@@ -189,14 +209,23 @@ export const PATCH = apiHandler(
if (nextEpisodeIds !== undefined) {
await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds);
}
if (nextEncounterIds !== undefined) {
await replaceEncounterLinks(
prisma,
user.id,
existing.id,
nextEncounterIds,
);
}
const document = await prisma.inboundDocument.findFirstOrThrow({
where: { id: existing.id },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const [links, contentIndex, thumbnail] = await Promise.all([
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 },
@@ -210,6 +239,7 @@ export const PATCH = apiHandler(
const touched = [
...Object.keys(data),
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
...(nextEncounterIds !== undefined ? ["encounterIds"] : []),
];
await auditLog("documents.inbound.update", {
@@ -234,6 +264,7 @@ export const PATCH = apiHandler(
contentIndex !== null,
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
visitLinks.get(document.id) ?? [],
),
);
},
@@ -20,6 +20,8 @@ const {
txCreateMany,
txDocumentFindMany,
txEpisodeFindMany,
txEncounterFindMany,
txEncounterLinkCreateMany,
} = vi.hoisted(() => ({
txQueryRaw: vi.fn(),
txCreate: vi.fn(),
@@ -28,6 +30,8 @@ const {
// writes, so the transaction double has to answer for them.
txDocumentFindMany: vi.fn(),
txEpisodeFindMany: vi.fn(),
txEncounterFindMany: vi.fn(),
txEncounterLinkCreateMany: vi.fn(),
}));
vi.mock("@/lib/db", () => {
@@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => {
$queryRaw: txQueryRaw,
inboundDocument: { create: txCreate, findMany: txDocumentFindMany },
illnessEpisode: { findMany: txEpisodeFindMany },
encounter: { findMany: txEncounterFindMany },
documentConditionLink: { createMany: txCreateMany },
encounterDocumentLink: { createMany: txEncounterLinkCreateMany },
};
return {
prisma: {
@@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => {
documentConditionLink: {
findMany: vi.fn(),
},
encounterDocumentLink: {
findMany: vi.fn(),
},
documentContentIndex: {
findMany: vi.fn(),
},
@@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => {
illnessEpisode: {
findMany: vi.fn(),
},
encounter: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
@@ -183,6 +195,12 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
txEncounterFindMany.mockResolvedValue([]);
txEncounterLinkCreateMany.mockResolvedValue({ count: 0 });
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
+48 -4
View File
@@ -1,9 +1,11 @@
/**
* Document vault: bulk actions over up to 100 owner-scoped documents.
*
* `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions:
* `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition
* links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A
* `{ ids[], action, kind?, episodeId?, encounterId? }` → a per-id result
* array. Actions: `setKind` (recategorise), `linkEpisode` / `unlinkEpisode`
* (condition links), `linkEncounter` / `unlinkEncounter` (visit links, the
* same table the visit's own sheet writes from the other end),
* `delete` (tombstone, undo-able), `restore` (clear tombstone). A
* partial failure never aborts the batch — each id reports `ok` or a short
* machine reason. Every id is narrowed to the caller; a foreign id reads as
* "notFound", exactly like the single-document routes.
@@ -68,7 +70,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { ids, action, kind, episodeId } = parsed.data;
const { ids, action, kind, episodeId, encounterId } = parsed.data;
const uniqueIds = [...new Set(ids)];
// Episode actions: the target episode must be a LIVE episode of the caller.
@@ -84,6 +86,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// Visit actions: the target visit must be a LIVE visit of the caller. Same
// shape as the episode gate above — the endpoint is checked once, before the
// batch, so a foreign id costs one refusal rather than a hundred.
if (action === "linkEncounter" || action === "unlinkEncounter") {
const encounter = await prisma.encounter.findFirst({
where: { id: encounterId!, userId: user.id, deletedAt: null },
select: { id: true },
});
if (!encounter) {
return apiError("Visit not found", 404, {
errorCode: "documents.inbound.encounterNotFound",
});
}
}
// One owner-scoped sweep resolves every id's current state; per-id work
// then never touches a row the caller does not own.
const owned = await prisma.inboundDocument.findMany({
@@ -136,6 +153,33 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
break;
}
case "linkEncounter": {
if (row.deletedAt !== null) {
results.push({ id, ok: false, error: "notFound" });
continue;
}
await linkTargets(prisma, {
userId: user.id,
sourceKind: "document",
sourceId: id,
targetKind: "encounter",
targetIds: [encounterId!],
});
break;
}
case "unlinkEncounter": {
// No tombstone check, matching `unlinkEpisode`: clearing a filing on
// a document the account has since deleted must stay possible, or
// the filing is unreachable.
await unlinkTargets(prisma, {
userId: user.id,
sourceKind: "document",
sourceId: id,
targetKind: "encounter",
targetIds: [encounterId!],
});
break;
}
case "delete": {
if (row.deletedAt !== null) {
// Already tombstoned — deleting twice is a no-op success.
+77 -12
View File
@@ -39,6 +39,8 @@ import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
narrowOwnedEncounterIds,
narrowOwnedEpisodeIds,
} from "@/lib/documents/links";
import {
@@ -116,8 +118,9 @@ async function duplicateResponse(
userId: string,
existing: SerialisableDocument,
): Promise<NextResponse> {
const [links, groups] = await Promise.all([
const [links, visitLinks, groups] = await Promise.all([
loadConditionLinks(userId, [existing.id]),
loadDocumentEncounterLinks(userId, [existing.id]),
prisma.extractedFact.groupBy({
by: ["status"],
where: { userId, documentId: existing.id },
@@ -140,6 +143,10 @@ async function duplicateResponse(
existing,
{ factCount, pendingCount },
links.get(existing.id) ?? [],
false,
null,
false,
visitLinks.get(existing.id) ?? [],
),
error: null,
meta: { duplicate: true },
@@ -212,17 +219,21 @@ async function processUpload(
return apiError("Field 'file' must be a file", 422);
}
// Optional metadata (title / kind / documentDate / episodeIds). The file is
// read separately; these are the form fields beside it. `episodeIds` may be
// repeated.
// Optional metadata (title / kind / documentDate / episodeIds /
// encounterIds). The file is read separately; these are the form fields
// beside it. Both id lists may be repeated.
const rawEpisodeIds = formData
.getAll("episodeIds")
.filter((v): v is string => typeof v === "string");
const rawEncounterIds = formData
.getAll("encounterIds")
.filter((v): v is string => typeof v === "string");
const parsed = documentCreateSchema.safeParse({
title: formData.get("title") ?? undefined,
kind: formData.get("kind") ?? undefined,
documentDate: formData.get("documentDate") ?? undefined,
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
});
if (!parsed.success) {
return apiError("Invalid document metadata", 422, {
@@ -268,6 +279,19 @@ async function processUpload(
});
}
// Same for the visit ids the review step offered. A refusal rather than a
// silent drop: the person saw a visit named and would otherwise watch the
// upload succeed without the link they asked for.
const encounterIds = await narrowOwnedEncounterIds(
user.id,
parsed.data.encounterIds ?? [],
);
if (encounterIds === null) {
return apiError("Visit not found", 404, {
errorCode: "documents.inbound.encounterNotFound",
});
}
// sha256 of the PLAINTEXT for same-user duplicate detection.
const contentSha256 = createHash("sha256").update(buffer).digest("hex");
@@ -346,6 +370,15 @@ async function processUpload(
targetIds: episodeIds,
});
}
if (encounterIds.length > 0) {
await linkTargets(tx, {
userId: user.id,
sourceKind: "document",
sourceId: created.id,
targetKind: "encounter",
targetIds: encounterIds,
});
}
return created;
});
} catch (err) {
@@ -382,6 +415,7 @@ async function processUpload(
byteSize: document.byteSize,
servingClass: detected.servingClass,
linked: episodeIds.length,
linkedVisits: encounterIds.length,
},
});
@@ -406,12 +440,19 @@ async function processUpload(
// because of it. Only fresh inserts reach here (a duplicate returns early).
void enqueueDocumentSummary(user.id, document.id);
const links = await loadConditionLinks(user.id, [document.id]);
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
]);
return apiSuccess(
serialiseDocument(
document,
{ factCount: 0, pendingCount: 0 },
links.get(document.id) ?? [],
false,
null,
false,
visitLinks.get(document.id) ?? [],
),
201,
);
@@ -481,8 +522,19 @@ export const GET = apiHandler(async (request: Request) => {
errorCode: "documents.inbound.invalidQuery",
});
}
const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } =
parsed.data;
const {
q,
kind,
episodeId,
encounterId,
year,
from,
to,
sort,
order,
cursor,
limit,
} = parsed.data;
const where: Prisma.InboundDocumentWhereInput = {
userId: user.id,
@@ -490,6 +542,10 @@ export const GET = apiHandler(async (request: Request) => {
};
if (kind && kind.length > 0) where.kind = { in: kind };
if (episodeId) where.conditionLinks = { some: { episodeId } };
// The visit filter reads the SAME table the visit's own sheet writes, from
// the document side. Filtering rather than post-filtering: a page of fifty
// documents narrowed in Node would page wrongly.
if (encounterId) where.encounterLinks = { some: { encounterId } };
if (year !== undefined) {
where.documentDate = {
gte: new Date(Date.UTC(year, 0, 1)),
@@ -576,10 +632,16 @@ export const GET = apiHandler(async (request: Request) => {
}
// Condition links for the page in ONE grouped query (no N+1).
const linkMap = await loadConditionLinks(
user.id,
page.map((d) => d.id),
);
const [linkMap, visitLinkMap] = await Promise.all([
loadConditionLinks(
user.id,
page.map((d) => d.id),
),
loadDocumentEncounterLinks(
user.id,
page.map((d) => d.id),
),
]);
// Which of the page's documents have a content index (drives the searchable
// status + the provenance the UI reads to tell an AI-read document from a
@@ -610,7 +672,9 @@ export const GET = apiHandler(async (request: Request) => {
count: page.length,
sort,
order,
filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year),
filtered: Boolean(
q || (kind && kind.length > 0) || episodeId || encounterId || year,
),
},
});
@@ -623,6 +687,7 @@ export const GET = apiHandler(async (request: Request) => {
indexSources.has(doc.id),
toContentIndexSource(indexSources.get(doc.id)),
thumbnailIds.has(doc.id),
visitLinkMap.get(doc.id) ?? [],
),
),
nextCursor,
+22 -3
View File
@@ -36,6 +36,7 @@ import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
applyEncounterLinks,
loadEncounterLinksForMany,
closeCheckupForVisit,
resolveClosableReminder,
resolveOwnedPractitioner,
@@ -50,7 +51,7 @@ import {
} from "@/lib/export/restore-skips";
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 = encounterListQuerySchema.safeParse({
@@ -58,6 +59,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
to: params.get("to") ?? undefined,
status: params.get("status") ?? undefined,
practitionerId: params.get("practitionerId") ?? undefined,
episodeId: params.get("episodeId") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
@@ -75,6 +77,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
deletedAt: null,
...(query.status ? { status: query.status } : {}),
...(query.practitionerId ? { practitionerId: query.practitionerId } : {}),
// Filtered in SQL rather than in Node: the two arms are separately
// `take`-bounded, so narrowing after the read would page wrongly.
...(query.episodeId
? { conditionLinks: { some: { episodeId: query.episodeId } } }
: {}),
...(query.from || query.to
? {
occurredAt: {
@@ -118,14 +125,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
}),
]);
// The links ride the list. The card counts what a visit produced, and the
// edit sheet seeds its pickers from the row it was handed — a list without
// them makes the first read zero and the second unfile everything on save.
// Three grouped queries for the page, not three per row.
const visible = await actingDomainVisibility(prisma, grantId);
const links = await loadEncounterLinksForMany(
prisma,
user.id,
[...upcoming, ...past].map((row) => row.id),
visible,
);
annotate({
action: { name: "encounter.visit.list", entity_type: "encounter" },
meta: { upcoming: upcoming.length, past: past.length },
});
const body: EncounterListDTO = {
upcoming: upcoming.map((row) => toEncounterDTO(row)),
past: past.map((row) => toEncounterDTO(row)),
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
};
return apiSuccess(body);
});
+59
View File
@@ -0,0 +1,59 @@
/**
* `GET /api/encounters/suggest?anchor=<iso>` — which visit does a record dated
* `anchor` most plausibly belong to?
*
* The three capture moments that ask this question live in the browser: the
* document upload review, the lab extraction review and the manual lab form.
* The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and
* the one-pre-selects / two-offer-a-picker verdict — lives in
* `src/lib/encounters/suggest-window.ts` and is answered here so all three read
* the same answer. Handing the client a windowed list and letting it decide
* would put the rule in two places, and the copy in the browser is the one
* nobody re-reads when the rule changes.
*
* A read of the record's own visits, so it declares the same domain the visit
* list does. `userId` is narrowed from auth; the anchor is the only input and
* it is a date, not an id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { encounterSuggestQuerySchema } from "@/lib/validations/encounters";
import { suggestEncounterForDate } from "@/lib/encounters/suggest-window";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = encounterSuggestQuerySchema.safeParse({
anchor: params.get("anchor") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const result = await suggestEncounterForDate(prisma, {
userId: user.id,
anchor: new Date(parsed.data.anchor),
});
annotate({
action: { name: "encounter.visit.suggest", entity_type: "encounter" },
meta: {
verdict: result.kind,
candidates:
result.kind === "many"
? result.encounters.length
: result.kind === "one"
? 1
: 0,
},
});
return apiSuccess(result);
});
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
encounter: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
@@ -165,6 +166,7 @@ beforeEach(() => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
+23 -1
View File
@@ -39,6 +39,7 @@ import {
linkOcrLabsToVaultDocument,
type InsertedLabForLink,
} from "@/lib/labs/vault-link";
import { linkTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
import {
@@ -217,6 +218,23 @@ async function commitOcrRows(request: NextRequest) {
).catch(() => {});
}
// The visit the review step offered, if it offered one. ONE LINK PER RESULT
// ROW rather than one for the panel: a marker re-run on a different day
// belongs to its own visit, which is exactly why the join is m:n and not a
// scalar column on the result. Best-effort like the vault cross-link — the
// readings are the authoritative write and must not be lost to a link that
// could not be made. The link service narrows both ends to this record, so
// an id naming nothing writes nothing.
if (parsed.data.encounterId && linkable.length > 0) {
await linkTargets(prisma, {
userId: user.id,
sourceKind: "encounter",
sourceId: parsed.data.encounterId,
targetKind: "labResult",
targetIds: linkable.map((row) => row.labResultId),
}).catch(() => {});
}
await auditLog("labs.ocr.commit", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -225,7 +243,11 @@ async function commitOcrRows(request: NextRequest) {
annotate({
action: { name: "labs.ocr.committed" },
meta: { inserted: inserted.length, skipped: skipped.length },
meta: {
inserted: inserted.length,
skipped: skipped.length,
linkedVisit: parsed.data.encounterId ? 1 : 0,
},
});
// A lab panel just landed — resolve any "annual blood panel" reminders now
+17
View File
@@ -21,6 +21,7 @@ import {
type ResolvedBiomarker,
serialiseLabResult,
} from "@/lib/labs/serialise";
import { linkTargets } from "@/lib/links";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
@@ -190,6 +191,7 @@ async function postLabResult(request: NextRequest) {
takenAt,
note,
source,
encounterId,
} = parsed.data;
// v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
@@ -267,6 +269,21 @@ async function postLabResult(request: NextRequest) {
noteEncrypted: note ? encryptNoteToBytes(note) : null,
},
});
// The by-the-way half: file the reading against the visit the form offered,
// if it offered one. Best-effort by design — the reading is the write that
// matters, and a link that could not be made (a visit deleted between the
// suggestion and the save) must not lose it. The link service narrows both
// ends to this record, so an id naming nothing simply writes nothing.
if (encounterId) {
await linkTargets(prisma, {
userId: user.id,
sourceKind: "encounter",
sourceId: encounterId,
targetKind: "labResult",
targetIds: [created.id],
}).catch(() => {});
}
invalidateUserHealthScore(user.id);
const dto = serialiseLabResult(created, biomarker);
+74
View File
@@ -0,0 +1,74 @@
/**
* `GET /api/mood/prognosis` — the day's two readings, side by side.
*
* The person's own rating leads and is returned exactly as it was stored. The
* forecast rides beside it with its band, the number of days it was built
* from, and how old it is. The two are never merged: the distance between them
* is the whole finding, and a single blended number would destroy it.
*
* **This route declares `record`, not `mind`, and that is the security
* decision on it.** The payload is not only mood: the fit reads the day's
* level-B context AND the figures the sleep, activity and recovery modules
* hold, and the contributions it publishes name those features one by one — a
* reader can see that sleep or steps or resting heart rate carried weight on a
* given day, and roughly how much. Declared `mind` it would be a hole with a
* mood-shaped label on it: a delegate holding only the mind section would
* receive a derived view of two sections the owner had declined to share. The
* convention for a read that crosses sections is `record`
* (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest`
* are the precedents) and a scoped grant never reaches one, so a scoped
* delegate loses the forecast entirely rather than receiving a filtered
* version of it. A filtered version would be worse: a contribution list with
* the linked features quietly removed would tell the reader that the model
* weighted only the mood fields, which is false.
*
* Nothing here writes. The rows come from the nightly job and the user cannot
* overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts`
* holds that, and this file is one of the ones it scans.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
// The parameter is declared although the handler body reads nothing off it,
// and the reason is the exported type rather than the runtime. `apiHandler`
// infers the type of `GET` from this callback, so a zero-argument handler
// would type `GET` as taking no arguments — and Next.js, and the integration
// tests, call it WITH the request. The wide event's method and path, and the
// read-vs-write check `requireRecordAuth` runs, both come from the request the
// wrapper receives from Next.js (not from this parameter), and both fall back
// to `GET` when no request is present — so an absent request keeps a read a
// read rather than escalating it.
export const GET = apiHandler(async (_request: NextRequest) => {
// `record`: see the file header. A scoped grant is refused here by
// construction rather than served a narrowed payload.
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
const reading = await readMoodPrognosis(user.id, new Date(), user.timezone);
annotate({
action: { name: "mood.prognosis.read" },
meta: reading.present
? {
present: true,
day: reading.date,
fit_rows: reading.n,
age_days: reading.ageDays,
current: reading.current,
provisional: reading.provisional,
deviation: reading.deviation,
}
: { present: false, reason: reading.reason, entries: reading.entries },
});
return apiSuccess(reading);
});
+20 -1
View File
@@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: {
userId: user.id,
deletedAt: null,
// Both plaintext columns, because a person looks a practice up by
// whichever of the two they remember: the doctor's name on the referral
// or the practice name on the door. Searching only the name made the
// second attempt return nothing and read as "not in the address book".
...(parsed.data.q
? { name: { contains: parsed.data.q, mode: "insensitive" as const } }
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
],
}
: {}),
},
orderBy: { name: "asc" },
+39 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
@@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
import { queryKeys } from "@/lib/query-keys";
import { VisitsSection } from "@/components/encounters/visits-section";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
/**
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
@@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys";
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
* measurements): a pull invalidates the measurement-reminders read so the
* list + server-computed next-due repaint.
*
* The page now holds two things, split by a segmented control rather than by a
* second nav entry: the preventive-care list, and the visits. They answer the
* same question from two directions — what do I still have to arrange, and what
* did I actually attend — and the page's own sentence already covers both. A
* visit carries no module gate for the same reason this page never had one.
*
* The pull invalidates both reads. Refreshing one and leaving the other stale
* is how a page ends up showing a visit that closed a checkup still listed as
* due.
*/
export default function CheckupsPage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const queryClient = useQueryClient();
const { t } = useTranslations();
const [view, setView] = useState("vorsorge");
const refresh = useCallback(
() =>
queryClient.invalidateQueries({
queryKey: queryKeys.measurementReminders(),
}),
invalidateKeys(queryClient, [
queryKeys.measurementReminders(),
queryKeys.encounters(),
]),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refresh });
@@ -41,7 +57,24 @@ export default function CheckupsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<VorsorgeSection enabled={isAuthenticated} variant="page" />
<Tabs value={view} onValueChange={setView}>
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
{t("checkups.tabVorsorge")}
</TabsTrigger>
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
{t("checkups.tabVisits")}
</TabsTrigger>
</TabsList>
<TabsContent value="vorsorge">
<VorsorgeSection enabled={isAuthenticated} variant="page" />
</TabsContent>
<TabsContent value="visits">
{/* Mounted only on the active tab, so the visits read does not fire
for a person who never opens it. */}
<VisitsSection enabled={isAuthenticated} />
</TabsContent>
</Tabs>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PractitionerList } from "@/components/practitioners/practitioner-list";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
/**
* The address book, one level under the checkups page the visits live on.
*
* Deliberately not in Settings. The Settings IA rule is that a settings surface
* configures behaviour; a list of one's own doctors configures nothing and is
* record content like every other clinical list. Sitting under `/checkups`
* also means it inherits that page's route-family classification, so a
* `profile`-scoped delegate reaches it exactly as they reach the visits.
*/
export default function PractitionersPage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
if (!mounted || isLoading) return <PageAuthGate />;
return (
<div className="space-y-6">
<PractitionerList enabled={isAuthenticated} />
</div>
);
}
+21
View File
@@ -36,6 +36,21 @@ const MoodChart = dynamic(
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* The day's two readings. Deferred like every other block on this route that
* is not the first paint: it reads its own endpoint and renders below the
* calendar, so nothing about it belongs in the eager graph of a page whose
* first paint is a heatmap. This route sits close to its bundle ceiling and
* every addition to it goes through the same treatment.
*/
const MoodPrognosisCard = dynamic(
() =>
import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({
default: mod.MoodPrognosisCard,
})),
{ ssr: false, loading: () => null },
);
interface ComprehensiveMoodData {
moodSummary: { count: number } | null;
}
@@ -137,6 +152,12 @@ export default function InsightsStimmungPage() {
<MetricTargetSummary slug="mood" />
{/* The day's own rating beside what the account's past days imply. It
sits high because the self-assessment is the leading value on this
page, and directly under the line chart because that is where a
reader has just seen the rating it leads with. */}
<MoodPrognosisCard />
{/* The better-days Einschätzung sits directly under the Ziel card,
ahead of the classification tiles and breakdowns. */}
<MoodInsightsSections region="assessment" />
+2
View File
@@ -20,6 +20,7 @@
import Link from "next/link";
import {
Activity,
CalendarCheck,
Award,
CalendarClock,
Footprints,
@@ -55,6 +56,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
ecg_new_recording: HeartPulse,
tension_window: Activity,
same_time_baseline: Footprints,
upcoming_visit: CalendarCheck,
};
/**
@@ -4,22 +4,30 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* The floating bulk bar, pinned via single-pass static renders: a labelled
* toolbar carrying the selected count and the bulk verbs (set type, link
* condition, share, delete, clear). The link-condition menu only renders when
* the account actually has episodes to link — no dead affordance on an
* episode-free account.
* condition, file against a visit, share, delete, clear). Each link menu only
* renders when the account actually has something to link to — no dead
* affordance on an account with no episodes, and none on an account with no
* visits either.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentBulkBar } from "../document-bulk-bar";
function render(episodes: { id: string; label: string }[]) {
function render(
episodes: { id: string; label: string }[],
encounters: { id: string; label: string }[] = [
{ id: "v1", label: "Routine visit · 01/08/2026" },
],
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentBulkBar
selectedCount={3}
episodes={episodes}
encounters={encounters}
busy={false}
onSetKind={() => {}}
onLinkEpisode={() => {}}
onLinkEncounter={() => {}}
onShare={() => {}}
onDelete={() => {}}
onClear={() => {}}
@@ -36,6 +44,7 @@ describe("<DocumentBulkBar>", () => {
expect(html).toContain("3 selected");
expect(html).toContain("Change type");
expect(html).toContain("Link condition");
expect(html).toContain("File against visit");
expect(html).toContain('data-slot="document-bulk-share"');
expect(html).toContain("Share");
expect(html).toContain("Delete");
@@ -47,4 +56,13 @@ describe("<DocumentBulkBar>", () => {
expect(html).not.toContain("Link condition");
expect(html).toContain("Change type");
});
it("omits the file-against-visit verb when the account has no visits", () => {
const html = render([{ id: "ep1", label: "Knee" }], []);
expect(html).not.toContain("File against visit");
expect(html).not.toContain('data-slot="document-bulk-link-visit"');
// The positive control: the neighbouring verb is still there, so the
// assertion above is about the visit menu and not about an empty render.
expect(html).toContain("Link condition");
});
});
@@ -30,6 +30,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
@@ -50,6 +50,7 @@ function doc(id: string, title: string): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep1", name: "Knee" }],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
@@ -38,6 +38,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
+55 -3
View File
@@ -4,8 +4,13 @@
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* bulk verbs — set type, link condition, share (one link for the whole
* selection), delete (undo-able), clear — all driving the page's handlers.
* bulk verbs — set type, link condition, file against a visit, share (one
* link for the whole selection), delete (undo-able), clear — all driving the
* page's handlers.
*
* The two link verbs are the same shape and stay that way: each renders only
* when the account HAS something to link to, so an empty menu is never
* offered.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. Share sits just
@@ -13,7 +18,14 @@
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
* per the standards.
*/
import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react";
import {
CalendarClock,
FolderPlus,
Share2,
Tag,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -32,12 +44,20 @@ export interface BulkEpisodeOption {
label: string;
}
/** One visit the selection can be filed against. Label resolved by the page. */
export interface BulkEncounterOption {
id: string;
label: string;
}
export function DocumentBulkBar({
selectedCount,
episodes,
encounters,
busy,
onSetKind,
onLinkEpisode,
onLinkEncounter,
onShare,
onDelete,
onClear,
@@ -45,10 +65,13 @@ export function DocumentBulkBar({
selectedCount: number;
/** The caller's live illness episodes — the link-condition targets. */
episodes: BulkEpisodeOption[];
/** The caller's recent visits — the file-against-a-visit targets. */
encounters: BulkEncounterOption[];
/** A bulk call is in flight — the verbs disable, Clear stays live. */
busy: boolean;
onSetKind: (kind: InboundDocumentKindValue) => void;
onLinkEpisode: (episodeId: string) => void;
onLinkEncounter: (encounterId: string) => void;
/**
* Share the whole selection as ONE documents-only link. The page caps the
* selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint —
@@ -135,6 +158,35 @@ export function DocumentBulkBar({
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkVisit")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
@@ -61,7 +61,9 @@ 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 {
@@ -77,6 +79,7 @@ import {
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
@@ -96,6 +99,7 @@ type PatchInput = {
kind?: InboundDocumentKindValue;
documentDate?: string | null;
episodeIds?: string[];
encounterIds?: string[];
};
/**
@@ -396,6 +400,17 @@ export function DocumentDetailSheet({
const [mutationError, setMutationError] = useState<string | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
/**
* The visit this SESSION filed the document against, if any.
*
* Kept beside the server state rather than derived from it, because the two
* answer different questions: `doc.encounterLinks` says what is filed, this
* says what the person just did here — which is what an undo can take back
* without touching a filing they made somewhere else.
*/
const [pickedEncounterId, setPickedEncounterId] = useState<string | null>(
null,
);
const [titleDraft, setTitleDraft] = useState("");
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
null,
@@ -499,6 +514,25 @@ export function DocumentDetailSheet({
patch.mutate({ episodeIds: next });
};
/**
* File this document against a visit, or clear that filing again.
*
* A replace-set PATCH like the condition links, so the undo is the same
* write with the id removed rather than a second endpoint. Nothing here can
* fail the document: an unknown id is refused by the route before anything
* is written, and the sheet keeps showing what the server last confirmed.
*/
const setSuggestedEncounter = (encounterId: string | null) => {
if (!doc) return;
const current = doc.encounterLinks.map((l) => l.encounterId);
const next =
encounterId === null
? current.filter((id) => id !== pickedEncounterId)
: [...new Set([...current, encounterId])];
setPickedEncounterId(encounterId);
patch.mutate({ encounterIds: next });
};
// The affordance gate falls back to the capability probe when the sheet is
// rendered without the usage-derived props (deep link before usage loads).
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
@@ -1001,6 +1035,47 @@ export function DocumentDetailSheet({
</div>
</div>
<DocumentEncounterSuggestion
anchor={doc.reportDate ?? doc.documentDate}
alreadyLinked={
doc.encounterLinks.length > 0 && pickedEncounterId === null
}
disabled={!canManage}
value={pickedEncounterId}
onChange={setSuggestedEncounter}
/>
{/* "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}
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
@@ -0,0 +1,43 @@
"use client";
/**
* The document moment of the visit suggestion.
*
* The rule and the rendering both live elsewhere — `suggest-window.ts` on the
* server and `EncounterSuggestionField` in the browser — so this file is only
* the wiring: which date to anchor on, and where the chosen id goes.
*
* The anchor is `reportDate ?? documentDate`. `reportDate` is what the model
* transcribed off the letter itself and is never inferred, so it is the truer
* of the two; `documentDate` is the person's own filing date and is the
* fallback.
*
* It renders nothing once the document already names a visit that this session
* did not choose: an offer to file something already filed is noise.
*/
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
export function DocumentEncounterSuggestion({
anchor,
alreadyLinked,
disabled,
value,
onChange,
}: {
anchor: string | null;
alreadyLinked: boolean;
disabled?: boolean;
value: string | null;
onChange: (encounterId: string | null) => void;
}) {
if (alreadyLinked) return null;
return (
<EncounterSuggestionField
anchor={anchor}
value={value}
onChange={onChange}
disabled={disabled}
slot="document-encounter-suggestion"
/>
);
}
+42 -2
View File
@@ -42,7 +42,10 @@ import { useAuth } from "@/hooks/use-auth";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import {
type CoachCloseIntent,
useCoachLaunch,
@@ -85,6 +88,13 @@ interface ListPage {
}
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
/**
* How many visits the bulk bar offers. A menu is a picker, not a history: past
* the first screenful a person scrolls rather than reads, and filing against a
* visit older than this belongs on the visit's own sheet.
*/
const BULK_VISIT_OPTIONS = 12;
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
@@ -226,6 +236,7 @@ export function closeDocumentSelectionAfterCoachHandoff(
export function DocumentsView() {
const { t } = useTranslations();
const format = useFormatters();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
// about it are none of them delegated verbs. Inside somebody else's record
@@ -476,6 +487,27 @@ export function DocumentsView() {
.sort((a, b) => a.name.localeCompare(b.name));
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
// The visits the bulk bar can file a selection against: the account's own,
// read through the same list the checkups page shows. Labelled here rather
// than in the bar so the label uses the reader's locale for the kind and the
// reader's timezone for the date — both presentation, both this side's job.
const visits = useEncounters(
!inSharedRecord || sections === null || sections.includes("profile"),
);
const bulkEncounterOptions = useMemo(
() =>
[...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])]
.slice(0, BULK_VISIT_OPTIONS)
.map((visit) => ({
id: visit.id,
label: `${
visit.practitioner?.name ??
encounterKindText(t, visit.kind as EncounterKind)
} · ${format.date(visit.occurredAt)}`,
})),
[visits.data, t, format],
);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
@@ -589,7 +621,11 @@ export function DocumentsView() {
const runBulk = useCallback(
(
action: Exclude<DocumentBulkAction, "delete" | "restore">,
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
extra: {
kind?: InboundDocumentKindValue;
episodeId?: string;
encounterId?: string;
},
) => {
const ids = [...selectedIds];
bulk.mutate(
@@ -957,6 +993,10 @@ export function DocumentsView() {
busy={bulk.isPending}
onSetKind={(kind) => runBulk("setKind", { kind })}
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
encounters={bulkEncounterOptions}
onLinkEncounter={(encounterId) =>
runBulk("linkEncounter", { encounterId })
}
onShare={openBulkShare}
onDelete={() => deleteBulk([...selectedIds])}
onClear={clearSelection}
+11 -1
View File
@@ -18,7 +18,7 @@ import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
/**
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
* Parse the vault's URL search params (`?q&kind&episode&encounter&year`) into the
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
* page), `kind` accepts repeats AND comma-separated values, and the result is
* normalised (kinds sorted, empties omitted) so the same view always produces
@@ -42,6 +42,13 @@ export function parseVaultSearchParams(
const episode = params.get("episode")?.trim();
if (episode) filters.episodeId = episode.slice(0, 40);
// `?encounter=` is how a visit's own detail row reaches the vault filtered
// to what that visit produced. No chip in the filter bar backs it: the facet
// arrives by deep link from the visit and clears with the clear-all pill,
// which is exactly what a one-way link needs and no more.
const encounter = params.get("encounter")?.trim();
if (encounter) filters.encounterId = encounter.slice(0, 40);
const yearRaw = params.get("year");
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
const year = Number(yearRaw);
@@ -64,6 +71,7 @@ export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
sp.set("kind", [...filters.kinds].sort().join(","));
}
if (filters.episodeId) sp.set("episode", filters.episodeId);
if (filters.encounterId) sp.set("encounter", filters.encounterId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
return sp.toString();
}
@@ -74,6 +82,7 @@ export function countActiveFilters(filters: DocumentVaultFilters): number {
if (filters.q) count += 1;
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
if (filters.episodeId) count += 1;
if (filters.encounterId) count += 1;
if (filters.year !== undefined) count += 1;
return count;
}
@@ -95,6 +104,7 @@ export function buildVaultListApiSearch(
sp.set("kind", filters.kinds.join(","));
}
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
if (filters.encounterId) sp.set("encounterId", filters.encounterId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
sp.set("sort", "documentDate");
sp.set("order", "desc");
@@ -0,0 +1,218 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The one product constraint this whole feature exists to hold: **a visit
* saves with a date and nothing else.**
*
* Three properties, each of which has an obvious way to get broken by a later
* "improvement":
*
* - no field but the date can block Save;
* - the status is DERIVED from the date on a new visit, never asked;
* - a link picker whose module is off is BLANK, not empty — an empty picker
* for a switched-off module advertises a feature that is not there.
*/
let modules: Record<string, boolean> = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import {
EncounterForm,
draftFromEncounter,
derivedStatus,
draftInstant,
draftToBody,
emptyDraft,
} from "../encounter-form";
/**
* The form body, rendered directly rather than through its sheet: the sheet is
* a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a
* test that went through it would assert against an empty string and pass for
* the wrong reason.
*/
function renderForm(over?: {
modules?: Record<string, boolean>;
isEdit?: boolean;
}) {
modules = over?.modules ?? {};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EncounterForm
draft={emptyDraft({ occurredAt: "2026-08-08T10:00" })}
onChange={() => {}}
isEdit={over?.isEdit ?? false}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
describe("nothing but the date is required", () => {
it("admits a draft carrying only a date", () => {
// `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft
// with no practitioner, no reason, no outcome and no link is saveable.
const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" });
expect(draft.practitioner).toBeNull();
expect(draft.reason).toBe("");
expect(draft.documentIds).toEqual([]);
expect(draftInstant(draft.occurredAt)).not.toBeNull();
});
it("refuses only when the date itself cannot be read", () => {
expect(draftInstant("not a date")).toBeNull();
});
it("marks the date field required and nothing else", () => {
const html = renderForm();
const required = html.match(/aria-required="true"/g) ?? [];
expect(required).toHaveLength(1);
expect(html).toContain('data-testid="encounter-occurred-at"');
});
it("turns a date-only draft into a complete write body", () => {
const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), {
isEdit: false,
});
expect(body).not.toBeNull();
expect(body?.practitionerId).toBeNull();
expect(body?.reason).toBeNull();
expect(body?.outcome).toBeNull();
expect(body?.documentIds).toEqual([]);
});
it("refuses to build a body from a date that does not parse", () => {
expect(
draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }),
).toBeNull();
});
});
describe("editing never unfiles what the visit already collected", () => {
it("seeds the link arrays from the row's own links", () => {
// The defect this pins: an edit seeded from a row that did not know its
// links would save a replace-set of three EMPTY arrays and silently
// unfile everything. The list carries the links so this cannot happen.
const draft = draftFromEncounter({
id: "e1",
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "ROUTINE",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
links: {
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
labResults: [{ id: "l1", label: "LDL", date: null, redacted: false }],
conditions: [{ id: "c1", label: "Knee", date: null, redacted: false }],
},
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
});
expect(draft.documentIds).toEqual(["d1"]);
expect(draft.labResultIds).toEqual(["l1"]);
expect(draft.episodeIds).toEqual(["c1"]);
const body = draftToBody(draft, { isEdit: true });
expect(body?.documentIds).toEqual(["d1"]);
expect(body?.labResultIds).toEqual(["l1"]);
expect(body?.episodeIds).toEqual(["c1"]);
});
});
describe("the status follows the date on a new visit", () => {
const now = Date.parse("2026-08-08T12:00:00.000Z");
it("reads a future instant as a booked appointment", () => {
expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED");
});
it("reads a past instant as a visit that happened", () => {
expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE");
});
it("never asks for the status on a new visit", () => {
// The picker is what "asked" would look like; its absence is the property.
expect(renderForm()).not.toContain('id="encounter-status"');
});
it("carries the derived status into the body rather than the draft default", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }),
{ isEdit: false },
);
expect(body?.status).toBe("PLANNED");
});
it("uses the draft's own status on an edit, where it is editable", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }),
{ isEdit: true },
);
expect(body?.status).toBe("CANCELLED");
});
});
describe("the link pickers follow the modules that own their targets", () => {
it("renders no link block at all when all three modules are off", () => {
const html = renderForm({ modules: {} });
expect(html).not.toContain('data-slot="encounter-link-pickers"');
expect(html).not.toContain('data-slot="encounter-link-documents"');
expect(html).not.toContain('data-slot="encounter-link-labs"');
expect(html).not.toContain('data-slot="encounter-link-conditions"');
});
it("blanks the block of a disabled module while keeping its neighbours", () => {
const html = renderForm({
modules: { inboundDocuments: true, labs: false, illness: false },
});
expect(html).toContain('data-slot="encounter-link-documents"');
// Not "renders an empty list" — absent.
expect(html).not.toContain('data-slot="encounter-link-labs"');
expect(html).not.toContain('data-slot="encounter-link-conditions"');
});
it("offers all three when all three modules are on", () => {
const html = renderForm({
modules: { inboundDocuments: true, labs: true, illness: true },
});
expect(html).toContain('data-slot="encounter-link-documents"');
expect(html).toContain('data-slot="encounter-link-labs"');
expect(html).toContain('data-slot="encounter-link-conditions"');
// The positive control: without it the three assertions above would pass
// against a component that never renders any block.
expect(html).toContain('data-slot="encounter-link-pickers"');
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The visits surface's designed states, pinned by single-pass static renders.
*
* - an account with no visits paints the empty state and one action, not an
* error and not a teaching wall;
* - upcoming sorts above past, in the two lists the SERVER split — the
* client renders the order it was given rather than re-deriving one;
* - a soft-deleted visit never arrives, so it cannot be rendered. That is a
* server property and the API test owns it; what this file pins is that
* the section renders exactly what the response carried, with nothing
* filtered or re-sorted on this side that could disagree with it.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: {} },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
// Never resolves inside a synchronous render, so an unseeded query stays
// pending and cannot accidentally satisfy an assertion.
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { VisitsSection } from "../visits-section";
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "ROUTINE",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
...over,
};
}
function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(
queryKeys.encounterList(null, null, undefined, undefined),
list,
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<VisitsSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<VisitsSection>", () => {
it("paints the empty state, with one action, when there is no visit", () => {
const html = render({ upcoming: [], past: [] });
expect(html).toContain("No visit recorded yet");
expect(html).toContain("A date is enough");
expect(html).toContain('data-slot="visits-add"');
// An empty account is not an error, and the two states never share copy.
expect(html).not.toContain("Visits could not be loaded.");
expect(html).not.toContain('data-slot="visits-upcoming"');
});
it("puts the upcoming list above the past one", () => {
const html = render({
upcoming: [visit({ id: "later", status: "PLANNED" })],
past: [visit({ id: "earlier" })],
});
const upcomingAt = html.indexOf('data-slot="visits-upcoming"');
const pastAt = html.indexOf('data-slot="visits-past"');
expect(upcomingAt).toBeGreaterThan(-1);
expect(pastAt).toBeGreaterThan(-1);
expect(upcomingAt).toBeLessThan(pastAt);
expect(html.indexOf('data-encounter-id="later"')).toBeLessThan(
html.indexOf('data-encounter-id="earlier"'),
);
});
it("renders the server's order inside a list rather than re-sorting it", () => {
// Two rows deliberately handed over in an order the client has no reason
// to like. Re-sorting here would be the second place the ordering lives.
const html = render({
upcoming: [],
past: [
visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }),
visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }),
],
});
expect(html.indexOf('data-encounter-id="second"')).toBeLessThan(
html.indexOf('data-encounter-id="first"'),
);
});
it("counts what a visit produced, from the links the LIST carried", () => {
// The counts come off `links`, and the list used to omit them — every card
// then read zero for a visit with three documents. The fixture carries a
// populated `links` because the response now does.
const html = render({
upcoming: [],
past: [
visit({
id: "linked",
links: {
documents: [
{ id: "d1", label: "Letter", date: null, redacted: false },
{ id: "d2", label: "Scan", date: null, redacted: false },
],
labResults: [
{ id: "l1", label: "LDL", date: null, redacted: false },
],
conditions: [],
},
}),
],
});
expect(html).toContain("2 linked documents");
expect(html).toContain("1 linked lab results");
// The empty family renders no counter rather than a zero.
expect(html).not.toContain("0 linked conditions");
});
it("offers the address book beside the add action", () => {
const html = render({ upcoming: [], past: [] });
expect(html).toContain('href="/checkups/practitioners"');
});
});
@@ -0,0 +1,283 @@
"use client";
/**
* The visit form.
*
* **A visit saves with a date and nothing else.** No required practitioner, no
* required kind, no required link, and no Save button disabled until something
* is picked. That is the whole product constraint this feature exists to hold:
* a required field on a form somebody fills after a doctor's appointment is the
* bureaucracy it was built to avoid. The only thing that can block Save is a
* missing or unparseable date, because a visit with no instant is not a record
* of anything.
*
* Status is DERIVED from the date on a new visit, never asked: a future instant
* means PLANNED and a past one means DONE. A person booking an appointment
* should not have to also tell the app that it is in the future. On an existing
* visit the status becomes editable, because moving a planned visit to done,
* cancelled or a no-show is the transition that closes a checkup.
*
* The three link pickers are gated by the modules that own what they point at,
* and the gate BLANKS the block rather than post-filtering it — an empty
* picker for a module the account turned off advertises a feature that is not
* there.
*/
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
import type { Practitioner } from "@/hooks/use-practitioners";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
encounterKindText,
encounterStatusText,
} from "./encounter-labels";
import { PractitionerCombobox } from "./practitioner-combobox";
import { EncounterLinkPickers } from "./encounter-link-pickers";
export interface EncounterDraft {
/** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */
occurredAt: string;
status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
kind: string;
practitioner: Practitioner | null;
reason: string;
outcome: string;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
}
function localValue(instant: Date): string {
const offset = instant.getTimezoneOffset();
return new Date(instant.getTime() - offset * 60_000)
.toISOString()
.slice(0, 16);
}
export function nowLocalValue(): string {
return localValue(new Date());
}
/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */
export function draftInstant(value: string): string | null {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
export function emptyDraft(
overrides?: Partial<EncounterDraft>,
): EncounterDraft {
return {
occurredAt: nowLocalValue(),
status: "DONE",
kind: "OTHER",
practitioner: null,
reason: "",
outcome: "",
documentIds: [],
labResultIds: [],
episodeIds: [],
...overrides,
};
}
export function draftFromEncounter(row: Encounter): EncounterDraft {
return {
occurredAt: localValue(new Date(row.occurredAt)),
status: row.status,
kind: row.kind,
practitioner: row.practitioner,
reason: row.reason ?? "",
outcome: row.outcome ?? "",
documentIds: row.links?.documents.map((link) => link.id) ?? [],
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
};
}
/**
* The status a NEW visit carries, read off its own date.
*
* Exported so the sheet and its tests agree on the derivation rather than each
* spelling out the comparison.
*/
export function derivedStatus(
occurredAt: string,
now = Date.now(),
): "PLANNED" | "DONE" {
const parsed = new Date(occurredAt).getTime();
return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE";
}
/** The body a draft becomes, with the empty strings turned into absences. */
export function draftToBody(
draft: EncounterDraft,
options: { isEdit: boolean; reminderId?: string | null },
): EncounterWriteBody | null {
const occurredAt = draftInstant(draft.occurredAt);
if (!occurredAt) return null;
return {
occurredAt,
status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt),
kind: draft.kind,
practitionerId: draft.practitioner?.id ?? null,
reason: draft.reason.trim() || null,
outcome: draft.outcome.trim() || null,
...(options.reminderId ? { reminderId: options.reminderId } : {}),
documentIds: draft.documentIds,
labResultIds: draft.labResultIds,
episodeIds: draft.episodeIds,
};
}
export function EncounterForm({
draft,
onChange,
isEdit,
}: {
draft: EncounterDraft;
onChange: (next: EncounterDraft) => void;
isEdit: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const patch = (part: Partial<EncounterDraft>) =>
onChange({ ...draft, ...part });
const planned = useMemo(
() =>
isEdit
? draft.status === "PLANNED"
: derivedStatus(draft.occurredAt) === "PLANNED",
[isEdit, draft.status, draft.occurredAt],
);
// The outcome field starts collapsed on a planned visit: there is nothing to
// write about a thing that has not happened. Derived rather than
// synchronised — a visit that already carries an outcome is never collapsed
// over it, and expressing that as an effect would be a second source of
// truth for something the draft already knows.
const [revealed, setRevealed] = useState(false);
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
return (
<div className="space-y-4" data-slot="encounter-form">
<FieldGroup
htmlFor="encounter-occurred-at"
label={t("encounters.form.date")}
hint={isEdit ? undefined : t("encounters.form.dateDerivesStatusHint")}
>
<DateTimeField
id="encounter-occurred-at"
value={draft.occurredAt}
onChange={(value) => patch({ occurredAt: value })}
required
aria-required="true"
data-testid="encounter-occurred-at"
/>
</FieldGroup>
{isEdit ? (
<FieldGroup
htmlFor="encounter-status"
label={t("encounters.form.status")}
>
<NativeSelect
id="encounter-status"
value={draft.status}
onChange={(event) =>
patch({
status: event.target.value as EncounterDraft["status"],
})
}
>
{ENCOUNTER_STATUSES.map((status) => (
<option key={status} value={status}>
{encounterStatusText(t, status)}
</option>
))}
</NativeSelect>
</FieldGroup>
) : null}
<FieldGroup
htmlFor="encounter-practitioner"
label={t("encounters.form.practitioner")}
hint={t("encounters.form.practitionerHint")}
>
<PractitionerCombobox
value={draft.practitioner?.id ?? null}
selected={draft.practitioner}
onChange={(next) => patch({ practitioner: next })}
/>
</FieldGroup>
<FieldGroup htmlFor="encounter-kind" label={t("encounters.form.kind")}>
<NativeSelect
id="encounter-kind"
value={draft.kind}
onChange={(event) => patch({ kind: event.target.value })}
>
{ENCOUNTER_KINDS.map((kind) => (
<option key={kind} value={kind}>
{encounterKindText(t, kind)}
</option>
))}
</NativeSelect>
</FieldGroup>
<FieldGroup
htmlFor="encounter-reason"
label={t("encounters.form.reason")}
>
<Textarea
id="encounter-reason"
rows={2}
value={draft.reason}
onChange={(event) => patch({ reason: event.target.value })}
/>
</FieldGroup>
{outcomeOpen ? (
<FieldGroup
htmlFor="encounter-outcome"
label={t("encounters.form.outcome")}
>
<Textarea
id="encounter-outcome"
rows={3}
value={draft.outcome}
onChange={(event) => patch({ outcome: event.target.value })}
/>
</FieldGroup>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
data-slot="encounter-outcome-reveal"
onClick={() => setRevealed(true)}
>
{t("encounters.form.outcomeReveal")}
</Button>
)}
<EncounterLinkPickers
modules={user?.modules}
documentIds={draft.documentIds}
labResultIds={draft.labResultIds}
episodeIds={draft.episodeIds}
onChange={patch}
/>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
/**
* Browser-side labels for a visit's kind and status.
*
* The server has its own `encounterKindLabel` and the two do not share code on
* purpose: the server's answer becomes a push body in the RECORD OWNER's
* language, and this one renders in the language of whoever is looking. Sharing
* a resolver would force one of the two to read the wrong locale.
*
* Both switches spell out literal `t()` calls rather than interpolating a key.
* An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
* or missing label would ship as raw dot notation instead of failing CI.
*/
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
type Translate = (key: string) => string;
/** Every kind, in the order the picker offers them. */
export const ENCOUNTER_KINDS: EncounterKind[] = [
"ROUTINE",
"ACUTE",
"SPECIALIST",
"PREVENTIVE",
"EMERGENCY",
"HOSPITAL",
"THERAPY",
"OTHER",
];
/** Every status a visit can be edited into. */
export const ENCOUNTER_STATUSES: EncounterStatus[] = [
"PLANNED",
"DONE",
"CANCELLED",
"NO_SHOW",
];
export function encounterKindText(t: Translate, kind: EncounterKind): string {
switch (kind) {
case "ROUTINE":
return t("encounters.kind.routine");
case "ACUTE":
return t("encounters.kind.acute");
case "SPECIALIST":
return t("encounters.kind.specialist");
case "PREVENTIVE":
return t("encounters.kind.preventive");
case "EMERGENCY":
return t("encounters.kind.emergency");
case "HOSPITAL":
return t("encounters.kind.hospital");
case "THERAPY":
return t("encounters.kind.therapy");
default:
return t("encounters.kind.other");
}
}
export function encounterStatusText(
t: Translate,
status: EncounterStatus,
): string {
switch (status) {
case "PLANNED":
return t("encounters.status.planned");
case "DONE":
return t("encounters.status.done");
case "CANCELLED":
return t("encounters.status.cancelled");
default:
return t("encounters.status.noShow");
}
}
@@ -0,0 +1,248 @@
"use client";
/**
* The visit form's three optional link blocks: documents, lab results,
* conditions.
*
* **The gate blanks the block, it does not post-filter it.** A module the
* account turned off leaves nothing behind here — no heading, no empty list,
* no "0 selected". An empty picker for a switched-off module advertises a
* feature that is not there and invites a person to wonder what is wrong. This
* is the same shape the dashboard snapshot uses for a disabled module.
*
* The encounter itself carries NO module gate. A visit is core, like the
* checkups page it lives on; what it may point AT follows the modules that own
* those things, which is why the gate lives here and not on the route.
*
* Nothing in this block can block a save. Every list starts empty and stays
* valid empty.
*/
import { useQuery } from "@tanstack/react-query";
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { ModuleKey } from "@/lib/modules/registry";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
/** How many candidates one block offers before the person must use the sheet. */
const OPTION_LIMIT = 25;
interface Option {
id: string;
label: string;
/** ISO-8601, or null for a target with no date of its own. */
date: string | null;
}
function LinkBlock({
icon: Icon,
title,
options,
selected,
onToggle,
pending,
slot,
}: {
icon: LucideIcon;
title: string;
options: Option[];
selected: string[];
onToggle: (id: string) => void;
pending: boolean;
slot: string;
}) {
const { t } = useTranslations();
const format = useFormatters();
return (
<div className="space-y-2" data-slot={slot}>
<div className="flex items-center gap-2">
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">{title}</span>
{selected.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{selected.length}
</Badge>
) : null}
</div>
{pending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("encounters.form.linkNothingToOffer")}
</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
{options.map((option) => {
const on = selected.includes(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() => onToggle(option.id)}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
{option.label}
</span>
{option.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(option.date)}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function toggle(list: string[], id: string): string[] {
return list.includes(id)
? list.filter((entry) => entry !== id)
: [...list, id];
}
interface LabListPage {
results: Array<{
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}>;
}
interface DocumentListPage {
documents: InboundDocumentDto[];
}
export function EncounterLinkPickers({
modules,
documentIds,
labResultIds,
episodeIds,
onChange,
}: {
modules: Partial<Record<ModuleKey, boolean>> | undefined;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
onChange: (part: {
documentIds?: string[];
labResultIds?: string[];
episodeIds?: string[];
}) => void;
}) {
const { t } = useTranslations();
const documentsOn = modules?.inboundDocuments === true;
const labsOn = modules?.labs === true;
const illnessOn = modules?.illness === true;
const documents = useQuery({
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
enabled: documentsOn,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
),
});
const labs = useQuery({
queryKey: queryKeys.labResultsList({
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
enabled: labsOn,
queryFn: () =>
apiGet<LabListPage>(
`/api/labs?limit=${OPTION_LIMIT}&offset=0&sortDir=desc`,
),
});
const episodes = useQuery({
queryKey: queryKeys.illnessEpisodes(true),
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=true&limit=${OPTION_LIMIT}`,
),
});
if (!documentsOn && !labsOn && !illnessOn) return null;
return (
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
{documentsOn ? (
<LinkBlock
icon={FolderOpen}
title={t("encounters.form.linkDocuments")}
slot="encounter-link-documents"
pending={documents.isPending}
selected={documentIds}
onToggle={(id) => onChange({ documentIds: toggle(documentIds, id) })}
options={(documents.data?.documents ?? []).map((doc) => ({
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
}))}
/>
) : null}
{labsOn ? (
<LinkBlock
icon={FlaskConical}
title={t("encounters.form.linkLabResults")}
slot="encounter-link-labs"
pending={labs.isPending}
selected={labResultIds}
onToggle={(id) =>
onChange({ labResultIds: toggle(labResultIds, id) })
}
options={(labs.data?.results ?? []).map((result) => ({
id: result.id,
label: result.panel
? `${result.panel} · ${result.analyte}`
: result.analyte,
date: result.takenAt,
}))}
/>
) : null}
{illnessOn ? (
<LinkBlock
icon={Stethoscope}
title={t("encounters.form.linkConditions")}
slot="encounter-link-conditions"
pending={episodes.isPending}
selected={episodeIds}
onToggle={(id) => onChange({ episodeIds: toggle(episodeIds, id) })}
options={(episodes.data ?? []).map((episode) => ({
id: episode.id,
label: episode.label,
date: episode.onsetAt,
}))}
/>
) : null}
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
/**
* The sheet the visit form lives in — create and edit, one surface.
*
* Save is disabled only when the date cannot be parsed. Nothing else on the
* form can block it, which is the property the whole feature is checked
* against.
*
* Two entry points pre-fill it and neither changes what is required:
*
* - `reminderId` — opened from a due checkup. Filing the visit as DONE closes
* that checkup through the server's forward-only, idempotent path, so a
* person who both ticks the box and files a visit gets one satisfaction and
* no error.
* - `episodeId` — opened from a condition's "Visits about this" card, with
* the episode already linked.
*
* A delegate who may file the visit but not close the checkup gets the visit
* saved and a plain sentence saying the checkup stayed open. That is reported
* rather than skipped in silence: somebody who believes a checkup was closed
* will not look at it again.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
import {
EncounterForm,
draftFromEncounter,
draftInstant,
draftToBody,
emptyDraft,
nowLocalValue,
type EncounterDraft,
} from "./encounter-form";
export function EncounterSheet({
open,
onOpenChange,
encounter,
reminderId,
episodeId,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates a visit; a row edits it. */
encounter: Encounter | null;
/** The due checkup this visit is being filed against, when there is one. */
reminderId?: string | null;
/** A condition to pre-link, when the sheet was opened from an episode. */
episodeId?: string | null;
onSaved?: (saved: Encounter) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useEncounterMutations();
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Seeded once, at mount. The caller remounts this component on every open by
// varying its `key`, so a second edit never shows the first one's values and
// a create after an edit starts empty — without an effect that copies props
// into state and briefly renders the stale pair.
const [draft, setDraft] = useState<EncounterDraft>(() =>
encounter
? draftFromEncounter(encounter)
: emptyDraft({
occurredAt: nowLocalValue(),
episodeIds: episodeId ? [episodeId] : [],
}),
);
const [error, setError] = useState<string | null>(null);
const isEdit = encounter !== null;
const pending = create.isPending || update.isPending;
// The one gate. A date that does not parse is not a visit; everything else
// on this form is optional by design.
const dateUsable = draftInstant(draft.occurredAt) !== null;
const submit = async () => {
const body = draftToBody(draft, { isEdit, reminderId });
if (!body) return;
try {
const saved = isEdit
? await update.mutateAsync({ id: encounter.id, body })
: await create.mutateAsync(body);
// The server names what its grant could not reach. Today that is only a
// checkup closure, and a delegate seeing nothing would believe it closed
// — so a write that did part of what was asked reports `partial`, not a
// green tick, through the one module that owns the affordance.
const wroteEverything = (saved.skipped?.links ?? 0) === 0;
toastWrittenOutcome(
wroteEverything ? "success" : "partial",
wroteEverything
? t(isEdit ? "encounters.updated" : "encounters.created")
: t("encounters.checkupNotClosed"),
);
onSaved?.(saved);
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : t("encounters.saveFailed");
setError(message);
toast.error(message);
}
};
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(isEdit ? "encounters.editTitle" : "encounters.createTitle")}
description={t("encounters.formDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="min-h-11"
data-slot="encounter-save"
disabled={!dateUsable || pending}
onClick={() => void submit()}
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<EncounterForm draft={draft} onChange={setDraft} isEdit={isEdit} />
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
{isEdit ? (
<AlertDialog
open={confirmingDelete}
onOpenChange={setConfirmingDelete}
>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11"
data-slot="encounter-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("encounters.deleteTitle")}
</AlertDialogTitle>
{/* Says what goes with it. Deleting a booked visit retires its
reminder, and a person who booked one deserves to know the
nudge stops rather than discover it by silence. */}
<AlertDialogDescription>
{t("encounters.deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(encounter.id, {
onSuccess: () => onOpenChange(false),
onError: () => toast.error(t("encounters.deleteFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -0,0 +1,130 @@
"use client";
/**
* "Gehört zu Termin …" — the one rendering of the suggestion, for all three
* moments that ask it.
*
* The RULE is not here. It lives in `src/lib/encounters/suggest-window.ts` and
* is resolved server-side, so a document arriving in the vault, a lab panel
* committed from an extraction and a panel typed by hand cannot answer the same
* question differently. What this component owns is only how the three verdicts
* look, and it owns that once:
*
* one candidate → pre-selected, visibly, with one undo;
* two or more → a picker with NOTHING pre-selected;
* none → nothing at all. No empty picker, no "create a visit?" nudge.
*
* The middle branch is the point. Pre-selecting the first of two is the failure
* that teaches a person to stop reading suggestions, and the component cannot
* do it: the "many" verdict carries no pre-selection and this file never picks
* one.
*
* It never blocks a save — every caller treats the value as optional — and it
* is never behind an AI call: the match is a date-window query over the
* person's own rows and resolves on an account with no provider configured.
*
* Controlled. The caller owns where the chosen id goes: straight into a PATCH
* on a document, or into the body of a lab write that has not happened yet.
*/
import { CalendarClock, Check, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEncounterSuggestion } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { EncounterKind } from "@/generated/prisma/client";
import type { EncounterSuggestion } from "@/lib/encounters/suggest-window";
import { encounterKindText } from "./encounter-labels";
export function EncounterSuggestionField({
anchor,
value,
onChange,
disabled,
slot = "encounter-suggestion",
}: {
/** The date to resolve around, ISO-8601. `null` means there is nothing to ask. */
anchor: string | null;
/** The visit currently chosen here, or null. */
value: string | null;
onChange: (encounterId: string | null) => void;
disabled?: boolean;
/** `data-slot` prefix, so a caller's tests can address its own instance. */
slot?: string;
}) {
const { t } = useTranslations();
const format = useFormatters();
const suggestion = useEncounterSuggestion(anchor, !disabled);
if (disabled) return null;
const result = suggestion.data;
if (!result || result.kind === "none") return null;
const label = (candidate: EncounterSuggestion) =>
`${
candidate.practitionerName ??
encounterKindText(t, candidate.kind as EncounterKind)
} · ${format.date(candidate.occurredAt)}`;
const candidates =
result.kind === "one" ? [result.encounter] : result.encounters;
return (
<div
className="space-y-1.5"
data-slot={slot}
data-verdict={result.kind}
// The count is on the element so a test can prove the rule reached the
// surface rather than inferring it from how many buttons rendered.
data-candidates={candidates.length}
>
{/* A content sentence, not meta (§3): it is the offer itself. */}
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t(
result.kind === "one"
? "encounters.suggestion.single"
: "encounters.suggestion.choose",
)}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{candidates.map((candidate) => {
const chosen = value === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={chosen ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={chosen}
data-slot={`${slot}-option`}
onClick={() => onChange(chosen ? null : candidate.id)}
>
{result.kind === "one" ? (
<Check
className={cn("size-4", chosen ? "opacity-100" : "opacity-0")}
aria-hidden
/>
) : null}
{label(candidate)}
</Button>
);
})}
{value !== null ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
data-slot={`${slot}-undo`}
onClick={() => onChange(null)}
>
<Undo2 className="size-4" aria-hidden />
{t("common.undo")}
</Button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,177 @@
"use client";
/**
* The practice picker on the visit form.
*
* A combobox rather than a select, because the address book grows past the
* length a dropdown reads well at and a person types the first three letters
* of a practice name faster than they scroll to it. The search runs in SQL over
* the two plaintext columns — that is why they are plaintext.
*
* "Add new" opens a nested sheet rather than navigating away. A person filling
* a half-finished visit form who is sent to another page to add a doctor comes
* back to an empty form, so the affordance that looks helpful is the one that
* loses their work.
*
* The field is OPTIONAL and says so: a person can log "the emergency room on
* the 3rd" before they know whose name was on the door.
*/
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { usePractitioners, type Practitioner } from "@/hooks/use-practitioners";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { PractitionerSheet } from "@/components/practitioners/practitioner-sheet";
export function PractitionerCombobox({
value,
onChange,
/** The already-resolved entry, so the closed trigger names it without a read. */
selected,
disabled,
}: {
value: string | null;
onChange: (next: Practitioner | null) => void;
selected: Practitioner | null;
disabled?: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const [adding, setAdding] = useState(false);
// Bumped on each open so the nested sheet remounts empty rather than showing
// whatever the last attempt left in it.
const [session, setSession] = useState(0);
const debounced = useDebouncedValue(term, 250);
const list = usePractitioners(debounced, open);
const rows = list.data ?? [];
return (
<>
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
data-slot="encounter-practitioner-trigger"
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{selected?.name ?? t("encounters.form.practitionerNone")}
</span>
<ChevronsUpDown
className="size-4 shrink-0 opacity-50"
aria-hidden
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("encounters.form.practitionerSearch")}
aria-label={t("encounters.form.practitionerSearch")}
data-slot="encounter-practitioner-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{list.isPending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : rows.length === 0 ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("encounters.form.practitionerNoMatch")}
</p>
) : (
rows.map((row) => (
<button
key={row.id}
type="button"
data-slot="encounter-practitioner-option"
onClick={() => {
onChange(row);
setOpen(false);
}}
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
>
<Check
className={cn(
"size-4 shrink-0",
row.id === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate">
{row.name}
</span>
{row.practice ? (
<span className="text-muted-foreground block truncate text-xs">
{row.practice}
</span>
) : null}
</span>
</button>
))
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2 min-h-11 w-full"
onClick={() => {
setOpen(false);
setAdding(true);
setSession((n) => n + 1);
}}
>
<Plus className="size-4" aria-hidden />
{t("encounters.form.practitionerAdd")}
</Button>
</PopoverContent>
</Popover>
{selected ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={t("encounters.form.practitionerClear")}
onClick={() => onChange(null)}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
{/* Nested, so the half-filled visit form behind it stays mounted. */}
<PractitionerSheet
key={session}
open={adding}
onOpenChange={setAdding}
practitioner={null}
onSaved={(created) => onChange(created)}
/>
</>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
/**
* One visit, as a row in the list.
*
* The date, the practice and the reason are what the person wrote, so all three
* are `text-foreground`; the counts and the relative distance are meta and
* muted. Status is carried by the SECTION rather than repeated on every card —
* a card in the upcoming list is planned by construction, and a badge saying so
* on every row is noise that hides the two rows where the status is a surprise.
* A cancelled or missed visit is the exception and says so, because those are
* the ones a person is scanning for.
*/
import {
CalendarClock,
FileText,
FlaskConical,
Stethoscope,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Encounter } from "@/hooks/use-encounters";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import { encounterKindText, encounterStatusText } from "./encounter-labels";
/** The two statuses worth calling out on a row; the rest the section implies. */
function isNoteworthy(status: EncounterStatus): boolean {
return status === "CANCELLED" || status === "NO_SHOW";
}
export function VisitCard({
encounter,
onOpen,
}: {
encounter: Encounter;
onOpen: (encounter: Encounter) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const counts = [
{
icon: FileText,
n: encounter.links?.documents.length ?? 0,
label: t("encounters.card.documentCount"),
},
{
icon: FlaskConical,
n: encounter.links?.labResults.length ?? 0,
label: t("encounters.card.labCount"),
},
{
icon: Stethoscope,
n: encounter.links?.conditions.length ?? 0,
label: t("encounters.card.conditionCount"),
},
].filter((entry) => entry.n > 0);
// The practice, or the kind when there is none. A visit with neither is not
// nameless — the kind always resolves, because it defaults to one.
const heading =
encounter.practitioner?.name ??
encounterKindText(t, encounter.kind as EncounterKind);
return (
<Card
data-slot="visit-card"
data-encounter-id={encounter.id}
className="gap-2 py-3 md:py-4"
>
<CardContent className="space-y-2">
<button
type="button"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
<div className="flex items-start gap-2">
<CalendarClock
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{heading}
</p>
<p className="text-muted-foreground text-xs">
{format.dateTime(encounter.occurredAt)}
</p>
</div>
<Badge variant="outline" className="shrink-0">
{encounterKindText(t, encounter.kind as EncounterKind)}
</Badge>
</div>
{encounter.reason ? (
<p className="text-foreground line-clamp-1 text-sm">
{encounter.reason}
</p>
) : null}
{isNoteworthy(encounter.status as EncounterStatus) ||
counts.length ? (
<div className="flex flex-wrap items-center gap-3">
{isNoteworthy(encounter.status as EncounterStatus) ? (
<span className="text-muted-foreground text-xs">
{encounterStatusText(t, encounter.status as EncounterStatus)}
</span>
) : null}
{counts.map(({ icon: Icon, n, label }) => (
<span
key={label}
className="text-muted-foreground flex items-center gap-1 text-xs tabular-nums"
aria-label={`${n} ${label}`}
>
<Icon className="size-3.5" aria-hidden />
{n}
</span>
))}
</div>
) : null}
</button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,147 @@
"use client";
/**
* "Termine" — the visits half of the checkups page.
*
* Two lists rather than one, and the split is the server's: upcoming reads
* soonest-first, past reads newest-first, and the API resolves both so the
* ordering lives in one place. A client that split and re-sorted one array
* would be the second place it lives.
*
* No new nav entry and no new route. `/checkups` already owns exactly the right
* sentence — when do I have to do what, where — and is deliberately not module
* gated, which is also why a visit carries no module key of its own.
*/
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { useTranslations } from "@/lib/i18n/context";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const list = useEncounters(enabled);
// `null` closed, `"new"` create, a row edits it.
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
const [session, setSession] = useState(0);
const openSheet = (target: Encounter | "new") => {
setEditing(target);
setSession((n) => n + 1);
};
const upcoming = list.data?.upcoming ?? [];
const past = list.data?.past ?? [];
const empty = upcoming.length === 0 && past.length === 0;
const addButton = canManage ? (
<Button
type="button"
className="min-h-11"
data-slot="visits-add"
onClick={() => openSheet("new")}
>
<Plus className="size-4" aria-hidden />
{t("encounters.add")}
</Button>
) : null;
return (
<section aria-labelledby="visits-section-title" className="space-y-4">
<PageHeader
titleId="visits-section-title"
title={t("encounters.sectionTitle")}
description={t("encounters.sectionDescription")}
actions={
<>
<Button asChild variant="outline" className="min-h-11">
<Link href="/checkups/practitioners">
<ContactRound className="size-4" aria-hidden />
{t("practitioners.linkLabel")}
</Link>
</Button>
{addButton}
</>
}
/>
{list.isError ? (
<QueryErrorCard
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full rounded-xl" />
))}
</div>
) : empty ? (
<EmptyState
icon={<CalendarClock className="size-6" aria-hidden />}
title={t("encounters.emptyTitle")}
description={t("encounters.emptyDescription")}
action={addButton ?? undefined}
ctaSize="lg"
/>
) : (
<div className="space-y-6">
{upcoming.length > 0 ? (
<div className="space-y-2" data-slot="visits-upcoming">
<SectionHeading
icon={CalendarClock}
title={t("encounters.upcomingHeading")}
/>
{upcoming.map((encounter) => (
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
/>
))}
</div>
) : null}
{past.length > 0 ? (
<div className="space-y-2" data-slot="visits-past">
<SectionHeading
icon={History}
title={t("encounters.pastHeading")}
/>
{past.map((encounter) => (
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
/>
))}
</div>
) : null}
</div>
)}
{/* `key` changes on every open, so the sheet remounts with a fresh draft
instead of copying props into state from an effect. It stays mounted
while closing, which is what keeps the exit animation. */}
<EncounterSheet
key={session}
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
encounter={editing === "new" ? null : editing}
/>
</section>
);
}
@@ -0,0 +1,151 @@
"use client";
/**
* "Termine dazu" — the visits filed against one condition episode.
*
* **From the episode side only, and that is a decision rather than an
* omission.** An episode spans weeks and a visit is a point, so matching from
* the VISIT side would produce false positives at a rate that teaches a person
* to ignore every suggestion in the product. Here the person already has the
* condition in front of them and is the one who knows; the card offers an
* action instead of a guess.
*
* Modelled on the episode's documents card, which does exactly this for the
* vault. Renders nothing when the illness module is off — a gated surface never
* leaks, and an episode page is unreachable in that state anyway.
*/
import { CalendarClock, Plus } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ListRow } from "@/components/ui/list-row";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useEncounters } from "@/hooks/use-encounters";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
const [sheetOpen, setSheetOpen] = useState(false);
const [session, setSession] = useState(0);
const moduleEnabled = user?.modules?.illness === true;
// The filter runs in SQL: the two arms of the response are separately
// bounded, so narrowing here would page wrongly on a long history.
const list = useEncounters(moduleEnabled, { episodeId });
if (!moduleEnabled) return null;
if (list.isError) {
return (
<QueryErrorCard
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
);
}
const visits = [...(list.data?.upcoming ?? []), ...(list.data?.past ?? [])];
return (
<>
<Card data-slot="episode-visits-card">
<CardContent className="space-y-4">
<TileHeader
icon={CalendarClock}
title={t("encounters.episodeCard.title")}
right={
visits.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{visits.length}
</span>
) : undefined
}
/>
{list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, index) => (
<Skeleton key={index} className="h-12 w-full rounded-lg" />
))}
</div>
) : visits.length === 0 ? (
// A quiet one-line absence, not a teaching empty state: the person
// came here about the condition, not to be taught about visits.
<p className="text-muted-foreground text-sm">
{t("encounters.episodeCard.emptyLine")}
</p>
) : (
<ul className="space-y-1.5">
{visits.map((visit) => (
<li key={visit.id}>
<ListRow asChild>
<Link
href="/checkups"
data-encounter-id={visit.id}
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
<CalendarClock
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{visit.practitioner?.name ??
encounterKindText(t, visit.kind as EncounterKind)}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(visit.occurredAt)}
</span>
</Link>
</ListRow>
</li>
))}
</ul>
)}
{canManage ? (
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
data-slot="episode-visits-add"
onClick={() => {
setSheetOpen(true);
setSession((n) => n + 1);
}}
>
<Plus className="size-4" aria-hidden />
{t("encounters.episodeCard.add")}
</Button>
</div>
) : null}
</CardContent>
</Card>
{canManage ? (
<EncounterSheet
key={session}
open={sheetOpen}
onOpenChange={setSheetOpen}
encounter={null}
// Pre-linked, so the person never has to find this condition again
// in a picker they already navigated away from.
episodeId={episodeId}
/>
) : null}
</>
);
}
@@ -11,6 +11,7 @@
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -156,6 +157,12 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
nothing when the documents module is off for this account. */}
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
{/* Condition ⇄ visits linking, condition side ONLY. A visit never
suggests an episode: an episode spans weeks and a visit is a point,
so the reverse match would be wrong often enough to train the person
to ignore every suggestion in the product. */}
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
{episode ? (
<>
<LogDaySheet
@@ -0,0 +1,170 @@
/**
* What reaches the screen, not what the strings say.
*
* The copy guard reads the bundles; this reads the render. Between them they
* cover the two halves of the same rule — that the two readings of a day are
* shown side by side and never merged, that the forecast never appears without
* its band and its count, and that the deviation is measured against the band
* rather than against the midpoint.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
import { MoodPrognosisView } from "../mood-prognosis-card";
function render(reading: PrognosisReading): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodPrognosisView reading={reading} />
</I18nProvider>,
);
}
function present(over: Partial<PrognosisReading> = {}): PrognosisReading {
return {
present: true,
date: "2026-08-07",
predicted: 5.4,
ciLow: 4.1,
ciHigh: 6.8,
n: 42,
modelVersion: "mood-ridge-1",
computedAt: "2026-08-08T02:35:00.000Z",
ageDays: 1,
current: true,
provisional: false,
stage: "regular",
entries: 64,
seasonalUnlocked: false,
selfAssessment: 6,
deviation: "within",
deviationAmount: 0,
contributions: [
{ feature: "dimension:a2", contribution: 0.9 },
{ feature: "linked:steps", contribution: -0.4 },
],
...over,
} as PrognosisReading;
}
describe("<MoodPrognosisView>", () => {
it("shows both readings, and shows the person's own one first", () => {
const html = render(present());
expect(html).toContain('data-slot="mood-prognosis-self"');
expect(html).toContain('data-slot="mood-prognosis-expected"');
// The self block precedes the expected block in the document, which is
// what "the self-assessment leads" means to a screen reader.
expect(html.indexOf('data-slot="mood-prognosis-self"')).toBeLessThan(
html.indexOf('data-slot="mood-prognosis-expected"'),
);
// Both values are on screen as themselves. A merged single figure is the
// failure this whole feature is shaped to avoid.
expect(html).toContain("6.0");
expect(html).toContain("5.4");
});
it("never shows the value without its band and its count", () => {
const html = render(present());
expect(html).toContain("4.1");
expect(html).toContain("6.8");
expect(html).toContain("42");
});
it("phrases the forecast as a counterfactual", () => {
const html = render(present());
expect(html).toContain("would have been expected");
expect(html).not.toContain("your mood is");
});
it("measures the deviation against the band, not the midpoint", () => {
// 6.0 is above the 5.4 midpoint and inside the 4.1-6.8 band, so it is not
// a finding. A midpoint comparison would have called it one.
expect(render(present())).toContain("inside that range");
const above = render(
present({ selfAssessment: 8, deviation: "above", deviationAmount: 1.2 }),
);
expect(above).toContain("1.2 points above");
});
it("labels a provisional forecast as provisional", () => {
expect(render(present({ provisional: true }))).toContain(
'data-slot="mood-prognosis-provisional"',
);
expect(render(present())).not.toContain(
'data-slot="mood-prognosis-provisional"',
);
});
it("says which day it is about when that day is not today", () => {
const stale = render(present({ current: false, ageDays: 4 }));
expect(stale).toContain('data-slot="mood-prognosis-age"');
// Rendered through the account's own date-order preference rather than as
// the raw day key — under this render's `en` locale and the default AUTO
// preference that is month-first. The assertion is on the formatted form
// precisely because a raw `2026-08-07` on the screen would pass a looser
// one.
expect(stale).toContain("08/07/2026");
expect(stale).not.toContain("2026-08-07");
});
it("names what the model weighted, in the reader's own words", () => {
const html = render(present());
// The dimension's own label, the one the slider carries.
expect(html).toContain("Stress");
expect(html).toContain("Steps");
expect(html).toContain("counted towards a higher expected value");
expect(html).toContain("counted towards a lower expected value");
});
it("skips a feature key this build no longer knows", () => {
const html = render(
present({
contributions: [
{ feature: "retired:whatever", contribution: 2 },
{ feature: "dimension:a3", contribution: 1 },
],
}),
);
expect(html).not.toContain("retired:whatever");
expect(html).toContain('data-slot="mood-prognosis-explanation"');
});
it("renders nothing at all below the first rung", () => {
expect(
render({
present: false,
reason: "no-output-yet",
entries: 4,
nextThreshold: 15,
}),
).toBe("");
});
it("explains the learning phase with the count and the next step", () => {
const html = render({
present: false,
reason: "learning-phase",
entries: 22,
nextThreshold: 30,
});
expect(html).toContain("22");
expect(html).toContain("30");
// Absence, not an error and not a zero.
expect(html).not.toContain('data-slot="mood-prognosis-expected"');
});
it("refuses honestly when there are days but no pattern", () => {
const html = render({
present: false,
reason: "no-pattern",
entries: 140,
nextThreshold: null,
});
expect(html).toContain("140");
expect(html).not.toContain('data-slot="mood-prognosis-statement"');
});
});
@@ -0,0 +1,248 @@
"use client";
/**
* The day's two readings, side by side, with the person's own one leading.
*
* The copy rules this component exists to hold, stated once so nobody has to
* infer them from the strings:
*
* * **No sentence names a cause.** Not "overtime lowered your mood", not
* "poor sleep is why today was hard". What the data supports is "on your
* own past days, a value around X would have been expected", and that is
* what it says. The ban is a test, not a habit:
* `src/__tests__/copy-matches-behaviour-guard.test.ts` reads the six
* bundles and fails on a causal construction in any of them.
* * **The forecast is a counterfactual and the sentence says so.** Never
* "your mood is 5.4" — always "a value around 5.4 would have been
* expected". The difference is the whole distinction between the two
* readings.
* * **The count is on the same surface as the number**, not in a tooltip. A
* forecast built from thirty days and one built from three hundred look
* identical otherwise, and a reader who cannot see which one they are
* looking at cannot weigh it.
* * **The deviation is measured against the band, never against the
* midpoint.** Inside the band is "as expected"; outside it is the finding.
*
* The explanation is generated from the contributions the job stored for that
* day, so it names what the model actually weighted rather than a fresh
* calculation against a model that may since have been re-fitted.
*/
import { useQuery } from "@tanstack/react-query";
import { Compass } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/date-format";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
import { prognosisFeatureLabel } from "./prognosis-feature-label";
/** How many weighted features the explanation names. */
const EXPLAINED_FEATURES = 3;
function formatValue(value: number, locale: string): string {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value);
}
export function MoodPrognosisCard() {
const { isAuthenticated } = useAuth();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodPrognosis(),
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
enabled: isAuthenticated,
staleTime: 60_000,
});
if (isLoading) {
return <Skeleton className="min-h-24 w-full rounded-xl" />;
}
if (!data) return null;
return <MoodPrognosisView reading={data} />;
}
/**
* The card itself, over a resolved reading.
*
* Split from the query so the copy rules above can be asserted against real
* renders rather than against the strings alone: "the two values are never
* merged" is a property of what reaches the screen, and a bundle check cannot
* see it.
*/
export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) {
const { t, locale } = useTranslations();
const dateFormat = useDateFormatPreference();
const data = reading;
// The day key is a plain calendar date and is rendered as one, through the
// account's own date-order preference and pinned to UTC — the same treatment
// every other day key in the app gets, so this card cannot show a different
// day from the list beside it.
const day = data.present ? formatDate(data.date, dateFormat, locale) : "";
// Below the first rung there is nothing to say and the surface says nothing
// rather than showing an empty frame with an apology in it.
if (!data.present && data.reason === "no-output-yet") return null;
return (
<Card data-slot="mood-prognosis" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader icon={Compass} title={t("insights.mood.prognosis.title")} />
</CardHeader>
<CardContent className="space-y-3">
{!data.present ? (
<p
className="text-muted-foreground text-sm"
data-slot="mood-prognosis-absent"
>
{data.reason === "learning-phase"
? t("insights.mood.prognosis.learning", {
entries: String(data.entries),
threshold: String(data.nextThreshold ?? 0),
})
: t("insights.mood.prognosis.noPattern", {
entries: String(data.entries),
})}
</p>
) : (
<>
<div className="flex flex-wrap items-end gap-x-8 gap-y-3">
{/* The self-assessment leads: it is the larger number, it comes
first, and it is the only one of the two the person gave. */}
<div data-slot="mood-prognosis-self">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.selfLabel")}
</p>
<p className="text-foreground text-3xl font-semibold tabular-nums">
{data.selfAssessment === null
? t("mood.dayContext.notRecorded")
: formatValue(data.selfAssessment, locale)}
</p>
</div>
<div data-slot="mood-prognosis-expected">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.expectedLabel")}
</p>
<p className="text-foreground text-xl font-medium tabular-nums">
{formatValue(data.predicted, locale)}
</p>
{/* The band and the count ride with the value, in the block a
screen reader reaches in the same breath. */}
<p className="text-muted-foreground text-xs tabular-nums">
{data.ciLow !== null && data.ciHigh !== null
? t("insights.mood.prognosis.band", {
low: formatValue(data.ciLow, locale),
high: formatValue(data.ciHigh, locale),
})
: null}
</p>
</div>
</div>
<p
className="text-foreground text-sm"
data-slot="mood-prognosis-statement"
>
{t("insights.mood.prognosis.statement", {
value: formatValue(data.predicted, locale),
date: day,
n: String(data.n),
})}
</p>
{data.provisional ? (
<p
className="text-muted-foreground text-xs"
data-slot="mood-prognosis-provisional"
>
{t("insights.mood.prognosis.provisional")}
</p>
) : null}
{!data.current ? (
<p
className="text-muted-foreground text-xs"
data-slot="mood-prognosis-age"
>
{t("insights.mood.prognosis.notToday", { date: day })}
</p>
) : null}
{data.deviation !== null && data.selfAssessment !== null ? (
<p
className="text-foreground text-sm"
data-slot="mood-prognosis-deviation"
>
{data.deviation === "within"
? t("insights.mood.prognosis.deviationWithin")
: data.deviation === "above"
? t("insights.mood.prognosis.deviationAbove", {
amount: formatValue(data.deviationAmount ?? 0, locale),
})
: t("insights.mood.prognosis.deviationBelow", {
amount: formatValue(data.deviationAmount ?? 0, locale),
})}
</p>
) : null}
<PrognosisExplanation contributions={data.contributions} />
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.caveat")}
</p>
</>
)}
</CardContent>
</Card>
);
}
function PrognosisExplanation({
contributions,
}: {
contributions: Array<{ feature: string; contribution: number }>;
}) {
const { t } = useTranslations();
const named = contributions
.map((item) => ({
...item,
label: prognosisFeatureLabel(item.feature, t),
}))
.filter(
(item): item is typeof item & { label: string } =>
item.label !== null && item.contribution !== 0,
)
.slice(0, EXPLAINED_FEATURES);
if (named.length === 0) return null;
return (
<div className="space-y-1" data-slot="mood-prognosis-explanation">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.weightedTitle")}
</p>
<ul className="space-y-0.5">
{named.map((item) => (
<li
key={item.feature}
className="text-foreground text-sm"
data-slot="mood-prognosis-weighted-item"
>
{item.contribution > 0
? t("insights.mood.prognosis.weightedUp", { label: item.label })
: t("insights.mood.prognosis.weightedDown", {
label: item.label,
})}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,71 @@
/**
* A stored feature key, in the reader's own language.
*
* The keys in `MoodPrediction.features` are stable identifiers, not copy. This
* turns one into a label, and it does so out of the strings the capture
* surfaces already use: a person reads "Workload" here because that is what
* the slider they moved was called, not because a second wording was invented
* for the explanation.
*
* Every key it cannot place returns `null`, and that path is real rather than
* defensive: a row written by an older `modelVersion` can name a feature this
* build has dropped, and skipping it is better than rendering an identifier at
* somebody.
*/
import { parseFeatureKey } from "@/lib/analytics/mood-prognosis/feature-keys";
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
import { getMoodDimension } from "@/lib/mood/dimensions";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* The five linked figures, resolved through literal keys.
*
* A switch rather than a computed key so the call-site coverage guard can see
* every string this file can ask for. The keys are the capture sheet's own
* (`mood.dayContext.linked*`), which is the point: the label a figure carries
* in the explanation is the label it carries where it was read.
*/
function linkedLabel(metric: string, t: Translate): string | null {
switch (metric) {
case "sleepAsleep":
return t("mood.dayContext.linkedSleep");
case "steps":
return t("mood.dayContext.linkedSteps");
case "activeEnergy":
return t("mood.dayContext.linkedActiveEnergy");
case "restingHeartRate":
return t("mood.dayContext.linkedRestingHeartRate");
case "heartRateVariability":
return t("mood.dayContext.linkedHrv");
default:
return null;
}
}
export function prognosisFeatureLabel(
key: string,
t: Translate,
): string | null {
const parsed = parseFeatureKey(key);
if (parsed === null) return null;
if (parsed.family === "dimension") {
const dimension = getMoodDimension(parsed.dimension);
return dimension ? t(dimension.labelKey) : null;
}
if (parsed.family === "linked") return linkedLabel(parsed.metric, t);
const field = t(`mood.context.field.${parsed.field}`);
if (parsed.value === null) return field;
// "Working day: longer than usual" — the field and the value the person
// picked, joined by a pattern rather than by a hard-coded colon, because the
// punctuation between them is a locale's business.
return t("insights.mood.prognosis.featureValue", {
field,
value: t(contextValueLabelKey(parsed.field, parsed.value)),
});
}
+27 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useRef, useState } from "react";
import { useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
@@ -25,6 +25,7 @@ import { localizedApiError } from "@/lib/api/localized-error";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -118,6 +119,21 @@ export function LabForm({
const [value, setValue] = useState("");
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
/**
* The visit this reading came out of, when the suggestion offered one.
*
* Deliberately NOT cleared by the keep-open reset below: a real lab report
* shares one draw date and one visit across every analyte, so the next row
* off the same report keeps both rather than asking again.
*/
const [visitId, setVisitId] = useState<string | null>(null);
// The `DateTimeField` value is a local `yyyy-MM-ddTHH:mm`; the suggestion
// asks the server in ISO-8601, so it is converted once here rather than in
// the shared field, which must not know about this form's input format.
const takenAtAnchor = useMemo(() => {
const parsed = new Date(takenAt);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}, [takenAt]);
const [note, setNote] = useState("");
// The window the paper report printed for THIS reading. Optional and folded
// away by default: most entries are typed against a catalog marker and never
@@ -224,6 +240,7 @@ export function LabForm({
: {}),
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
...(visitId ? { encounterId: visitId } : {}),
});
toast.success(
t("labs.form.savedToast"),
@@ -449,6 +466,15 @@ export function LabForm({
</div>
</div>
{/* Anchored on the draw date, not on now: backdating a reading moves
the offer with it. */}
<EncounterSuggestionField
anchor={takenAtAnchor}
value={visitId}
onChange={setVisitId}
slot="lab-form-encounter-suggestion"
/>
{resultType === "numeric" && selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
+32 -1
View File
@@ -18,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError } from "@/lib/api/api-fetch";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { OcrRowEditor } from "./ocr-row-editor";
@@ -128,6 +129,12 @@ export function OcrReviewDialog({
// filed into the Documents vault and cross-linked to the committed labs. Text
// mode keeps the image on-device, so nothing is retained there.
const [pickedFile, setPickedFile] = useState<File | null>(null);
/**
* The visit this panel came out of, if the review step offered one and the
* person took the offer. Never blocks the save and never pre-fills anything
* else: the panel commits with it unset exactly as before.
*/
const [visitId, setVisitId] = useState<string | null>(null);
// Text mode OCR's the image in the browser then POSTs the text; vision mode
// uploads the image. Both resolve with the same proposed-rows DTO.
@@ -143,10 +150,24 @@ export function OcrReviewDialog({
[rows],
);
/**
* The draw date the whole panel hangs off — the earliest `takenAt` the scan
* read. A panel is one draw; asking the suggestion about each row separately
* would ask the same question a dozen times and could answer it a dozen ways.
*/
const panelAnchor = useMemo(() => {
const dates = rows
.map((row) => row.takenAt)
.filter((value): value is string => Boolean(value))
.sort();
return dates.length > 0 ? `${dates[0]}T12:00:00.000Z` : null;
}, [rows]);
function reset() {
setStage("pick");
setRows([]);
setPickedFile(null);
setVisitId(null);
extract.reset();
commit.reset();
}
@@ -183,7 +204,7 @@ export function OcrReviewDialog({
return;
}
commit.mutate(
{ rows: payload, file: pickedFile },
{ rows: payload, file: pickedFile, encounterId: visitId },
{
onSuccess: (result) => {
// The commit re-checks each confirmed row against what is already
@@ -307,6 +328,16 @@ export function OcrReviewDialog({
<p className="text-muted-foreground text-sm">
{t("labs.ocr.foundCount", { count: rows.length })}
</p>
{/* Anchored on the draw date the scan read, so the offer is about
the panel rather than about today. */}
<EncounterSuggestionField
anchor={panelAnchor}
value={visitId}
onChange={setVisitId}
slot="labs-ocr-encounter-suggestion"
/>
{rows.map((row) => (
<OcrRowEditor
key={row.key}
+8 -1
View File
@@ -143,6 +143,12 @@ export interface OcrCommitInput {
* image stays on-device.
*/
file?: File | null;
/**
* The visit this panel came out of, when the review step offered one and the
* person took the offer. Optional always — a commit with none named succeeds
* unchanged.
*/
encounterId?: string | null;
}
/**
@@ -166,13 +172,14 @@ async function fileScanToVault(file: File): Promise<string | undefined> {
export function useOcrCommit() {
const queryClient = useQueryClient();
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
mutationFn: async ({ rows, file }: OcrCommitInput) => {
mutationFn: async ({ rows, file, encounterId }: OcrCommitInput) => {
const documentId = file
? await fileScanToVault(file).catch(() => undefined)
: undefined;
return apiPost<OcrCommitResult>("/api/labs/ocr/commit", {
rows,
...(documentId ? { documentId } : {}),
...(encounterId ? { encounterId } : {}),
});
},
onSuccess: (_result, { file }) => {
@@ -20,7 +20,14 @@ import { useRouter } from "next/navigation";
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useEncounters } from "@/hooks/use-encounters";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -38,6 +45,15 @@ import {
const SUMMARY_LIMIT = 3;
/**
* How far ahead an appointment has to be before this card stops mentioning it.
*
* A dashboard summary answers "what is coming", and two weeks is the horizon a
* person plans in. Further out and the line is noise that pushes the checkups
* it sits under off the card.
*/
const APPOINTMENT_HORIZON_DAYS = 14;
function resolveLabel(
reminder: MeasurementReminder,
t: (key: string) => string,
@@ -54,6 +70,7 @@ export function VorsorgeDashboardCard() {
// Issue #490 — the same profile zone the checkups page derives its due
// phrase in, so the two screens still agree when the device sits elsewhere.
const displayTz = useDisplayTimezone();
const format = useFormatters();
const router = useRouter();
// The same answer `/checkups` gives the same action. Satisfying a reminder
// is not one of the verbs a delegation admits at any level
@@ -72,6 +89,26 @@ export function VorsorgeDashboardCard() {
const showLoading = !reminders && !isError;
const { satisfy } = useMeasurementReminderMutations();
const [now] = useState(() => Date.now());
/**
* The next appointment, read from the VISIT list and never from the reminder
* table.
*
* An ENCOUNTER-origin reminder is excluded from every preventive-care read,
* which is correct — an appointment is not a checkup, and relaxing that
* filter here would put one in the due list. But a notification about
* something invisible in the app is worse than no notification, so the
* appointment appears where a person looks, sourced from the record it
* actually lives in. A diff in this file that touches `origin` is the wrong
* fix.
*/
const visits = useEncounters();
const nextAppointment = (visits.data?.upcoming ?? []).find(
(visit) =>
visit.status === "PLANNED" &&
new Date(visit.occurredAt).getTime() - now <=
APPOINTMENT_HORIZON_DAYS * 24 * 60 * 60 * 1000,
);
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
const [captureFooterEl, setCaptureFooterEl] = useState<HTMLDivElement | null>(
null,
@@ -183,6 +220,27 @@ export function VorsorgeDashboardCard() {
})}
</ul>
)}
{/* Visually distinct from a due checkup: a quiet line under the block
rather than a fourth row with an action, because there is nothing
to do about an appointment except keep it. */}
{nextAppointment ? (
<Link
href="/checkups"
data-slot="vorsorge-dashboard-next-visit"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<CalendarClock className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">
{t("encounters.dashboard.nextVisit", {
what:
nextAppointment.practitioner?.name ??
encounterKindText(t, nextAppointment.kind as EncounterKind),
when: format.date(nextAppointment.occurredAt),
})}
</span>
</Link>
) : null}
</CardContent>
<ResponsiveSheet
@@ -35,6 +35,7 @@ import { useRouter } from "next/navigation";
import {
Activity,
CalendarClock,
CalendarPlus,
CheckCircle2,
Plus,
RefreshCw,
@@ -50,6 +51,7 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
import { cn } from "@/lib/utils";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AlertDialog,
@@ -238,6 +240,19 @@ export function VorsorgeSection({
const [editing, setEditing] = useState<MeasurementReminder | "new" | null>(
null,
);
/**
* The due checkup a visit is being filed against, if any.
*
* The visit sheet does the rest: saving it as DONE satisfies this reminder
* through the server's forward-only, idempotent path, so filing a visit AND
* ticking the box is one satisfaction rather than an error.
*/
const [visitForReminder, setVisitForReminder] = useState<string | null>(null);
const [visitSession, setVisitSession] = useState(0);
const openVisitFor = (reminder: MeasurementReminder) => {
setVisitForReminder(reminder.id);
setVisitSession((n) => n + 1);
};
const [form, setForm] = useState<FormState>(EMPTY_FORM);
// The value-capture sheet: holds the reminder whose measurement we are
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
@@ -738,6 +753,7 @@ export function VorsorgeSection({
now={now}
view={prefs.view}
onPrimaryAction={() => onPrimaryAction(reminder)}
onFileVisit={() => openVisitFor(reminder)}
onRemove={() => remove.mutate(reminder.id)}
onEdit={() => openEdit(reminder)}
busy={
@@ -749,6 +765,18 @@ export function VorsorgeSection({
)}
</ul>
)}
<EncounterSheet
key={visitSession}
open={visitForReminder !== null}
onOpenChange={(open) => {
if (!open) setVisitForReminder(null);
}}
encounter={null}
reminderId={visitForReminder}
// The reminder list repaints from the encounter write's own
// invalidation; nothing extra to do here.
onSaved={() => setVisitForReminder(null)}
/>
</section>
);
}
@@ -779,6 +807,7 @@ function VorsorgeCard({
now,
view,
onPrimaryAction,
onFileVisit,
onRemove,
onEdit,
busy,
@@ -787,6 +816,8 @@ function VorsorgeCard({
now: number;
view: "cards" | "list";
onPrimaryAction: () => void;
/** Open the visit sheet with this checkup pre-filled, closing it on save. */
onFileVisit: () => void;
onRemove: () => void;
onEdit: () => void;
busy: boolean;
@@ -912,6 +943,30 @@ function VorsorgeCard({
// "do it now" wash; a button that changes colour by schedule state reads as
// a different control). Due-ness is communicated by the discreet coloured
// next-due text above.
/**
* "Termin eintragen" — the second way to close a checkup.
*
* Beside "erledigt" rather than instead of it, because the two answer
* different questions: one says the thing happened, the other says WHERE it
* happened and files the record of it. Saving the visit as DONE satisfies
* this reminder through the same forward-only, idempotent primitive the
* checkbox uses, so a person who does both gets one satisfaction and no
* error.
*/
const fileVisitButton = canManage ? (
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
data-slot="vorsorge-file-visit"
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" />
{t("measurementReminders.fileVisit")}
</Button>
) : null;
const primaryButton = canManage ? (
<Button
type="button"
@@ -1003,6 +1058,24 @@ function VorsorgeCard({
: t("measurementReminders.markDone")}
</Button>
) : null}
{/* Icon-only in the dense list, labelled in the cards branch —
the same action either way, never one branch offering what
the other withholds. */}
{canManage ? (
<Button
type="button"
variant="outline"
size="icon"
className="size-11 sm:size-9"
data-slot="vorsorge-file-visit"
aria-label={t("measurementReminders.fileVisit")}
title={t("measurementReminders.fileVisit")}
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" aria-hidden />
</Button>
) : null}
{headerActions}
</div>
</CardContent>
@@ -1129,7 +1202,10 @@ function VorsorgeCard({
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
{primaryButton ? (
<div className="mt-auto pt-0">{primaryButton}</div>
<div className="mt-auto space-y-1.5 pt-0">
{primaryButton}
{fileVisitButton}
</div>
) : null}
</CardContent>
</Card>
@@ -0,0 +1,228 @@
"use client";
/**
* The account's own address book of doctors and practices.
*
* **Record content, not configuration**, so it does not live in Settings: a
* settings surface configures behaviour, and a list of one's own doctors
* configures nothing. It sits beside the visits that name it.
*
* Deleting is a soft delete and the visits that referenced the entry keep it.
* The confirmation says so in one sentence, because a person deleting a doctor
* has every reason to fear their history goes with it.
*/
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
usePractitionerMutations,
usePractitioners,
type Practitioner,
} from "@/hooks/use-practitioners";
import { useTranslations } from "@/lib/i18n/context";
import { PractitionerSheet } from "./practitioner-sheet";
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const [term, setTerm] = useState("");
const debounced = useDebouncedValue(term, 250);
const list = usePractitioners(debounced, enabled);
const { remove } = usePractitionerMutations();
const [editing, setEditing] = useState<Practitioner | "new" | null>(null);
const [deleting, setDeleting] = useState<Practitioner | null>(null);
// Bumped on every open so the sheet remounts with a fresh draft rather than
// copying props into state from an effect.
const [session, setSession] = useState(0);
const openSheet = (target: Practitioner | "new") => {
setEditing(target);
setSession((n) => n + 1);
};
const rows = list.data ?? [];
const addButton = canManage ? (
<Button
type="button"
className="min-h-11"
data-slot="practitioner-add"
onClick={() => openSheet("new")}
>
<Plus className="size-4" aria-hidden />
{t("practitioners.add")}
</Button>
) : null;
return (
<section className="space-y-4">
<PageHeader
title={t("practitioners.title")}
description={t("practitioners.description")}
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
actions={addButton}
/>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("practitioners.searchPlaceholder")}
aria-label={t("practitioners.searchPlaceholder")}
className="pl-9"
data-slot="practitioner-search"
/>
</div>
{list.isError ? (
<QueryErrorCard
title={t("practitioners.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-16 w-full rounded-xl" />
))}
</div>
) : rows.length === 0 ? (
<EmptyState
icon={<ContactRound className="size-6" aria-hidden />}
title={t(
debounced
? "practitioners.noMatchTitle"
: "practitioners.emptyTitle",
)}
description={t(
debounced
? "practitioners.noMatchDescription"
: "practitioners.emptyDescription",
)}
action={debounced ? undefined : (addButton ?? undefined)}
ctaSize="lg"
/>
) : (
<ul className="space-y-2" data-slot="practitioner-list">
{rows.map((row) => (
<li key={row.id}>
<Card className="gap-2 py-3 md:py-4">
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{row.name}
</p>
{row.practice || row.specialty ? (
<p className="text-foreground truncate text-sm">
{[row.practice, row.specialty]
.filter(Boolean)
.join(" · ")}
</p>
) : null}
{row.location ? (
<p className="text-muted-foreground truncate text-xs">
{row.location}
</p>
) : null}
</div>
{canManage ? (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.edit")}
onClick={() => openSheet(row)}
>
<Pencil className="size-4" aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.delete")}
onClick={() => setDeleting(row)}
>
<Trash2 className="size-4" aria-hidden />
</Button>
</div>
) : null}
</CardContent>
</Card>
</li>
))}
</ul>
)}
<PractitionerSheet
key={session}
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
practitioner={editing === "new" ? null : editing}
/>
<AlertDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) setDeleting(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("practitioners.deleteTitle")}
</AlertDialogTitle>
{/* The one sentence that matters: the old visits stay. */}
<AlertDialogDescription>
{t("practitioners.deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (!deleting) return;
const id = deleting.id;
setDeleting(null);
remove.mutate(id, {
onError: () => toast.error(t("practitioners.deleteFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
@@ -0,0 +1,205 @@
"use client";
/**
* Add or correct one address-book entry.
*
* Six plaintext-ish fields and one encrypted note. `name` is the only required
* one, for the same reason the visit only requires a date: a person typing a
* practice into a form after a doctor's appointment should not be stopped by a
* phone number they do not have to hand.
*
* Reachable two ways — from the address-book page and nested inside the visit
* form's picker — so it owns no navigation of its own and reports what it
* created back to whoever opened it.
*/
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import {
usePractitionerMutations,
type Practitioner,
type PractitionerWriteBody,
} from "@/hooks/use-practitioners";
interface Draft {
name: string;
specialty: string;
practice: string;
location: string;
phone: string;
note: string;
}
const EMPTY: Draft = {
name: "",
specialty: "",
practice: "",
location: "",
phone: "",
note: "",
};
function draftFrom(row: Practitioner | null): Draft {
if (!row) return EMPTY;
return {
name: row.name,
specialty: row.specialty ?? "",
practice: row.practice ?? "",
location: row.location ?? "",
phone: row.phone ?? "",
note: row.note ?? "",
};
}
/** An empty field clears the column rather than writing an empty string. */
function orNull(value: string): string | null {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function PractitionerSheet({
open,
onOpenChange,
practitioner,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates; a row edits it. */
practitioner: Practitioner | null;
onSaved?: (row: Practitioner) => void;
}) {
const { t } = useTranslations();
const { create, update } = usePractitionerMutations();
// Seeded once, at mount. The caller varies this component's `key` on every
// open, so a second edit never shows the first one's values and a create
// after an edit starts empty — without an effect that copies props into
// state and briefly renders the stale pair.
const [draft, setDraft] = useState<Draft>(() => draftFrom(practitioner));
const [error, setError] = useState<string | null>(null);
const pending = create.isPending || update.isPending;
const nameGiven = draft.name.trim().length > 0;
const submit = async () => {
if (!nameGiven) return;
const body: PractitionerWriteBody = {
name: draft.name.trim(),
specialty: orNull(draft.specialty),
practice: orNull(draft.practice),
location: orNull(draft.location),
phone: orNull(draft.phone),
note: orNull(draft.note),
};
try {
const saved = practitioner
? await update.mutateAsync({ id: practitioner.id, body })
: await create.mutateAsync(body);
onSaved?.(saved);
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : t("practitioners.saveFailed");
setError(message);
toast.error(message);
}
};
const field = (key: keyof Draft) => ({
value: draft[key],
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => setDraft((prev) => ({ ...prev, [key]: event.target.value })),
});
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(
practitioner ? "practitioners.editTitle" : "practitioners.createTitle",
)}
description={t("practitioners.formDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={!nameGiven || pending}
onClick={() => void submit()}
data-slot="practitioner-save"
className="min-h-11"
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4" data-slot="practitioner-form">
<FieldGroup htmlFor="practitioner-name" label={t("practitioners.name")}>
<Input
id="practitioner-name"
required
aria-required="true"
autoComplete="off"
{...field("name")}
/>
</FieldGroup>
<FieldGroup
htmlFor="practitioner-specialty"
label={t("practitioners.specialty")}
>
<Input id="practitioner-specialty" {...field("specialty")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-practice"
label={t("practitioners.practice")}
>
<Input id="practitioner-practice" {...field("practice")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-location"
label={t("practitioners.location")}
>
<Input id="practitioner-location" {...field("location")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-phone"
label={t("practitioners.phone")}
>
<Input id="practitioner-phone" type="tel" {...field("phone")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-note"
label={t("practitioners.note")}
hint={t("practitioners.noteHint")}
>
<Textarea id="practitioner-note" rows={3} {...field("note")} />
</FieldGroup>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", async () => {
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
// The panel's visit offer reads the encounter list. Pending forever here:
// this file is about the panel's own state, and a resolved read would put
// an offer in the markup every assertion below would have to know about.
useQuery: () => ({ data: undefined, isPending: true, isError: false }),
}));
import { I18nProvider } from "@/lib/i18n/context";
@@ -188,7 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
expect(groupChip(html, "history")).toBe("0/2");
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
expect(groupChip(html, "history")).toBe("0/3");
expect(groupChip(html, "cardio")).toBe("0/14");
expect(groupChip(html, "activity")).toBe("0/8");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
@@ -187,6 +187,7 @@ const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
ecg_new_recording: "dashboard.heroItems.ecg_new_recording",
tension_window: "dashboard.heroItems.tension_window",
same_time_baseline: "dashboard.heroItems.same_time_baseline",
upcoming_visit: "dashboard.heroItems.upcoming_visit",
};
export function DashboardLayoutSection({ id }: { id: string }) {
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The report's reason, pinned on the two properties that make it trustworthy:
*
* - it appears for an appointment inside the horizon and for nothing else;
* - it NEVER auto-applies. Applying is a press, and the component calls
* nothing on render — a window control that moves on its own is what makes
* an export panel untrustworthy.
*
* The fixtures are relative to the moment the test runs, so the horizon cannot
* rot out from under them as the calendar moves.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: {} },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return { ...actual, apiGet: () => new Promise(() => {}) };
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { ReportVisitOffer } from "../report-visit-offer";
const DAY_MS = 24 * 60 * 60 * 1000;
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: new Date(Date.now() + 5 * DAY_MS).toISOString(),
status: "PLANNED",
kind: "SPECIALIST",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...over,
};
}
function render(upcoming: EncounterDTO[], onUse = () => {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(
queryKeys.encounterList(null, null, undefined, undefined),
{
upcoming,
past: [],
},
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<ReportVisitOffer onUseVisitDate={onUse} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ReportVisitOffer>", () => {
it("offers an appointment five days out, naming its own source", () => {
const html = render([visit({ id: "soon" })]);
expect(html).toContain('data-slot="report-visit-offer"');
expect(html).toContain("An appointment is coming up");
expect(html).toContain('data-slot="report-visit-offer-apply"');
});
it("stays silent for an appointment thirty days out", () => {
const html = render([
visit({
id: "far",
occurredAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
}),
]);
expect(html).not.toContain('data-slot="report-visit-offer"');
});
it("stays silent when there is no appointment at all", () => {
expect(render([])).not.toContain('data-slot="report-visit-offer"');
});
it("ignores a cancelled appointment inside the horizon", () => {
const html = render([visit({ id: "off", status: "CANCELLED" })]);
expect(html).not.toContain('data-slot="report-visit-offer"');
});
it("changes the window only when pressed", () => {
const onUse = vi.fn();
const html = render([visit({ id: "soon" })], onUse);
// The offer rendered — so the assertion below is about restraint, not
// about a component that produced nothing.
expect(html).toContain('data-slot="report-visit-offer-apply"');
expect(onUse).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,110 @@
"use client";
/**
* "Bericht für den Termin am … erstellen" — the report knows why it is being
* generated.
*
* The cheapest integration in this milestone and the one that changes the most:
* a person opening the export panel a week before an appointment is almost
* always opening it BECAUSE of the appointment, and the window they want ends
* on that day. One query, one prefilled field, one dismissible offer.
*
* **It never auto-applies.** The window is not silently changed and then left
* for the person to discover; the offer names its own source ("weil am … ein
* Termin ansteht") and does nothing until pressed. A control that moves on its
* own is the thing that makes an export panel untrustworthy.
*
* Nothing about the stored selection changes: this sets the custom-range end
* date and nothing else.
*/
import { CalendarClock, X } from "lucide-react";
import { useState } from "react";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { Button } from "@/components/ui/button";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
/**
* How far ahead a booked visit is treated as the reason for this report.
*
* Two weeks: far enough that somebody preparing in advance is offered it, near
* enough that a routine check-up six months out does not colonise the panel.
*/
export const REPORT_VISIT_HORIZON_DAYS = 14;
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
function localDay(iso: string): string {
const at = new Date(iso);
const offset = at.getTimezoneOffset();
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
export function ReportVisitOffer({
onUseVisitDate,
}: {
/** Sets the custom window's END date. Called only on an explicit press. */
onUseVisitDate: (endDay: string) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const [dismissed, setDismissed] = useState(false);
const visits = useEncounters();
// Read once, at mount. A clock read during render is impure — and here it
// would also mean the offer could vanish mid-interaction as a visit crossed
// the horizon between two renders.
const [now] = useState(() => Date.now());
if (dismissed) return null;
const horizon = now + REPORT_VISIT_HORIZON_DAYS * 24 * 60 * 60 * 1000;
const next = (visits.data?.upcoming ?? []).find((visit) => {
if (visit.status !== "PLANNED") return false;
const at = new Date(visit.occurredAt).getTime();
return at > now && at <= horizon;
});
if (!next) return null;
const what =
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
return (
<div
data-slot="report-visit-offer"
className="border-border flex flex-wrap items-center gap-2 rounded-lg border p-3"
>
{/* Content, not meta: it is the offer, and it names its own source. */}
<p className="text-foreground flex min-w-0 flex-1 items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
<span>
{t("settings.healthRecord.visitOffer", {
what,
when: format.date(next.occurredAt),
})}
</span>
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="report-visit-offer-apply"
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
>
{t("settings.healthRecord.visitOfferApply")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0 sm:size-9"
aria-label={t("common.dismiss")}
onClick={() => setDismissed(true)}
>
<X className="size-4" aria-hidden />
</Button>
</div>
);
}
@@ -44,6 +44,7 @@ import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { ReportVisitOffer } from "./report-visit-offer";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -253,6 +254,15 @@ export function HealthRecordExportPanel() {
</div>
</div>
{/* Never auto-applies: pressing it is what opens the custom window and
sets its end date. */}
<ReportVisitOffer
onUseVisitDate={(endDay) => {
setCustomRange(true);
setEndDate(endDay);
}}
/>
{customRange ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
+138
View File
@@ -0,0 +1,138 @@
"use client";
/**
* Data hooks for visits and the address book behind them.
*
* Every value the surfaces render arrives resolved from the server — the
* practitioner as an object, the links with a label and a date, the
* appointment's next-due instant computed. Nothing here re-derives one. A
* client that recomputed a server-side derivation drifts from it the first
* time the derivation changes on one side only.
*
* Writes reach two roots (`encounterDependentKeys`), because renaming a
* practice changes the label every visit that names it renders.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
encounterDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
import type { EncounterSuggestionResult } from "@/lib/encounters/suggest-window";
export type Encounter = EncounterDTO;
export type EncounterList = EncounterListDTO;
export interface EncounterWriteBody {
occurredAt?: string;
status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
kind?: string;
practitionerId?: string | null;
reason?: string | null;
outcome?: string | null;
/** The checkup this visit closes, when it was filed from a due one. */
reminderId?: string | null;
documentIds?: string[];
labResultIds?: string[];
episodeIds?: string[];
}
const BASE = "/api/encounters";
export function useEncounters(
enabled = true,
window?: {
from?: string;
to?: string;
status?: string;
/** Only visits filed against this condition episode. */
episodeId?: string;
},
) {
const from = window?.from ?? null;
const to = window?.to ?? null;
return useQuery({
queryKey: queryKeys.encounterList(
from,
to,
window?.status,
window?.episodeId,
),
queryFn: () => {
const sp = new URLSearchParams();
if (from) sp.set("from", from);
if (to) sp.set("to", to);
if (window?.status) sp.set("status", window.status);
if (window?.episodeId) sp.set("episodeId", window.episodeId);
const qs = sp.toString();
return apiGet<EncounterList>(qs ? `${BASE}?${qs}` : BASE);
},
enabled,
});
}
/**
* "Does this belong to a visit?" for one anchor date.
*
* The verdict is resolved server-side so the document moment and the two lab
* moments cannot answer the question differently. `anchor` being null means
* the surface has no date yet and there is nothing to ask about.
*/
export function useEncounterSuggestion(anchor: string | null, enabled = true) {
return useQuery({
queryKey: queryKeys.encounterSuggestion(anchor ?? ""),
queryFn: () =>
apiGet<EncounterSuggestionResult>(
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
),
enabled: enabled && Boolean(anchor),
});
}
export function useEncounterMutations() {
const qc = useQueryClient();
/**
* A visit write reaches further than the visit list.
*
* Filing one against a due checkup satisfies that reminder, which moves its
* next-due date on the preventive-care list, on the dashboard card and on the
* Today rail — three surfaces that read the reminder table and would
* otherwise keep showing it due until their own poll. Booking one moves the
* same reads the other way: a new appointment is a row the digest surfaces.
*
* So the fan-out is the visit roots PLUS the reminder reads, through the same
* helper the Vorsorge surfaces use, rather than a second sweep that could
* drift from it.
*/
const invalidate = () =>
Promise.all([
invalidateKeys(qc, encounterDependentKeys),
invalidateReminderReads(qc),
]);
const create = useMutation({
mutationKey: queryKeys.encounterCreate(),
mutationFn: (body: EncounterWriteBody) => apiPost<Encounter>(BASE, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.encounterUpdate(),
mutationFn: ({ id, body }: { id: string; body: EncounterWriteBody }) =>
apiPatch<Encounter>(`${BASE}/${id}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.encounterDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
onSuccess: invalidate,
});
return { create, update, remove };
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
/**
* Data hooks for the account's own address book of doctors and practices.
*
* The search runs in SQL over the two plaintext columns rather than in the
* browser over a downloaded list: the columns are plaintext precisely so the
* picker can search and sort them there, and a client-side filter would need
* the whole book in memory to do it.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
encounterDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type { PractitionerDTO } from "@/lib/practitioners/dto";
export type Practitioner = PractitionerDTO;
export interface PractitionerWriteBody {
name?: string;
specialty?: string | null;
practice?: string | null;
location?: string | null;
phone?: string | null;
note?: string | null;
}
const BASE = "/api/practitioners";
export function usePractitioners(q?: string, enabled = true) {
const term = q?.trim() || undefined;
return useQuery({
queryKey: queryKeys.practitionerList(term),
queryFn: () =>
apiGet<Practitioner[]>(
term ? `${BASE}?q=${encodeURIComponent(term)}` : BASE,
),
enabled,
});
}
export function usePractitionerMutations() {
const qc = useQueryClient();
const invalidate = () => invalidateKeys(qc, encounterDependentKeys);
const create = useMutation({
mutationKey: queryKeys.practitionerCreate(),
mutationFn: (body: PractitionerWriteBody) =>
apiPost<Practitioner>(BASE, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.practitionerUpdate(),
mutationFn: ({ id, body }: { id: string; body: PractitionerWriteBody }) =>
apiPatch<Practitioner>(`${BASE}/${id}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.practitionerDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
onSuccess: invalidate,
});
return { create, update, remove };
}
@@ -0,0 +1,281 @@
/**
* The matrix: what becomes a column, what never does, and what happens to a
* day that is missing something.
*
* The cases that matter are the absences. A day with no context row must not
* contribute a zero to a work-status column, because zero there means "worked
* a regular day and not overtime" and the truth is "nobody said". That
* distinction is invisible in the fitted numbers and would quietly teach the
* model that every unlogged day was a day off.
*/
import { describe, expect, it } from "vitest";
import {
MAX_MODEL_FEATURES,
MIN_FEATURE_COVERAGE,
buildFeatureMatrix,
extractDayFeatures,
screenFeatures,
standardiseDay,
type PrognosisContextInput,
type PrognosisDayInput,
} from "../features";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
const EMPTY_LINKED = {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
};
function context(
overrides: Partial<PrognosisContextInput> = {},
): PrognosisContextInput {
return {
workStatus: null,
workMinutes: null,
overtimeMinutes: null,
workLoad: null,
workSatisfaction: null,
contactCircles: [],
contactForm: null,
contactExtent: null,
contactQuality: null,
contactSupport: null,
leisureCategories: [],
leisureMinutes: null,
leisureJoy: null,
leisureRecovery: null,
eventType: null,
eventValence: null,
...overrides,
};
}
function day(
index: number,
overrides: Partial<PrognosisDayInput> = {},
): PrognosisDayInput {
const date = new Date(Date.UTC(2026, 0, 1 + index));
return {
day: date.toISOString().slice(0, 10),
a1: 5,
dimensions: {},
context: null,
linked: { ...EMPTY_LINKED },
...overrides,
};
}
describe("extractDayFeatures", () => {
it("takes A2 through the dimension table's inverse flag, not inline", () => {
const a2 = MOOD_DIMENSIONS.find((d) => d.key === "a2");
expect(a2?.inverse, "A2 is the inverse dimension this case is about").toBe(
true,
);
const features = extractDayFeatures(
day(0, { dimensions: { a2: 8, a3: 8 } }),
);
// Stress 8 is a heavy day, so on an up-is-better axis it reads as 2.
expect(features.get("dimension:a2")).toBe(2);
// A3 is not inverse and is carried literally.
expect(features.get("dimension:a3")).toBe(8);
});
it("never makes A1 an input", () => {
const features = extractDayFeatures(
day(0, { a1: 7, dimensions: { a1: 7, a2: 3 } }),
);
expect(features.has("dimension:a1")).toBe(false);
});
it("a day with no context contributes no context column at all", () => {
const features = extractDayFeatures(day(0, { context: null }));
expect(
[...features.keys()].filter((k) => k.startsWith("context:")),
).toEqual([]);
});
it("an unanswered field contributes no column; an answered one contributes zeros and a one", () => {
const answered = extractDayFeatures(
day(0, { context: context({ workStatus: "overtime" }) }),
);
expect(answered.get("context:workStatus=overtime")).toBe(1);
expect(answered.get("context:workStatus=off")).toBe(0);
const unanswered = extractDayFeatures(
day(0, { context: context({ workLoad: 4 }) }),
);
expect(unanswered.has("context:workStatus=overtime")).toBe(false);
expect(unanswered.has("context:workStatus=off")).toBe(false);
expect(unanswered.get("context:workLoad")).toBe(4);
});
it("a recorded zero is a value, and absence is absence", () => {
const zero = extractDayFeatures(
day(0, { linked: { ...EMPTY_LINKED, steps: 0 } }),
);
expect(zero.get("linked:steps")).toBe(0);
const absent = extractDayFeatures(day(0));
expect(absent.has("linked:steps")).toBe(false);
});
it("an empty multi-select says nothing rather than saying no to everything", () => {
const features = extractDayFeatures(
day(0, { context: context({ contactCircles: [] }) }),
);
expect(features.has("context:contactCircles=partner")).toBe(false);
});
});
describe("screening", () => {
/** A2 drives A1 exactly; everything else is constant or noise. */
function drivenDays(count: number): PrognosisDayInput[] {
return Array.from({ length: count }, (_, i) => {
const stress = i % 11;
return day(i, {
// Up-is-better A2 is `10 - stress`, and the target follows it.
a1: 10 - stress,
dimensions: { a2: stress },
});
});
}
it("keeps a feature that tracks the target", () => {
const screened = screenFeatures(drivenDays(40));
const a2 = screened.find((s) => s.key === "dimension:a2");
expect(a2?.selected, "a perfectly tracking feature was screened out").toBe(
true,
);
expect(a2?.pairs).toBe(40);
});
it("refuses a feature recorded on too few of the days, before testing it", () => {
const days = drivenDays(40);
// Recorded on a quarter of the days — under the coverage floor whatever
// it correlates with.
const covered = Math.floor(40 * MIN_FEATURE_COVERAGE) - 5;
for (let i = 0; i < covered; i++) {
days[i] = { ...days[i], linked: { ...EMPTY_LINKED, steps: 1000 + i } };
}
const screened = screenFeatures(days);
const steps = screened.find((s) => s.key === "linked:steps");
expect(steps?.selected).toBe(false);
expect(steps?.reason).toBe("coverage");
});
it("refuses a one-hot seen fewer times than the occurrence floor", () => {
const days = drivenDays(40).map((d, i) => ({
...d,
// Everybody worked; two days were overtime. The column is present on
// every day, so coverage passes and the occurrence floor is what
// refuses it.
context: context({ workStatus: i < 2 ? "overtime" : "regular" }),
}));
const screened = screenFeatures(days);
const overtime = screened.find(
(s) => s.key === "context:workStatus=overtime",
);
expect(overtime?.selected).toBe(false);
expect(overtime?.reason).toBe("occurrences");
});
it("refuses pure noise — the whole reason the FDR layer is here", () => {
// A deterministic pseudo-random series, so the case cannot flake.
let seed = 42;
const random = () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
const days = Array.from({ length: 60 }, (_, i) =>
day(i, {
a1: Math.round(random() * 10),
dimensions: { a2: Math.round(random() * 10) },
linked: { ...EMPTY_LINKED, steps: Math.round(random() * 10000) },
}),
);
const selected = screenFeatures(days).filter((s) => s.selected);
expect(selected.map((s) => s.key)).toEqual([]);
});
it("caps the column count", () => {
expect(MAX_MODEL_FEATURES).toBe(12);
});
});
describe("buildFeatureMatrix", () => {
function drivenDays(count: number): PrognosisDayInput[] {
return Array.from({ length: count }, (_, i) =>
day(i, { a1: 10 - (i % 11), dimensions: { a2: i % 11 } }),
);
}
it("standardises per user, so the columns are that person's own units", () => {
const matrix = buildFeatureMatrix(drivenDays(40));
expect(matrix).not.toBeNull();
if (!matrix) return;
const column = matrix.rows.map((row) => row[0]);
const mean = column.reduce((s, v) => s + v, 0) / column.length;
const sd = Math.sqrt(
column.reduce((s, v) => s + (v - mean) * (v - mean), 0) / column.length,
);
expect(mean).toBeCloseTo(0, 6);
expect(sd).toBeCloseTo(1, 6);
// The standardisation is kept so a later day can be scored on the same
// scale rather than re-standardised against itself.
expect(matrix.standardisation[0].key).toBe(matrix.features[0]);
expect(matrix.standardisation[0].sd).toBeGreaterThan(0);
});
it("orders rows oldest first, which is what makes the validation honest", () => {
const matrix = buildFeatureMatrix(drivenDays(40));
expect(matrix).not.toBeNull();
if (!matrix) return;
const sorted = [...matrix.days].sort();
expect(matrix.days).toEqual(sorted);
});
it("drops a day that is missing a selected feature rather than filling it in", () => {
const days = drivenDays(40);
days[5] = { ...days[5], dimensions: {} };
const matrix = buildFeatureMatrix(days);
expect(matrix).not.toBeNull();
if (!matrix) return;
expect(matrix.days).not.toContain(days[5].day);
expect(matrix.rows).toHaveLength(39);
// Nothing was imputed in its place.
expect(matrix.targets).toHaveLength(39);
});
it("refuses when nothing survives the screening", () => {
const flat = Array.from({ length: 40 }, (_, i) => day(i, { a1: 5 }));
expect(buildFeatureMatrix(flat)).toBeNull();
});
it("refuses a constant target instead of dividing by zero", () => {
const constant = Array.from({ length: 40 }, (_, i) =>
day(i, { a1: 5, dimensions: { a2: i % 7 } }),
);
expect(buildFeatureMatrix(constant)).toBeNull();
});
});
describe("standardiseDay", () => {
it("scores a day on the stored scale", () => {
const row = standardiseDay(day(0, { dimensions: { a2: 5 } }), [
{ key: "dimension:a2", mean: 5, sd: 2 },
]);
// Up-is-better A2 of a stress-5 day is 5, the mean, so zero.
expect(row).toEqual([0]);
});
it("refuses a day missing one of the fit's features", () => {
expect(
standardiseDay(day(0, { dimensions: {} }), [
{ key: "dimension:a2", mean: 5, sd: 2 },
]),
).toBeNull();
});
});
@@ -0,0 +1,71 @@
/**
* `computeForecast` — the two floors a fit has to clear, and the refusals.
*
* The first floor is the ladder, on rated days. The second is the one this
* file exists for: a complete-case matrix can shrink well below the rated-day
* count, because a day missing one of the fit's inputs is dropped rather than
* imputed. An account with thirty rated days but a feature recorded on only
* two thirds of them fits on twenty complete rows, and a forecast "built from
* twenty days" past a floor that was supposed to mean thirty is a floor that
* does not hold. The fit is refused below `MIN_ENTRIES_FOR_FORECAST` complete
* rows, not only below thirty rated days.
*/
import { describe, expect, it } from "vitest";
import { computeForecast } from "../forecast";
import type { PrognosisDayInput } from "../features";
import { MIN_ENTRIES_FOR_FORECAST } from "../thresholds";
const EMPTY_LINKED = {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
};
function dayKey(index: number): string {
return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
}
/**
* `rated` days carrying a rating driven by A2, of which the most recent
* `withFeature` actually carry the A2 slider. The rest are older face-only
* days: rated, but with no input the fit can use, so they drop out of the
* complete-case matrix. The feature sits on the RECENT days on purpose, so the
* days the write window would score are scorable — otherwise the refusal is
* "no recent day to score" rather than the "too few complete rows" this file
* is about.
*/
function sparseDays(rated: number, withFeature: number): PrognosisDayInput[] {
return Array.from({ length: rated }, (_, i) => {
const stress = i % 11;
const hasFeature = i >= rated - withFeature;
return {
day: dayKey(i),
a1: Math.round(0.8 * (10 - stress) + 1),
dimensions: hasFeature ? { a2: stress } : {},
context: null,
linked: { ...EMPTY_LINKED },
};
});
}
describe("computeForecast complete-row floor", () => {
it("refuses a fit that clears the rated-day floor but not the complete-row one", () => {
// 40 rated days, A2 on 25 of them: coverage 62.5% clears the candidate
// floor, so A2 is screened in, but the complete-case matrix is 25 rows —
// under the 30-row floor.
const result = computeForecast(sparseDays(40, 25));
expect(result.status, JSON.stringify(result)).toBe("refused");
});
it("writes once the complete rows themselves reach the floor", () => {
// Every rated day carries the feature, so complete rows equal rated days.
const result = computeForecast(sparseDays(40, 40));
expect(result.status).toBe("written");
if (result.status === "written") {
expect(result.n).toBeGreaterThanOrEqual(MIN_ENTRIES_FOR_FORECAST);
}
});
});
@@ -0,0 +1,205 @@
/**
* The minimum-n ladder, at every boundary, from both sides.
*
* Each case names the threshold it is about, so a red here says which floor
* moved rather than "expected false to be true". That matters more than usual:
* the thing these gates prevent is invisible on screen, so the test output is
* the only place the failure is ever legible.
*
* Every one of these has been watched failing — the comparison flipped from
* `>=` to `>`, the red read, the file restored and the hash checked — and one
* check was deleted outright to prove the absence of a gate fails too. The log
* is in the phase's execution notes.
*
* The assertions name the REASON, not just the absence: "no forecast" and "no
* forecast because there are only eleven entries and fifteen is where anything
* starts" are different answers, and only the second one can be shown to
* somebody.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
MIN_ENTRIES_FOR_FORECAST,
MIN_ENTRIES_FOR_REGULAR_FORECAST,
MIN_ENTRIES_FOR_SEASONAL,
MIN_ENTRIES_LEARNING_PHASE,
MIN_TAG_OCCURRENCES,
forecastGate,
occurrencesSuffice,
prognosisStage,
seasonalComparisonsUnlocked,
} from "../thresholds";
import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
describe("the ladder is ordered and its rungs are the documented numbers", () => {
it("holds the values the concept asks for", () => {
expect(MIN_ENTRIES_LEARNING_PHASE).toBe(15);
expect(MIN_ENTRIES_FOR_FORECAST).toBe(30);
expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBe(60);
expect(MIN_ENTRIES_FOR_SEASONAL).toBe(90);
});
it("rises strictly, so no rung can be reached before the one below it", () => {
expect(MIN_ENTRIES_LEARNING_PHASE).toBeLessThan(MIN_ENTRIES_FOR_FORECAST);
expect(MIN_ENTRIES_FOR_FORECAST).toBeLessThan(
MIN_ENTRIES_FOR_REGULAR_FORECAST,
);
expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBeLessThan(
MIN_ENTRIES_FOR_SEASONAL,
);
});
});
describe("MIN_ENTRIES_LEARNING_PHASE — below it, nothing at all", () => {
it(`refuses at ${MIN_ENTRIES_LEARNING_PHASE - 1}, and says why`, () => {
const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE - 1);
expect(gate.present, "MIN_ENTRIES_LEARNING_PHASE let n-1 through").toBe(
false,
);
expect(gate).toEqual({
present: false,
reason: "no-output-yet",
stage: "insufficient",
entries: MIN_ENTRIES_LEARNING_PHASE - 1,
nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
});
});
it(`reaches the learning phase at exactly ${MIN_ENTRIES_LEARNING_PHASE}`, () => {
const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE);
expect(
gate,
"MIN_ENTRIES_LEARNING_PHASE did not admit its own value",
).toEqual({
present: false,
reason: "learning-phase",
stage: "learning",
entries: MIN_ENTRIES_LEARNING_PHASE,
nextThreshold: MIN_ENTRIES_FOR_FORECAST,
});
});
it("an empty account is absence, never a zero", () => {
const gate = forecastGate(0);
expect(gate.present).toBe(false);
expect(gate).not.toHaveProperty("predicted");
if (!gate.present) expect(gate.entries).toBe(0);
});
});
describe("MIN_ENTRIES_FOR_FORECAST — below it, no forecast", () => {
it(`refuses at ${MIN_ENTRIES_FOR_FORECAST - 1}, naming the learning phase`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST - 1);
expect(gate.present, "MIN_ENTRIES_FOR_FORECAST let n-1 through").toBe(
false,
);
if (!gate.present) {
expect(gate.reason).toBe("learning-phase");
expect(gate.nextThreshold).toBe(MIN_ENTRIES_FOR_FORECAST);
expect(gate.entries).toBe(MIN_ENTRIES_FOR_FORECAST - 1);
}
});
it(`forecasts at exactly ${MIN_ENTRIES_FOR_FORECAST}, labelled provisional`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST);
expect(gate.present, "MIN_ENTRIES_FOR_FORECAST refused its own value").toBe(
true,
);
if (gate.present) {
expect(gate.stage).toBe("provisional");
expect(gate.provisional).toBe(true);
}
});
});
describe("MIN_ENTRIES_FOR_REGULAR_FORECAST — below it, provisional", () => {
it(`is still provisional at ${MIN_ENTRIES_FOR_REGULAR_FORECAST - 1}`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST - 1);
expect(gate.present).toBe(true);
if (gate.present) {
expect(
gate.provisional,
"MIN_ENTRIES_FOR_REGULAR_FORECAST dropped the provisional label at n-1",
).toBe(true);
expect(gate.stage).toBe("provisional");
}
});
it(`drops the label at exactly ${MIN_ENTRIES_FOR_REGULAR_FORECAST}`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST);
expect(gate.present).toBe(true);
if (gate.present) {
expect(
gate.provisional,
"MIN_ENTRIES_FOR_REGULAR_FORECAST kept the provisional label at its own value",
).toBe(false);
expect(gate.stage).toBe("regular");
}
});
});
describe("MIN_ENTRIES_FOR_SEASONAL — below it, no weekday or seasonal view", () => {
it(`refuses the comparison at ${MIN_ENTRIES_FOR_SEASONAL - 1}`, () => {
expect(
seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL - 1),
"MIN_ENTRIES_FOR_SEASONAL unlocked the comparison at n-1",
).toBe(false);
expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL - 1)).toBe("regular");
});
it(`unlocks it at exactly ${MIN_ENTRIES_FOR_SEASONAL}`, () => {
expect(
seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL),
"MIN_ENTRIES_FOR_SEASONAL refused its own value",
).toBe(true);
expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL)).toBe("seasonal");
});
});
describe("MIN_TAG_OCCURRENCES — a value seen four times is not evidence", () => {
it(`refuses at ${MIN_TAG_OCCURRENCES - 1}`, () => {
expect(
occurrencesSuffice(MIN_TAG_OCCURRENCES - 1),
"MIN_TAG_OCCURRENCES admitted a value seen n-1 times",
).toBe(false);
});
it(`admits at exactly ${MIN_TAG_OCCURRENCES}`, () => {
expect(
occurrencesSuffice(MIN_TAG_OCCURRENCES),
"MIN_TAG_OCCURRENCES refused its own value",
).toBe(true);
});
it("is the crosstab's floor rather than a second opinion about it", () => {
expect(MIN_TAG_OCCURRENCES).toBe(CONTEXT_MIN_PRESENT_DAYS);
});
});
describe("no second number is minted for an idea that already has one", () => {
const THRESHOLDS = readFileSync(join(__dirname, "../thresholds.ts"), "utf8");
it("the file really is the one under test", () => {
// The positive control: the two checks below pass on an empty read, so
// each needs proof the file was found and holds what it claims to.
expect(THRESHOLDS.length).toBeGreaterThan(1000);
expect(THRESHOLDS).toContain("MIN_ENTRIES_FOR_SEASONAL");
});
it("declares no paired-observation floor of its own", () => {
// `MIN_PAIRED_N` lives in `correlations.ts` and the screening imports it
// from there. A constant here named for the same idea would be a second
// answer to one question, and the two would drift.
expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRED/);
expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRS/);
});
it("takes the occurrence floor from the crosstab rather than restating it", () => {
expect(THRESHOLDS).toMatch(
/MIN_TAG_OCCURRENCES\s*=\s*CONTEXT_MIN_PRESENT_DAYS/,
);
});
});
@@ -0,0 +1,269 @@
/**
* The fit, and the one property that makes its band worth reading.
*
* The leakage case is the reason this file exists. A random k-fold over days
* produces a model that scores beautifully and predicts nothing, and there is
* nothing in the reported error that says so — which is why the assertion is
* on the FOLD INDICES and on the day keys themselves rather than on the error.
* An error-based check passes a leaking split whenever the leak happens to be
* small, which is exactly when nobody would look.
*
* Watched red: the split was replaced with a shuffle, and
* `describe("no training fold contains a day after its validation fold")`
* went red naming a future day inside a training fold. Reverted, hash checked.
*/
import { describe, expect, it } from "vitest";
import { buildFeatureMatrix, type PrognosisDayInput } from "../features";
import { fitRidge, predictWithFit, solveLinearSystem } from "../ridge";
import {
BAND_LOWER_QUANTILE,
BAND_UPPER_QUANTILE,
LAMBDA_GRID,
MIN_VALIDATION_ROWS,
MODEL_VERSION,
fitPrognosisModel,
quantile,
rollingOriginFolds,
} from "../validate";
const EMPTY_LINKED = {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
};
function dayKey(index: number): string {
return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
}
/** A deterministic pseudo-random series — a flaky statistics test is useless. */
function seeded(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
}
/**
* Days whose target is a clean linear function of one dimension.
*
* One driver rather than two, and that is a statement about the screening
* rather than about the fit: the screening is univariate, so a feature that
* only matters jointly with another is refused before the fit ever sees it.
* A two-driver fixture measures that property, not this one.
*/
function linearDays(count: number): PrognosisDayInput[] {
return Array.from({ length: count }, (_, i) => {
const stress = i % 11;
return {
day: dayKey(i),
// 0.8 × up-is-better stress, plus one — inside the 0-10 scale.
a1: 0.8 * (10 - stress) + 1,
dimensions: { a2: stress, a3: (i * 7) % 11 },
context: null,
linked: { ...EMPTY_LINKED },
};
});
}
/** Days whose target is noise and whose inputs are unrelated noise. */
function noisyDays(count: number, seed = 7): PrognosisDayInput[] {
const random = seeded(seed);
return Array.from({ length: count }, (_, i) => ({
day: dayKey(i),
a1: Math.round(random() * 10),
dimensions: {
a2: Math.round(random() * 10),
a3: Math.round(random() * 10),
},
context: null,
linked: { ...EMPTY_LINKED },
}));
}
describe("no training fold contains a day after its validation fold", () => {
const ROWS = 60;
const folds = rollingOriginFolds(ROWS);
it("produces folds at all — the assertions below pass on an empty list", () => {
expect(folds.length).toBeGreaterThan(0);
expect(folds.length).toBeLessThanOrEqual(4);
// Every validation row is covered exactly once and the last fold reaches
// the end, so no day is silently dropped from the measurement.
expect(folds[folds.length - 1].validationEnd).toBe(ROWS);
});
it("trains on a prefix that ends exactly where the validation starts", () => {
for (const fold of folds) {
expect(fold.trainEnd).toBe(fold.validationStart);
expect(fold.trainEnd).toBeGreaterThan(0);
expect(fold.validationEnd).toBeGreaterThan(fold.validationStart);
}
});
it("holds on the day keys, not just on the indices", () => {
// The indices are only meaningful because the matrix is time-ordered, so
// the property is asserted against the dates the rows actually carry.
const matrix = buildFeatureMatrix(linearDays(ROWS));
expect(matrix).not.toBeNull();
if (!matrix) return;
for (const fold of rollingOriginFolds(matrix.rows.length)) {
const trainingDays = matrix.days.slice(0, fold.trainEnd);
const validationDays = matrix.days.slice(
fold.validationStart,
fold.validationEnd,
);
const latestTrainingDay = trainingDays[trainingDays.length - 1];
const earliestValidationDay = validationDays[0];
const leaked = trainingDays.filter((d) => d >= earliestValidationDay);
expect(
leaked,
`a training fold holds ${leaked.length} day(s) dated at or after the validation fold that starts on ${earliestValidationDay}`,
).toEqual([]);
expect(latestTrainingDay < earliestValidationDay).toBe(true);
}
});
it("expands rather than slides — later folds train on more history", () => {
const sizes = folds.map((f) => f.trainEnd);
for (let i = 1; i < sizes.length; i++) {
expect(sizes[i]).toBeGreaterThan(sizes[i - 1]);
}
});
it("refuses to split a window too short to measure anything on", () => {
expect(rollingOriginFolds(MIN_VALIDATION_ROWS - 1)).toEqual([]);
});
});
describe("the ridge solve", () => {
it("recovers the coefficients of a clean linear signal", () => {
// y = 2*x1 - 3*x2, standardised columns, tiny penalty.
const rows: number[][] = [];
const targets: number[] = [];
for (let i = 0; i < 40; i++) {
const x1 = ((i % 7) - 3) / 2;
const x2 = ((i % 5) - 2) / 1.5;
rows.push([x1, x2]);
targets.push(2 * x1 - 3 * x2 + 5);
}
const fit = fitRidge(rows, targets, 1e-6);
expect(fit).not.toBeNull();
if (!fit) return;
expect(fit.coefficients[0]).toBeCloseTo(2, 2);
expect(fit.coefficients[1]).toBeCloseTo(-3, 2);
// The intercept is the mean of the training targets, and the columns are
// centred against their own means, so a row at the column means predicts
// the average day exactly.
expect(predictWithFit(fit, fit.columnMeans)).toBeCloseTo(fit.intercept, 6);
// And every training row is predicted back to its own target.
rows.forEach((row, i) => {
expect(predictWithFit(fit, row)).toBeCloseTo(targets[i], 2);
});
});
it("does not divide by zero on a constant target", () => {
const rows = Array.from({ length: 20 }, (_, i) => [i / 10, (i % 3) / 2]);
const fit = fitRidge(rows, new Array(20).fill(4), 1);
expect(fit).not.toBeNull();
if (!fit) return;
expect(fit.coefficients.every(Number.isFinite)).toBe(true);
expect(fit.intercept).toBe(4);
expect(predictWithFit(fit, [0, 0])).toBeCloseTo(4, 6);
});
it("refuses a singular system rather than answering zeros", () => {
// Two identical columns and no penalty: the system has no unique answer,
// and a vector of zeros would look exactly like "these features do not
// matter".
expect(
solveLinearSystem(
[
[1, 1],
[1, 1],
],
[1, 1],
),
).toBeNull();
});
it("penalises: a larger lambda pulls the coefficients towards zero", () => {
const rows = Array.from({ length: 30 }, (_, i) => [((i % 7) - 3) / 2]);
const targets = rows.map((r) => 4 * r[0] + 5);
const light = fitRidge(rows, targets, 0.01);
const heavy = fitRidge(rows, targets, 100);
expect(light).not.toBeNull();
expect(heavy).not.toBeNull();
if (!light || !heavy) return;
expect(Math.abs(heavy.coefficients[0])).toBeLessThan(
Math.abs(light.coefficients[0]),
);
});
});
describe("the band reacts to how wrong the model is", () => {
it("is narrow on a clean signal and wide on noise", () => {
const clean = buildFeatureMatrix(linearDays(80));
expect(clean).not.toBeNull();
if (!clean) return;
const cleanModel = fitPrognosisModel(clean);
expect(cleanModel).not.toBeNull();
if (!cleanModel) return;
const cleanWidth = cleanModel.bandHigh - cleanModel.bandLow;
// Noise cannot go through the screening — it is refused before a fit is
// attempted, which is the stronger answer. So the band's reaction is
// measured on the matrix directly, with the screening bypassed by handing
// the fit a matrix built from the noisy targets and the clean columns.
const noisy = buildFeatureMatrix(linearDays(80));
expect(noisy).not.toBeNull();
if (!noisy) return;
const random = seeded(11);
const noisyMatrix = {
...noisy,
targets: noisy.targets.map(() => Math.round(random() * 10)),
};
const noisyModel = fitPrognosisModel(noisyMatrix);
expect(noisyModel).not.toBeNull();
if (!noisyModel) return;
const noisyWidth = noisyModel.bandHigh - noisyModel.bandLow;
expect(
noisyWidth,
"the band did not widen on noise — a confident wrong number is the failure this band exists to prevent",
).toBeGreaterThan(cleanWidth * 2);
expect(cleanWidth).toBeLessThan(2);
});
it("refuses noise at the screening, before any fit is attempted", () => {
expect(buildFeatureMatrix(noisyDays(80))).toBeNull();
});
it("takes the band from the validation residuals, at the stated quantiles", () => {
const matrix = buildFeatureMatrix(linearDays(80));
expect(matrix).not.toBeNull();
if (!matrix) return;
const model = fitPrognosisModel(matrix);
expect(model).not.toBeNull();
if (!model) return;
expect(model.bandLow).toBeLessThanOrEqual(model.bandHigh);
expect(BAND_LOWER_QUANTILE).toBe(0.1);
expect(BAND_UPPER_QUANTILE).toBe(0.9);
expect(model.n).toBe(matrix.rows.length);
expect(model.modelVersion).toBe(MODEL_VERSION);
expect(LAMBDA_GRID).toContain(model.fit.lambda);
// Never zero: an unpenalised solve memorises the training days.
expect(Math.min(...LAMBDA_GRID)).toBeGreaterThan(0);
});
it("quantile interpolates rather than rounding to a sample", () => {
expect(quantile([0, 10], 0.5)).toBe(5);
expect(quantile([1, 2, 3, 4], 0)).toBe(1);
expect(quantile([1, 2, 3, 4], 1)).toBe(4);
});
});
@@ -0,0 +1,98 @@
/**
* The names the model's inputs are known by, everywhere.
*
* One string per feature, stored in `MoodPrediction.features`, read back by
* the surface that names what the model weighted, and compared against by the
* tests. It is therefore a wire format in all but name: renaming a key
* silently orphans every stored row that used it, which is why the encoding is
* a function here rather than a template literal at four call sites.
*
* The shape is `<family>:<rest>`, and the four families are the four places an
* input can come from:
*
* * `dimension:a2` — a level-A self-rating (A1 is the target)
* * `context:workLoad` — a numeric field of the day's context
* * `context:workStatus=off` — one value of a closed context vocabulary
* * `linked:steps` — a figure read from the module that owns it
*
* Nothing here resolves a label. The label is a locale lookup and this module
* is pure and bundle-safe, so the resolver lives beside the component that
* renders it and takes a parsed key from here.
*/
import type { MoodDimensionKey } from "@/lib/mood/dimensions";
/** The linked figures the model may read, and the day-figure each names. */
export const LINKED_FEATURE_METRICS = [
"sleepAsleep",
"steps",
"activeEnergy",
"restingHeartRate",
"heartRateVariability",
] as const;
export type LinkedFeatureMetric = (typeof LINKED_FEATURE_METRICS)[number];
export type ParsedFeatureKey =
| { family: "dimension"; dimension: MoodDimensionKey }
| { family: "context"; field: string; value: null }
| { family: "context"; field: string; value: string }
| { family: "linked"; metric: LinkedFeatureMetric };
/** `dimension:a2` */
export function dimensionFeatureKey(dimension: MoodDimensionKey): string {
return `dimension:${dimension}`;
}
/** `context:workLoad` — a numeric field. */
export function contextNumericFeatureKey(field: string): string {
return `context:${field}`;
}
/** `context:workStatus=off` — one value of a closed vocabulary. */
export function contextValueFeatureKey(field: string, value: string): string {
return `context:${field}=${value}`;
}
/** `linked:steps` */
export function linkedFeatureKey(metric: LinkedFeatureMetric): string {
return `linked:${metric}`;
}
const DIMENSION_KEYS = new Set(["a1", "a2", "a3", "a4", "a5"]);
const LINKED_KEYS = new Set<string>(LINKED_FEATURE_METRICS);
/**
* Read a stored key back.
*
* `null` for anything this version does not recognise, and that case is real
* rather than defensive: a row written by an older `modelVersion` can name a
* feature the current build has dropped, and the surface has to skip it rather
* than render `undefined` at somebody.
*/
export function parseFeatureKey(key: string): ParsedFeatureKey | null {
const separator = key.indexOf(":");
if (separator < 0) return null;
const family = key.slice(0, separator);
const rest = key.slice(separator + 1);
if (rest.length === 0) return null;
if (family === "dimension") {
return DIMENSION_KEYS.has(rest)
? { family: "dimension", dimension: rest as MoodDimensionKey }
: null;
}
if (family === "linked") {
return LINKED_KEYS.has(rest)
? { family: "linked", metric: rest as LinkedFeatureMetric }
: null;
}
if (family === "context") {
const equals = rest.indexOf("=");
if (equals < 0) return { family: "context", field: rest, value: null };
const field = rest.slice(0, equals);
const value = rest.slice(equals + 1);
if (field.length === 0 || value.length === 0) return null;
return { family: "context", field, value };
}
return null;
}
@@ -0,0 +1,489 @@
/**
* One row per day, one column per thing the day was made of.
*
* The target is the day's own pleasantness (A1) and everything else is an
* input: the other four level-A dimensions, the numbers and the closed
* vocabularies of the day's context, and the figures the sleep, activity and
* recovery modules already hold for that day. A1 is the only target on
* purpose — five targets would be five ways to be wrong and five bands to
* explain.
*
* Four rules this file is built on, each of which has a way of being got
* wrong that looks fine on screen:
*
* 1. **Nothing is imputed. Ever.** A day with a missing input is not a day
* with an average input. The stated rule is complete cases: a day enters
* the fit only if it carries the target and every SELECTED feature, and a
* feature only becomes a candidate if it was recorded on at least
* {@link MIN_FEATURE_COVERAGE} of the days that carry a target. The
* coverage floor is what keeps the complete-case rule from quietly
* emptying the matrix: without it, one field somebody filled in twice
* would exclude every other day they ever logged.
* 2. **Standardised per user, not min-max.** A person whose stress never
* exceeds six must not have six read as "maximum stress". The mean and the
* spread come from their own fit window, so every feature is expressed in
* that person's own units — which is also what makes the stored
* contributions comparable to each other.
* 3. **Screened before it is fitted, with the multiple-comparison correction
* the rest of the product already runs.** A sweep over sixty context
* features at raw p < 0.05 finds three things that are not there. Pearson
* with the shared `MIN_PAIRED_N` floor, Benjamini-Hochberg at the shared
* `FDR_Q`, and the shared effect-size floor on the shrunk estimate — all
* imported, none re-tuned here.
* 4. **Orientation comes from the table that owns it.** A2 is inverse and is
* flipped through `MOOD_DIMENSIONS[i].inverse`, never inline, so the chart
* and the model read one source. Context ratings are NOT flipped: no table
* states their orientation, and inventing one here would be exactly the
* second opinion the dimension table exists to prevent. It costs nothing
* mathematically — a flip only changes the sign of a coefficient, and the
* stored contribution is signed anyway.
*/
import {
EFFECT_SIZE_FLOOR,
FDR_Q,
benjaminiHochberg,
shrinkEstimate,
} from "@/lib/insights/correlation-discovery";
import {
MAX_P_VALUE,
MIN_PAIRED_N,
pearson,
} from "@/lib/insights/correlations";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import type { MoodDimensionKey } from "@/lib/mood/dimensions";
import {
contextNumericFeatureKey,
contextValueFeatureKey,
dimensionFeatureKey,
linkedFeatureKey,
LINKED_FEATURE_METRICS,
} from "./feature-keys";
import { occurrencesSuffice } from "./thresholds";
/**
* Most features the fit may carry.
*
* Twelve over thirty days is already generous — a ridge penalty is what makes
* that survivable at all — and the cap is what stops a well-logged account
* from being fitted with sixty columns and a hundred rows.
*/
export const MAX_MODEL_FEATURES = 12;
/**
* Share of target-carrying days a feature must be recorded on to be a
* candidate at all.
*
* The other half of the complete-case rule. A field somebody filled in for a
* fortnight and then stopped is not a column of the matrix; it is a fortnight,
* and admitting it would silently discard every day outside that fortnight.
*/
export const MIN_FEATURE_COVERAGE = 0.6;
/** The day's context as the model reads it. The note never comes here. */
export interface PrognosisContextInput {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string[];
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string[];
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
}
/** One day, as everything upstream of the fit has resolved it. */
export interface PrognosisDayInput {
/** Local day key, `YYYY-MM-DD`. */
day: string;
/** The target: the day's own pleasantness, 0-10. `null` days are not fitted. */
a1: number | null;
/** The other four self-ratings, literal as the person set them. */
dimensions: Partial<Record<MoodDimensionKey, number | null>>;
/** The day's stored context, or `null` when no section was opened. */
context: PrognosisContextInput | null;
/** What the owning modules hold for the day. `null` is absence. */
linked: {
sleepAsleep: number | null;
steps: number | null;
activeEnergy: number | null;
restingHeartRate: number | null;
heartRateVariability: number | null;
};
}
/** The numeric context fields, in capture order. */
const CONTEXT_NUMERIC_FIELDS = [
"workMinutes",
"overtimeMinutes",
"workLoad",
"workSatisfaction",
"contactQuality",
"contactSupport",
"leisureMinutes",
"leisureJoy",
"leisureRecovery",
"eventValence",
] as const satisfies readonly (keyof PrognosisContextInput)[];
/** The single-valued vocabularies, each expanded to one column per value. */
const CONTEXT_SINGLE_VOCABULARIES = [
{ field: "workStatus", keys: WORK_STATUS_KEYS },
{ field: "contactForm", keys: CONTACT_FORM_KEYS },
{ field: "contactExtent", keys: CONTACT_EXTENT_KEYS },
{ field: "eventType", keys: EVENT_TYPE_KEYS },
] as const;
/** The multi-select vocabularies, same expansion. */
const CONTEXT_MULTI_VOCABULARIES = [
{ field: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
{ field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
] as const;
function isNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
/**
* Every feature this day carries a value for.
*
* A feature the day says nothing about is ABSENT from the map — it is not
* zero. The difference is the whole of rule 1: a day with no context row has
* no work-status column at all, while a day whose work status is `off` has a
* zero in every other work-status column and a one in that one.
*/
export function extractDayFeatures(
day: PrognosisDayInput,
): Map<string, number> {
const out = new Map<string, number>();
for (const dimension of MOOD_DIMENSIONS) {
// A1 is the target, not an input. Fitting a value on itself would produce
// a perfect model of nothing.
if (dimension.key === "a1") continue;
const raw = day.dimensions[dimension.key];
if (!isNumber(raw)) continue;
// The orientation flip goes through the table that declares it, so a
// dimension that becomes inverse later moves the chart and the model
// together.
out.set(
dimensionFeatureKey(dimension.key),
dimension.inverse ? dimension.max - raw : raw,
);
}
const context = day.context;
if (context) {
for (const field of CONTEXT_NUMERIC_FIELDS) {
const raw = context[field];
if (isNumber(raw)) out.set(contextNumericFeatureKey(field), raw);
}
for (const { field, keys } of CONTEXT_SINGLE_VOCABULARIES) {
const value = context[field];
// A field nobody answered contributes no columns. Zeroing them would
// tell the fit "not overtime" on a day nobody said anything about work.
if (typeof value !== "string" || value.length === 0) continue;
for (const key of keys) {
out.set(contextValueFeatureKey(field, key), value === key ? 1 : 0);
}
}
for (const { field, keys } of CONTEXT_MULTI_VOCABULARIES) {
const values = context[field];
if (!Array.isArray(values) || values.length === 0) continue;
const chosen = new Set(values);
for (const key of keys) {
out.set(contextValueFeatureKey(field, key), chosen.has(key) ? 1 : 0);
}
}
}
for (const metric of LINKED_FEATURE_METRICS) {
const raw = day.linked[metric];
if (isNumber(raw)) out.set(linkedFeatureKey(metric), raw);
}
return out;
}
/** What the screening decided about one candidate, kept for the tests. */
export interface ScreenedFeature {
key: string;
/** Days carrying both the feature and the target. */
pairs: number;
r: number;
/** The shrunk estimate the effect floor is applied to. */
shrunk: number;
pValue: number;
qValue: number;
selected: boolean;
/** Why it was refused, when it was. */
reason?:
| "coverage"
| "too-few-pairs"
| "no-variance"
| "p-value"
| "fdr"
| "effect-size"
| "occurrences"
| "cap";
}
/**
* Which candidates earn a column, and why the others did not.
*
* Order of refusal matters and is the order below: coverage, then pairs, then
* the raw p, then the family-wide FDR, then the effect floor, then the cap.
* A feature that fails an earlier gate is not tested against a later one, so
* the family the FDR corrects over is the set that was actually tested.
*/
export function screenFeatures(
days: readonly PrognosisDayInput[],
): ScreenedFeature[] {
const targetDays = days.filter((d) => isNumber(d.a1));
const perDay = new Map<string, Map<string, number>>();
for (const day of targetDays) perDay.set(day.day, extractDayFeatures(day));
const candidates = new Set<string>();
for (const features of perDay.values()) {
for (const key of features.keys()) candidates.add(key);
}
const coverageFloor = targetDays.length * MIN_FEATURE_COVERAGE;
const refused: ScreenedFeature[] = [];
const tested: Array<{
key: string;
pairs: number;
r: number;
shrunk: number;
pValue: number;
}> = [];
for (const key of [...candidates].sort()) {
const xs: number[] = [];
const ys: number[] = [];
let occurrences = 0;
for (const day of targetDays) {
const value = perDay.get(day.day)?.get(key);
if (value === undefined) continue;
xs.push(value);
ys.push(day.a1 as number);
if (value !== 0) occurrences += 1;
}
if (xs.length < coverageFloor) {
refused.push(zero(key, xs.length, "coverage"));
continue;
}
// A one-hot column seen four times is not evidence, however well it
// correlates. This is the ladder's occurrence floor, not a second one.
if (!occurrencesSuffice(occurrences)) {
refused.push(zero(key, xs.length, "occurrences"));
continue;
}
const result = pearson({ xs, ys, minPairs: MIN_PAIRED_N });
if (result.status === "insufficient") {
refused.push(
zero(
key,
result.n,
result.reason === "no_variance" ? "no-variance" : "too-few-pairs",
),
);
continue;
}
if (result.pValue >= MAX_P_VALUE) {
refused.push({
key,
pairs: result.n,
r: result.r,
shrunk: shrinkEstimate(result.r, result.n),
pValue: result.pValue,
qValue: 1,
selected: false,
reason: "p-value",
});
continue;
}
tested.push({
key,
pairs: result.n,
r: result.r,
shrunk: shrinkEstimate(result.r, result.n),
pValue: result.pValue,
});
}
const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
const survivors: ScreenedFeature[] = [];
tested.forEach((t, index) => {
const qValue = qValues[index] ?? 1;
if (qValue > FDR_Q) {
survivors.push({ ...t, qValue, selected: false, reason: "fdr" });
return;
}
if (Math.abs(t.shrunk) < EFFECT_SIZE_FLOOR) {
survivors.push({ ...t, qValue, selected: false, reason: "effect-size" });
return;
}
survivors.push({ ...t, qValue, selected: true });
});
// The cap keeps the strongest, and a stable tie-break by key keeps two runs
// over the same data returning the same columns.
const admitted = survivors
.filter((s) => s.selected)
.sort(
(a, b) =>
Math.abs(b.shrunk) - Math.abs(a.shrunk) || a.key.localeCompare(b.key),
);
for (const feature of admitted.slice(MAX_MODEL_FEATURES)) {
feature.selected = false;
feature.reason = "cap";
}
return [...survivors, ...refused].sort((a, b) => a.key.localeCompare(b.key));
}
function zero(
key: string,
pairs: number,
reason: ScreenedFeature["reason"],
): ScreenedFeature {
return {
key,
pairs,
r: 0,
shrunk: 0,
pValue: 1,
qValue: 1,
selected: false,
reason,
};
}
/** Per-feature centre and spread, over this account's own fit window. */
export interface FeatureStandardisation {
key: string;
mean: number;
sd: number;
}
export interface FeatureMatrix {
/** The selected feature keys, in column order. */
features: string[];
/** Standardised rows, one per complete day, oldest first. */
rows: number[][];
/** The target for each row, unstandardised (0-10). */
targets: number[];
/** The day each row is, so the validation can stay time-ordered. */
days: string[];
/** What each column was standardised by, needed to score a new day. */
standardisation: FeatureStandardisation[];
}
/**
* Build the matrix: screen, then keep the complete days, then standardise.
*
* Returns `null` when nothing survives the screening or when no day carries
* every surviving feature. That is a refusal, not an empty matrix — the caller
* writes no forecast rather than one built on nothing.
*/
export function buildFeatureMatrix(
days: readonly PrognosisDayInput[],
): FeatureMatrix | null {
const screened = screenFeatures(days);
const selected = screened.filter((s) => s.selected).map((s) => s.key);
if (selected.length === 0) return null;
const ordered = [...days]
.filter((d) => isNumber(d.a1))
.sort((a, b) => a.day.localeCompare(b.day));
const rawRows: number[][] = [];
const targets: number[] = [];
const dayKeys: string[] = [];
for (const day of ordered) {
const features = extractDayFeatures(day);
const row: number[] = [];
let complete = true;
for (const key of selected) {
const value = features.get(key);
if (value === undefined) {
complete = false;
break;
}
row.push(value);
}
// Complete cases only. A day missing one selected feature is a day the fit
// does not see; nothing is filled in for it.
if (!complete) continue;
rawRows.push(row);
targets.push(day.a1 as number);
dayKeys.push(day.day);
}
if (rawRows.length === 0) return null;
const standardisation = selected.map((key, column) => {
const values = rawRows.map((row) => row[column]);
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance =
values.reduce((sum, v) => sum + (v - mean) * (v - mean), 0) /
values.length;
return { key, mean, sd: Math.sqrt(variance) };
});
// A column with no spread inside the complete-case set carries no
// information and would divide by zero. It goes, and if that empties the
// set the whole fit is refused.
const keep = standardisation
.map((s, index) => ({ ...s, index }))
.filter((s) => s.sd > 0);
if (keep.length === 0) return null;
return {
features: keep.map((k) => k.key),
rows: rawRows.map((row) => keep.map((k) => (row[k.index] - k.mean) / k.sd)),
targets,
days: dayKeys,
standardisation: keep.map(({ key, mean, sd }) => ({ key, mean, sd })),
};
}
/**
* Standardise one day's raw features against a stored fit, for scoring.
*
* `null` when the day does not carry every feature the fit uses — the same
* complete-case rule the matrix applies, asked of a single day. A day that
* cannot be scored gets no forecast, which is the honest answer.
*/
export function standardiseDay(
day: PrognosisDayInput,
standardisation: readonly FeatureStandardisation[],
): number[] | null {
const features = extractDayFeatures(day);
const row: number[] = [];
for (const { key, mean, sd } of standardisation) {
const value = features.get(key);
if (value === undefined || sd <= 0) return null;
row.push((value - mean) / sd);
}
return row;
}
@@ -0,0 +1,160 @@
/**
* One account's answer, computed and handed back as rows to write.
*
* Pure: it takes the days somebody logged and returns either the rows a pass
* should store or a refusal with the reason. It does not touch the database,
* and that is a boundary rather than a style — `MoodPrediction` has exactly
* one writer, the refresh job, and a guard holds it there
* (`src/__tests__/mood-prediction-write-surface-guard.test.ts`). Keeping the
* arithmetic here and the write there is what lets the whole decision path be
* tested without a container.
*
* **It refuses more often than it answers, and that is the design.** Too few
* days, no feature that clears the screening, no recent day carrying every
* feature the fit needs, a singular system — every one of them returns a
* refusal and the account is left with no forecast at all. The surface then
* says there is not enough to go on, which is true, instead of showing a
* number built out of three days and a coincidence.
*/
import {
buildFeatureMatrix,
standardiseDay,
type PrognosisDayInput,
} from "./features";
import { PROGNOSIS_WRITE_WINDOW_DAYS } from "./load";
import { featureContributions, predictWithFit } from "./ridge";
import { MIN_ENTRIES_FOR_FORECAST, forecastGate } from "./thresholds";
import { fitPrognosisModel } from "./validate";
/** The lowest and highest value the target scale can express. */
export const TARGET_MIN = 0;
export const TARGET_MAX = 10;
/** One stored contribution: what a feature moved the expected value by. */
export interface FeatureContribution {
feature: string;
contribution: number;
}
/** One day's forecast, in the shape the row is written in. */
export interface ForecastRow {
date: string;
predicted: number;
ciLow: number;
ciHigh: number;
n: number;
modelVersion: string;
/** Ordered by absolute contribution, largest first. */
contributions: FeatureContribution[];
}
/** Why an account gets no forecast. Every one of these is a normal outcome. */
export type ForecastRefusal =
/** Below the ladder's forecast threshold. */
| "too-few-days"
/** Nothing recorded often enough, or varied enough, to screen in. */
| "no-pattern"
/** A pattern, but no recent day carrying every feature it needs. */
| "no-scorable-day";
export type ForecastResult =
| {
status: "written";
rows: ForecastRow[];
/** Complete days the fit used. The `n` every surface must show. */
n: number;
modelVersion: string;
/** How wide the band came out, for the job's event only. */
bandWidth: number;
featureCount: number;
}
| { status: "refused"; reason: ForecastRefusal };
function clampToScale(value: number): number {
return Math.min(TARGET_MAX, Math.max(TARGET_MIN, value));
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/**
* Fit the account's window and score its most recent days.
*
* The ladder is read against the number of DAYS carrying a self-rating, which
* is the number a person can see themselves growing. The `n` stored on each
* row is the number of complete days the fit actually used, which can be
* smaller — a day that is missing one of the fit's inputs cannot be part of
* it. Both numbers are honest and they answer different questions; the one on
* the surface beside the value is `n`, because that is what the value rests on.
*/
export function computeForecast(
days: readonly PrognosisDayInput[],
writeWindowDays: number = PROGNOSIS_WRITE_WINDOW_DAYS,
): ForecastResult {
const rated = days.filter((d) => typeof d.a1 === "number");
const gate = forecastGate(rated.length);
if (!gate.present) return { status: "refused", reason: "too-few-days" };
const matrix = buildFeatureMatrix(days);
const model = matrix === null ? null : fitPrognosisModel(matrix);
if (model === null) return { status: "refused", reason: "no-pattern" };
// The second floor, and the one the rated-day gate above cannot see. A day
// missing one of the selected features is dropped rather than imputed, so a
// sparse-context account can clear thirty rated days and still fit on a
// handful of complete rows. `n` shown beside the value and a wide band on a
// small fit keep it from being dishonest, but a forecast "built from twelve
// days" past a floor that was supposed to mean thirty is a floor that does
// not hold. The number of days the fit ACTUALLY used has to clear the same
// threshold, not only the number of days that carried a rating.
if (model.n < MIN_ENTRIES_FOR_FORECAST) {
return { status: "refused", reason: "no-pattern" };
}
const rows: ForecastRow[] = [];
for (const day of rated.slice(-writeWindowDays)) {
const standardised = standardiseDay(day, model.standardisation);
// A day missing one of the fit's features cannot be scored, and nothing is
// filled in for it. It simply has no forecast.
if (standardised === null) continue;
const raw = predictWithFit(model.fit, standardised);
if (raw === null) continue;
rows.push({
date: day.day,
predicted: round(clampToScale(raw), 2),
// The band travels with the prediction and is clamped to the same scale:
// a lower edge below zero would be a claim the scale cannot express.
ciLow: round(clampToScale(raw + model.bandLow), 2),
ciHigh: round(clampToScale(raw + model.bandHigh), 2),
n: model.n,
modelVersion: model.modelVersion,
contributions: featureContributions(
model.fit,
model.features,
standardised,
)
.filter((c) => Number.isFinite(c.contribution))
.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution))
.map((c) => ({
feature: c.feature,
contribution: round(c.contribution, 3),
})),
});
}
if (rows.length === 0) {
return { status: "refused", reason: "no-scorable-day" };
}
return {
status: "written",
rows,
n: model.n,
modelVersion: model.modelVersion,
bandWidth: round(model.bandHigh - model.bandLow, 2),
featureCount: model.features.length,
};
}
+213
View File
@@ -0,0 +1,213 @@
/**
* The database read behind the fit, and nothing else.
*
* Separate from `features.ts` on purpose. `features.ts` is pure, and it shares
* `feature-keys.ts` with the client component that labels a stored feature key
* (`components/insights/mood/prognosis-feature-label.ts` imports the parser
* from there) — so those two modules form a Prisma-free island the browser
* bundle can reach. A database import in `features.ts` would drag the client
* towards that bundle; keeping the read here is what holds the line. The split
* is the boundary, not a preference.
*
* **One row per day, taken whole from one entry.** A day with two entries
* contributes the later one — its target, its dimensions and its context
* together. Averaging the ratings and taking the context from whichever entry
* happened to carry one would build a row nobody logged, and the model would
* then be fitted on days that did not happen.
*
* The day key is the entry's own `date`, already resolved in the zone the
* entry was written in. Re-deriving it here from `moodLoggedAt` would be a
* second opinion about which day a row belongs to, which is the class of bug
* the per-row `tz` column was added to end.
*/
import { prisma } from "@/lib/db";
import { decodeKeyList } from "@/lib/mood/context-vocabulary";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { resolveLinkedDayFigures } from "@/lib/mood/linked-context";
import type { PrognosisDayInput } from "./features";
/**
* How far back the fit looks.
*
* A year: long enough to hold a seasonal swing, short enough that a life that
* changed two years ago is not still being fitted against today. Longer would
* also make the per-user standardisation describe somebody the person no
* longer is.
*/
export const PROGNOSIS_FIT_WINDOW_DAYS = 365;
/**
* How many recent days the job writes a forecast for.
*
* Not the whole window: a forecast is worth storing for the days somebody
* might still be looking at. Older rows are recomputable and are deleted on
* every pass, which is also what keeps a stale forecast from outliving the
* data it was fitted on.
*/
export const PROGNOSIS_WRITE_WINDOW_DAYS = 7;
/**
* Hard cap on the rows one fit reads.
*
* A year holds 365 days and the fold keeps one entry per day, so the cap only
* ever bites on an account that logs many times a day — and there it bites on
* the oldest days, which is the right end to lose. It is here because an
* unbounded `findMany` in a nightly job over an account with years of history
* is a table scan waiting for the account that has one.
*/
export const MAX_PROGNOSIS_ROWS = 5000;
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
export function dayKeyBefore(now: Date, days: number): string {
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
}
/**
* Every day of the fit window, in day order, with everything the matrix needs.
*
* The linked figures come from the resolver that owns them, over the whole
* window in one query. They are read in the account's display zone: the entry
* rows carry their own zone for their own day key, but a step count has to be
* bucketed under some zone, and the account's own is the one every other
* surface uses.
*/
export async function loadPrognosisDays(
userId: string,
now: Date,
timezone: string | null,
windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS,
): Promise<PrognosisDayInput[]> {
const since = dayKeyBefore(now, windowDays);
const entries = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, date: { gte: since } },
// Descending, and the fold below keeps the FIRST row it sees per day, so
// "the last entry of a day wins" is the read order rather than a
// comparison. Descending also puts the cap on the right end: an account
// that logs many times a day loses its oldest days to the limit, never
// its newest ones, and the newest are the days a forecast is written for.
orderBy: [{ date: "desc" }, { moodLoggedAt: "desc" }],
take: MAX_PROGNOSIS_ROWS,
select: {
date: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
context: {
select: {
workStatus: true,
workMinutes: true,
overtimeMinutes: true,
workLoad: true,
workSatisfaction: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
contactQuality: true,
contactSupport: true,
leisureCategories: true,
leisureMinutes: true,
leisureJoy: true,
leisureRecovery: true,
eventType: true,
eventValence: true,
// The note is deliberately not selected. It is encrypted free text
// about somebody's day and the model has no use for it; a fit does
// not read prose, and a query that decrypted it anyway would be
// reading a diary to compute an average.
},
},
},
});
const byDay = new Map<string, PrognosisDayInput>();
for (const entry of entries) {
// The rows arrive newest first, so the first one seen for a day is that
// day's last entry.
if (byDay.has(entry.date)) continue;
byDay.set(entry.date, {
day: entry.date,
a1: entry.moodA1,
dimensions: {
a2: entry.stressA2,
a3: entry.energyA3,
a4: entry.connectionA4,
a5: entry.stabilityA5,
},
context: entry.context
? {
workStatus: entry.context.workStatus,
workMinutes: entry.context.workMinutes,
overtimeMinutes: entry.context.overtimeMinutes,
workLoad: entry.context.workLoad,
workSatisfaction: entry.context.workSatisfaction,
contactCircles: decodeKeyList(entry.context.contactCircles),
contactForm: entry.context.contactForm,
contactExtent: entry.context.contactExtent,
contactQuality: entry.context.contactQuality,
contactSupport: entry.context.contactSupport,
leisureCategories: decodeKeyList(entry.context.leisureCategories),
leisureMinutes: entry.context.leisureMinutes,
leisureJoy: entry.context.leisureJoy,
leisureRecovery: entry.context.leisureRecovery,
eventType: entry.context.eventType,
eventValence: entry.context.eventValence,
}
: null,
linked: {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
},
});
}
const days = [...byDay.keys()].sort();
if (days.length === 0) return [];
const linked = await resolveLinkedDayFigures(
userId,
days,
timezone ?? DEFAULT_TIMEZONE,
);
for (const day of days) {
const figures = linked.get(day);
if (figures) byDay.get(day)!.linked = figures;
}
return days.map((day) => byDay.get(day)!);
}
/**
* How many days of the window carry a self-rated pleasantness.
*
* The number the ladder is read against, and the number a surface quotes when
* it explains why there is nothing yet. Days, not entries: two entries on one
* Tuesday are one Tuesday, and counting them as two would let somebody reach
* a threshold by logging twice a day for a fortnight.
*/
export async function countQualifyingDays(
userId: string,
now: Date,
windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS,
): Promise<number> {
const since = dayKeyBefore(now, windowDays);
const days = await prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodA1: { not: null },
date: { gte: since },
},
distinct: ["date"],
select: { date: true },
// Bounded by the window either way — a year holds 366 distinct day keys —
// and the cap says so rather than trusting the window to be small.
take: 400,
});
return days.length;
}
+231
View File
@@ -0,0 +1,231 @@
/**
* What the surfaces read: the two values of a day, side by side, never merged.
*
* The self-assessment is the leading value and is returned exactly as it was
* stored. The prognosis is the second reading — computed, banded, counted, and
* not editable. The deviation between them is the finding, and it is measured
* against the BAND rather than against the midpoint: a rating inside the band
* is a day that went as the person's own past days suggest, and only a rating
* outside it is worth a sentence.
*
* One reader for every surface. The route, the Coach snapshot and the daily
* briefing all come through here, so a change to what "the day's forecast"
* means moves all three together instead of two of them.
*
* Absence is explicit and carries its reason and its count. `no-output-yet`
* and `learning-phase` come from the ladder; `no-pattern` is the honest answer
* when there are plenty of days and nothing in them the fit could hold on to.
* None of the three is an error and none of them is a zero.
*/
import { prisma } from "@/lib/db";
import {
dayKeyAgeInDays,
isCurrentForTodayClaim,
} from "@/lib/insights/measurement-freshness";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import type { FeatureContribution } from "./forecast";
import { countQualifyingDays } from "./load";
import {
forecastGate,
seasonalComparisonsUnlocked,
type PrognosisStage,
} from "./thresholds";
/** How the day's own rating sat against the band. */
export type PrognosisDeviation = "within" | "above" | "below";
export interface PrognosisPresent {
present: true;
/** The day the forecast is about, `YYYY-MM-DD`. */
date: string;
/** The expected pleasantness, 0-10. Never shown without `n` and the band. */
predicted: number;
ciLow: number | null;
ciHigh: number | null;
/** Complete days the fit used. Displayed wherever `predicted` is. */
n: number;
modelVersion: string;
computedAt: string;
/**
* Whole days between the forecast's day and the reader's today. A forecast
* about the day before yesterday cannot back a sentence about today, and
* `current` is the same rule every other present-tense claim in the product
* uses — one constant, not a second one.
*/
ageDays: number | null;
current: boolean;
/** Below the regular threshold the forecast is labelled provisional. */
provisional: boolean;
/**
* The rung the value is shown at. Only the three rungs that CARRY a value —
* a below-floor reading never reaches here, it becomes the absence shape —
* so the type matches the published enum exactly and an `insufficient` /
* `learning` stage cannot be constructed for a present reading.
*/
stage: Extract<PrognosisStage, "provisional" | "regular" | "seasonal">;
/** Days of history the ladder was read against. */
entries: number;
/** Whether weekday and seasonal comparisons have unlocked. */
seasonalUnlocked: boolean;
/** The person's own rating for that day. `null` when they gave none. */
selfAssessment: number | null;
/** Where that rating sat against the band. `null` without a rating. */
deviation: PrognosisDeviation | null;
/** How far outside the band it sat, in scale points. `0` when inside. */
deviationAmount: number | null;
/** What the model weighted for that day, largest absolute first. */
contributions: FeatureContribution[];
}
export interface PrognosisAbsent {
present: false;
/**
* `no-output-yet` — too few days for anything at all.
* `learning-phase` — enough to describe, not enough to forecast.
* `no-pattern` — enough days, nothing in them the fit could hold on to.
*/
reason: "no-output-yet" | "learning-phase" | "no-pattern";
/** Days of history carrying a self-rating. */
entries: number;
/** Days at which the next step unlocks, when a count is what is missing. */
nextThreshold: number | null;
}
export type PrognosisReading = PrognosisPresent | PrognosisAbsent;
/** Parse the stored contributions. A malformed value reads as none. */
export function parseContributions(stored: string): FeatureContribution[] {
try {
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is FeatureContribution =>
typeof item === "object" &&
item !== null &&
typeof (item as FeatureContribution).feature === "string" &&
typeof (item as FeatureContribution).contribution === "number",
);
} catch {
return [];
}
}
/** Where a rating sits against a band. */
export function deviationAgainstBand(
actual: number,
low: number | null,
high: number | null,
): { deviation: PrognosisDeviation; amount: number } | null {
if (low === null || high === null) return null;
if (actual > high) return { deviation: "above", amount: actual - high };
if (actual < low) return { deviation: "below", amount: low - actual };
return { deviation: "within", amount: 0 };
}
/**
* The newest forecast an account holds, with the day's own rating beside it.
*
* The job writes a trailing window and the newest row in it is normally
* yesterday's — the pass runs before anybody has logged today. That is why
* the age travels with the value instead of being assumed to be zero.
*/
export async function readMoodPrognosis(
userId: string,
now: Date = new Date(),
timezone?: string | null,
): Promise<PrognosisReading> {
const row = await prisma.moodPrediction.findFirst({
where: { userId },
orderBy: { date: "desc" },
select: {
date: true,
predicted: true,
ciLow: true,
ciHigh: true,
n: true,
modelVersion: true,
features: true,
computedAt: true,
},
});
if (row === null) {
// No row is not automatically "too few days": an account can have a year
// of entries and still get no forecast, because nothing in them cleared
// the screening. The count decides which of the three answers is true.
const entries = await countQualifyingDays(userId, now);
const gate = forecastGate(entries);
if (!gate.present) {
return {
present: false,
reason: gate.reason,
entries,
nextThreshold: gate.nextThreshold,
};
}
return {
present: false,
reason: "no-pattern",
entries,
nextThreshold: null,
};
}
const entries = await countQualifyingDays(userId, now);
// A row on disk is not enough to answer with. The job writes nightly, so
// between an entry deletion and the next pass a row outlives its own
// eligibility. The read path re-reads the ladder and refuses below the
// floor, exactly as the job would — otherwise this route serves a value the
// account no longer has the data to support for up to a day, and it serves
// it under a stage ("insufficient" / "learning") that is not in the
// published enum, which fails a strict client decode of the whole payload.
const gate = forecastGate(entries);
if (!gate.present) {
return {
present: false,
reason: gate.reason,
entries,
nextThreshold: gate.nextThreshold,
};
}
const today = moodDateKey(now, timezone ?? DEFAULT_TIMEZONE);
const ageDays = dayKeyAgeInDays(row.date, today);
const entry = await prisma.moodEntry.findFirst({
where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } },
orderBy: { moodLoggedAt: "desc" },
select: { moodA1: true },
});
const selfAssessment = entry?.moodA1 ?? null;
const against =
selfAssessment === null
? null
: deviationAgainstBand(selfAssessment, row.ciLow, row.ciHigh);
return {
present: true,
date: row.date,
predicted: row.predicted,
ciLow: row.ciLow,
ciHigh: row.ciHigh,
n: row.n,
modelVersion: row.modelVersion,
computedAt: row.computedAt.toISOString(),
ageDays,
current: isCurrentForTodayClaim(ageDays),
// `gate` is the present variant here — the absence arm returned above — so
// the stage is one of provisional / regular / seasonal, never the
// out-of-enum "insufficient" or "learning" a stale row could otherwise
// carry onto the wire.
provisional: gate.provisional,
stage: gate.stage,
entries,
seasonalUnlocked: seasonalComparisonsUnlocked(entries),
selfAssessment,
deviation: against?.deviation ?? null,
deviationAmount: against?.amount ?? null,
contributions: parseContributions(row.features),
};
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Ridge regression, closed form, in about sixty lines of arithmetic.
*
* `(XᵀX + λI)β = Xᵀy`, solved by Gaussian elimination with partial pivoting.
* That is the whole model. It is here rather than in a dependency because a
* dozen standardised columns is a 12×12 system, and adding a package to solve
* one of those would be adding a supply chain to avoid writing a loop.
*
* Two properties the rest of the feature relies on:
*
* * **The intercept is not penalised, because there is no intercept in the
* system.** The target is centred before the solve and the mean is added
* back afterwards, which is the standard treatment: penalising the
* intercept would shrink every prediction towards zero rather than
* towards the person's own average day, and towards zero on a 0-10
* pleasantness scale means "towards the worst day imaginable".
* * **λ is never zero.** With more columns than the ridge penalty can
* afford, an unpenalised solve is a memoriser: it fits the training days
* perfectly and says nothing about any other day. The grid the validation
* searches starts above zero for that reason.
*
* This is arithmetic, not an AI surface. No provider, no consent gate, no
* egress — see the note at the top of the refresh job.
*/
/** A fitted model: one coefficient per standardised column, plus the mean. */
export interface RidgeFit {
/** One per column of the matrix it was fitted on, in the same order. */
coefficients: number[];
/** The mean of the training targets, added back on every prediction. */
intercept: number;
/**
* The mean of each training column, subtracted before every prediction.
*
* Usually zero to floating point, because the matrix arrives z-scored. It is
* carried anyway rather than assumed: centring the target while leaving the
* columns uncentred biases every coefficient, and the bias is small enough
* to look like rounding. Somebody handing this function a raw matrix should
* get the right answer, not a plausible one.
*/
columnMeans: number[];
/** The penalty this fit used. */
lambda: number;
}
/**
* Solve `A x = b` by Gaussian elimination with partial pivoting.
*
* `null` when the system is singular to working precision. The caller treats
* that as "no fit" rather than as a fit of zeros: a singular system means the
* columns are telling the same story twice, and a zero vector would silently
* become a model that predicts the average and calls it a pattern.
*/
export function solveLinearSystem(
matrix: readonly number[][],
vector: readonly number[],
): number[] | null {
const n = vector.length;
if (matrix.length !== n) return null;
// Copy: the caller's XᵀX is reused across the λ grid and elimination is
// destructive.
const a = matrix.map((row, i) => [...row, vector[i]]);
for (let column = 0; column < n; column++) {
let pivot = column;
for (let row = column + 1; row < n; row++) {
if (Math.abs(a[row][column]) > Math.abs(a[pivot][column])) pivot = row;
}
if (Math.abs(a[pivot][column]) < 1e-10) return null;
if (pivot !== column) {
const swap = a[pivot];
a[pivot] = a[column];
a[column] = swap;
}
const pivotValue = a[column][column];
for (let row = column + 1; row < n; row++) {
const factor = a[row][column] / pivotValue;
if (factor === 0) continue;
for (let k = column; k <= n; k++) a[row][k] -= factor * a[column][k];
}
}
const solution = new Array<number>(n).fill(0);
for (let row = n - 1; row >= 0; row--) {
let sum = a[row][n];
for (let k = row + 1; k < n; k++) sum -= a[row][k] * solution[k];
solution[row] = sum / a[row][row];
}
return solution.every((v) => Number.isFinite(v)) ? solution : null;
}
/**
* Fit `rows` against `targets` at one penalty.
*
* `rows` are already standardised per user (see `features.ts`), so the columns
* are comparable and one λ is a defensible penalty across all of them —
* which is not true of raw minutes beside a 0-10 rating.
*/
export function fitRidge(
rows: readonly (readonly number[])[],
targets: readonly number[],
lambda: number,
): RidgeFit | null {
const n = rows.length;
if (n === 0 || targets.length !== n) return null;
const columns = rows[0].length;
if (columns === 0) return null;
if (rows.some((row) => row.length !== columns)) return null;
const intercept = targets.reduce((sum, v) => sum + v, 0) / n;
const centredTargets = targets.map((v) => v - intercept);
const columnMeans = Array.from({ length: columns }, (_, j) => {
let sum = 0;
for (let i = 0; i < n; i++) sum += rows[i][j];
return sum / n;
});
const centredRows = rows.map((row) => row.map((v, j) => v - columnMeans[j]));
const xtx: number[][] = Array.from({ length: columns }, () =>
new Array<number>(columns).fill(0),
);
const xty = new Array<number>(columns).fill(0);
for (let i = 0; i < n; i++) {
const row = centredRows[i];
for (let j = 0; j < columns; j++) {
xty[j] += row[j] * centredTargets[i];
for (let k = j; k < columns; k++) {
const product = row[j] * row[k];
xtx[j][k] += product;
if (k !== j) xtx[k][j] += product;
}
}
}
for (let j = 0; j < columns; j++) xtx[j][j] += lambda;
const coefficients = solveLinearSystem(xtx, xty);
if (coefficients === null) return null;
return { coefficients, intercept, columnMeans, lambda };
}
/** Score one standardised row. */
export function predictWithFit(
fit: RidgeFit,
row: readonly number[],
): number | null {
if (row.length !== fit.coefficients.length) return null;
let sum = fit.intercept;
for (let i = 0; i < row.length; i++) {
sum += fit.coefficients[i] * (row[i] - fit.columnMeans[i]);
}
return Number.isFinite(sum) ? sum : null;
}
/**
* The per-feature contributions of one scored day.
*
* `coefficient × the day's standardised value`, which is what the stored
* `features` column holds and what the explanation line is generated from.
* Signed, so a feature that pulled the expected value down reads as having
* pulled it down, and the parts sum to the distance between the prediction and
* the person's own average day.
*/
export function featureContributions(
fit: RidgeFit,
featureKeys: readonly string[],
row: readonly number[],
): Array<{ feature: string; contribution: number }> {
return featureKeys.map((feature, index) => ({
feature,
contribution:
fit.coefficients[index] * (row[index] - fit.columnMeans[index]),
}));
}
@@ -0,0 +1,181 @@
/**
* How much history a prognosis needs before it is allowed to say anything.
*
* This is the most dangerous file in the feature, and it is dangerous for one
* reason: **the failure mode is silent.** A forecast built from eight days
* renders exactly like a forecast built from eighty. Nobody looking at the
* screen can tell the difference, and the person reading it is being told
* something about their own health record. So every floor is a named constant
* with a boundary test, and every one of those tests has been watched failing
* — a gate nobody has seen go red is a decoration, not a gate.
*
* The ladder has five rungs, and each rung says what the surface may claim:
*
* | entries | stage | what may be shown |
* |---------|---------------|------------------------------------------------|
* | 0-14 | `insufficient`| nothing beyond the self-assessment trend |
* | 15-29 | `learning` | descriptive hints, labelled as a learning phase |
* | 30-59 | `provisional` | a forecast, labelled provisional |
* | 60-89 | `regular` | a forecast without the provisional label |
* | 90+ | `seasonal` | plus weekday and seasonal comparisons |
*
* Absence is `{ present: false }` with a reason and the count, never `0` and
* never an empty forecast object with a null inside it. That shape is a
* contract elsewhere in the product already — the MCP surface means exactly
* "honestly not recorded" by it — and a surface that answered zero here would
* be claiming a bad day where the truth is that nothing can be said yet.
*
* **Two numbers are deliberately NOT minted here.** The paired-observation
* floor for a correlation is `MIN_PAIRED_N` in
* `src/lib/insights/correlations.ts`, and the screening in `features.ts`
* imports it from there; a second constant for the same idea is how two
* surfaces start disagreeing about what counts as enough. The occurrence floor
* below is the crosstab's own floor by assignment rather than by coincidence,
* for the same reason — Phase 2's context comparison says at its declaration
* that this file would import it, and this file does.
*/
import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
/**
* Below this, no output at all — not even a descriptive hint.
*
* Fifteen days of a person's life is not a pattern, and a surface that
* described one would be describing noise back to somebody who would
* reasonably believe it.
*/
export const MIN_ENTRIES_LEARNING_PHASE = 15;
/**
* Below this, descriptive hints only, labelled as a learning phase. At or
* above it, a fitted forecast is allowed.
*/
export const MIN_ENTRIES_FOR_FORECAST = 30;
/** Below this, a forecast is shown but labelled provisional. */
export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60;
/**
* Below this, a weekday or seasonal comparison built on the forecast may not
* be shown.
*
* Two things it does NOT do, both worth stating because the omission is a
* decision rather than an oversight:
*
* * It does not retro-gate the weekday board the mood insights page has
* carried for releases. That board is built from the entry aggregates,
* not from this fit, and it has its own floors; hiding it from accounts
* under ninety days would take a surface away from people who have been
* reading it, to enforce a rule about a different calculation.
* * It does not build a second weekday board. One exists, and a second
* answer to "how do your Mondays go" is exactly the drift this milestone
* spent its effort avoiding.
*
* What it does is gate the `seasonalUnlocked` flag the forecast publishes, so
* any surface that wants to hang a weekday claim off THIS comparison — the
* clients included — asks one place whether the history supports one.
*/
export const MIN_ENTRIES_FOR_SEASONAL = 90;
/**
* How often a tag or a context value must have been recorded before it may
* appear in an explanation at all.
*
* The crosstab's floor, taken rather than re-declared: it is the same
* question ("has this been seen often enough to be worth a sentence") asked
* by a different surface, and two answers to it would drift.
*/
export const MIN_TAG_OCCURRENCES = CONTEXT_MIN_PRESENT_DAYS;
// A per-weekday observation floor was drafted here for a weekday-comparison
// board built on the forecast. That board was not built — the forecast
// publishes `seasonalUnlocked` and leaves the weekday view to the mood
// insights page, which has its own floors — so the constant had no consumer
// and was removed rather than left as a number nothing reads. If a weekday
// comparison on THIS fit is ever built, reintroduce the floor with it.
/** Where an account sits on the ladder. */
export type PrognosisStage =
"insufficient" | "learning" | "provisional" | "regular" | "seasonal";
/**
* Why there is no forecast. The reason travels with the count and with the
* next rung, so a surface can say "eleven of fifteen" instead of an error.
*/
export type PrognosisAbsenceReason = "no-output-yet" | "learning-phase";
export interface PrognosisAbsent {
present: false;
reason: PrognosisAbsenceReason;
stage: Extract<PrognosisStage, "insufficient" | "learning">;
/** Qualifying entries the account has. */
entries: number;
/** Entries at which the next thing becomes available. */
nextThreshold: number;
}
export interface PrognosisAvailable {
present: true;
stage: Extract<PrognosisStage, "provisional" | "regular" | "seasonal">;
entries: number;
/** The forecast carries a provisional label below the regular threshold. */
provisional: boolean;
}
export type PrognosisGate = PrognosisAbsent | PrognosisAvailable;
/**
* Which rung this many qualifying entries reaches.
*
* Every comparison is `>=`, so the constant names the first value that
* qualifies rather than the last that does not. The boundary tests pin both
* sides of each one.
*/
export function prognosisStage(entries: number): PrognosisStage {
if (entries >= MIN_ENTRIES_FOR_SEASONAL) return "seasonal";
if (entries >= MIN_ENTRIES_FOR_REGULAR_FORECAST) return "regular";
if (entries >= MIN_ENTRIES_FOR_FORECAST) return "provisional";
if (entries >= MIN_ENTRIES_LEARNING_PHASE) return "learning";
return "insufficient";
}
/**
* Whether a forecast may be shown for an account with this many qualifying
* entries, and if not, why not and what would change it.
*/
export function forecastGate(entries: number): PrognosisGate {
const stage = prognosisStage(entries);
if (stage === "insufficient") {
return {
present: false,
reason: "no-output-yet",
stage,
entries,
nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
};
}
if (stage === "learning") {
return {
present: false,
reason: "learning-phase",
stage,
entries,
nextThreshold: MIN_ENTRIES_FOR_FORECAST,
};
}
return {
present: true,
stage,
entries,
provisional: stage === "provisional",
};
}
/** Whether weekday and seasonal comparisons have unlocked for this account. */
export function seasonalComparisonsUnlocked(entries: number): boolean {
return entries >= MIN_ENTRIES_FOR_SEASONAL;
}
/** Whether a tag or context value has been recorded often enough to name. */
export function occurrencesSuffice(occurrences: number): boolean {
return occurrences >= MIN_TAG_OCCURRENCES;
}
@@ -0,0 +1,230 @@
/**
* Choosing the penalty, and measuring how wrong the model is, without letting
* it see the future.
*
* **Rolling origin, expanding window. Never a random k-fold.** Days are not
* exchangeable: a Tuesday sits between a Monday and a Wednesday that resemble
* it, and a shuffled split puts Wednesday in the training set and asks the
* model about Tuesday. The result is a model that looks excellent and is
* useless, and nothing about the number it reports says so. So the folds are
* ranges of a time-ordered array, the training range always ends where the
* validation range starts, and `rolling-origin.test.ts` asserts that on the
* fold indices and on the day keys themselves rather than on the resulting
* error — an error-based assertion would pass a leaking split whenever the
* leak happened to be small.
*
* **The band comes from the validation residuals**, not from a textbook
* standard error on the training fit. A training-fit interval on a model with
* twelve columns and thirty rows is narrow by construction and says nothing;
* the spread of the errors the model actually made on days it had not seen is
* the honest width. It widens when the model is bad, which is the property the
* surface depends on to report low confidence instead of a confident wrong
* number.
*
* **What is out-of-sample here, and what is not — stated exactly, because the
* boundary is easy to overclaim.** The RIDGE FIT is honest: within every fold
* the coefficients are solved on the training prefix alone and scored on
* validation rows the fit never saw, and no training fold holds a row dated
* after its validation fold. That is the claim `rolling-origin.test.ts`
* proves, and it is the one that matters for "does λ generalise".
*
* Two steps sit OUTSIDE that guarantee, on purpose:
*
* * **Feature selection.** The screening in `features.ts` runs once over the
* whole window before the split, so the feature set a fold trains on was
* chosen with that fold's validation rows in view. This is the classic
* "screen, then cross-validate" optimism.
* * **Standardisation.** The per-user mean and spread are taken over the
* whole window too, so a validation row is z-scored against statistics its
* own value helped compute.
*
* Both make the reported error slightly better than a fully-nested procedure
* would — measured here at roughly one part in sixty on the mean absolute
* error and a couple of points of coverage, i.e. small enough that the band is
* not dishonest but not zero. Nesting the screening and the standardisation
* inside each fold would remove even that, at the cost of a per-fold feature
* set and a materially larger amount of machinery; the trade was taken the
* cheap way deliberately, and this comment is the price of taking it — a
* reader must not read "no fold sees ahead" as covering the SELECTION, only
* the FIT. If the feature set ever grows past a handful of strong columns, or
* the optimism is ever measured larger, nest it.
*/
import { fitRidge, predictWithFit, type RidgeFit } from "./ridge";
import type { FeatureMatrix } from "./features";
/**
* The stored model identity. Bump it whenever the feature set, the screening
* or the fit changes, so a re-fit is legible in the rows rather than silently
* changing what they mean. Rows carrying an older version stay readable; the
* surface skips a feature key the current build no longer knows.
*/
export const MODEL_VERSION = "mood-ridge-1";
/**
* The penalties the validation searches.
*
* Never zero: with a dozen standardised columns over a few dozen days, an
* unpenalised solve memorises the training days and describes no other day.
* The grid is coarse on purpose — the difference between λ=8 and λ=10 is far
* below the noise in thirty days of self-ratings, and a fine grid would only
* be a slower way to overfit the validation set.
*/
export const LAMBDA_GRID = [0.5, 1, 2, 5, 10, 25, 50] as const;
/** How many validation blocks the window is cut into. */
export const VALIDATION_FOLDS = 4;
/**
* The band is the central 80% of the validation residuals: the 10th and the
* 90th percentile. A range wide enough to be honest and narrow enough to be
* worth reading — and empirical, so an asymmetric error stays asymmetric
* instead of being averaged into a symmetric claim.
*/
export const BAND_LOWER_QUANTILE = 0.1;
export const BAND_UPPER_QUANTILE = 0.9;
/** Fewest rows a fold split is attempted on at all. */
export const MIN_VALIDATION_ROWS = 10;
/** One expanding-window split. Every index is into the time-ordered matrix. */
export interface RollingOriginFold {
/** Training rows are `[0, trainEnd)`. */
trainEnd: number;
/** Validation rows are `[validationStart, validationEnd)`. */
validationStart: number;
validationEnd: number;
}
/**
* Cut a time-ordered window into expanding-window folds.
*
* `trainEnd === validationStart` in every fold, which is the whole invariant:
* the training range is a prefix and the validation range starts exactly where
* it ends. No gap (a gap would waste days), no overlap (an overlap is the
* leak).
*/
export function rollingOriginFolds(
rowCount: number,
folds: number = VALIDATION_FOLDS,
): RollingOriginFold[] {
if (rowCount < MIN_VALIDATION_ROWS || folds < 1) return [];
const block = Math.floor(rowCount / (folds + 1));
if (block < 1) return [];
const out: RollingOriginFold[] = [];
for (let index = 1; index <= folds; index++) {
const validationStart = block * index;
const validationEnd =
index === folds ? rowCount : Math.min(rowCount, block * (index + 1));
if (validationEnd <= validationStart) continue;
out.push({ trainEnd: validationStart, validationStart, validationEnd });
}
return out;
}
/** Linear-interpolated quantile of an unsorted sample. */
export function quantile(values: readonly number[], q: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 1) return sorted[0];
const position = (sorted.length - 1) * Math.min(1, Math.max(0, q));
const lower = Math.floor(position);
const upper = Math.ceil(position);
if (lower === upper) return sorted[lower];
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}
/** What one penalty scored across every fold. */
export interface LambdaScore {
lambda: number;
/** Residuals (`actual − predicted`) from validation rows only. */
residuals: number[];
meanAbsoluteError: number;
}
/**
* Score one penalty over the folds. Training folds never contain a row dated
* after their validation fold, by construction of {@link rollingOriginFolds}.
*/
export function scoreLambda(
matrix: FeatureMatrix,
lambda: number,
folds: readonly RollingOriginFold[],
): LambdaScore | null {
const residuals: number[] = [];
for (const fold of folds) {
const fit = fitRidge(
matrix.rows.slice(0, fold.trainEnd),
matrix.targets.slice(0, fold.trainEnd),
lambda,
);
if (fit === null) continue;
for (let i = fold.validationStart; i < fold.validationEnd; i++) {
const predicted = predictWithFit(fit, matrix.rows[i]);
if (predicted === null) continue;
residuals.push(matrix.targets[i] - predicted);
}
}
if (residuals.length === 0) return null;
return {
lambda,
residuals,
meanAbsoluteError:
residuals.reduce((sum, r) => sum + Math.abs(r), 0) / residuals.length,
};
}
/** A model, ready to score a day, with the band its own errors earned it. */
export interface PrognosisModel {
fit: RidgeFit;
features: string[];
standardisation: FeatureMatrix["standardisation"];
/** Rows the final fit used. This is the `n` every surface must show. */
n: number;
/** Offsets from a prediction to the edges of the band, in target units. */
bandLow: number;
bandHigh: number;
/** Mean absolute error over the validation folds, for the record. */
meanAbsoluteError: number;
modelVersion: string;
}
/**
* Validate, choose λ, refit on the whole window, and take the band from the
* validation residuals.
*
* `null` is a refusal and is a normal outcome: too few complete days to split,
* a singular system, or a fold set that produced no residual at all. The
* caller writes no forecast rather than a forecast nobody measured.
*/
export function fitPrognosisModel(
matrix: FeatureMatrix,
lambdaGrid: readonly number[] = LAMBDA_GRID,
): PrognosisModel | null {
const folds = rollingOriginFolds(matrix.rows.length);
if (folds.length === 0) return null;
let best: LambdaScore | null = null;
for (const lambda of lambdaGrid) {
const score = scoreLambda(matrix, lambda, folds);
if (score === null) continue;
if (best === null || score.meanAbsoluteError < best.meanAbsoluteError) {
best = score;
}
}
if (best === null) return null;
const fit = fitRidge(matrix.rows, matrix.targets, best.lambda);
if (fit === null) return null;
return {
fit,
features: matrix.features,
standardisation: matrix.standardisation,
n: matrix.rows.length,
bandLow: quantile(best.residuals, BAND_LOWER_QUANTILE),
bandHigh: quantile(best.residuals, BAND_UPPER_QUANTILE),
meanAbsoluteError: best.meanAbsoluteError,
modelVersion: MODEL_VERSION,
};
}
@@ -172,6 +172,7 @@ const fakeDb = vi.hoisted(() => {
ecgRecording: { findFirst: async () => null },
personalRecord: { findMany: none },
arrivalReaction: { findMany: none },
encounter: { findFirst: async () => null },
};
});
@@ -20,6 +20,7 @@ vi.mock("@/lib/db", () => ({
dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) },
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findFirst: vi.fn().mockResolvedValue(null) },
},
}));
+65
View File
@@ -126,6 +126,21 @@ export interface DailyDigestPreventiveDue {
label: string;
}
/**
* A booked visit falling today or tomorrow.
*
* Read off the VISIT table, never off the reminder engine: an appointment's
* one-shot reminder is excluded from every preventive-care read, and this is
* how the appointment reaches the rail without relaxing that exclusion. The
* kind rides as the enum constant so the reader's own bundle names it.
*/
export interface DailyDigestUpcomingVisit {
id: string;
kind: string;
occurredAt: string;
practitionerName: string | null;
}
/**
* S11 — a confident elevated-at-rest ("tension") window for the day, already
* detected server-side (`loadIntradayPulse`) under its full confidence gate.
@@ -251,6 +266,8 @@ export interface DailyDigestInput {
morningRefreshedToday: boolean;
syncIssues: DailyDigestSyncIssue[];
preventiveDue: DailyDigestPreventiveDue[];
/** The booked visit falling today or tomorrow, or null. Honest-absent. */
upcomingVisit?: DailyDigestUpcomingVisit | null;
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
coachPlans: DailyDigestCoachPlan[];
/**
@@ -504,6 +521,44 @@ function buildPreventiveCareItem(
};
}
/**
* The booked visit falling today or tomorrow, as one rail line.
*
* No module gate: a visit is core, like the checkups page it lives on. No
* action beyond opening the page either — there is nothing to do about an
* appointment except keep it, and a "mark done" here would duplicate the
* visit's own status field with a second place to set it.
*/
function buildUpcomingVisitItem(
visit: DailyDigestUpcomingVisit | null,
now: Date,
t: Translate,
): PriorityItem | null {
if (!visit) return null;
const at = new Date(visit.occurredAt);
if (Number.isNaN(at.getTime())) return null;
// Today vs tomorrow is decided on the SAME instants the read window used, so
// a visit admitted by the query can never be described as neither.
const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000);
const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`);
return {
kind: "upcoming_visit",
title: t("daily.item.upcomingVisit.title"),
body:
hoursAway <= 24
? t("daily.item.upcomingVisit.bodyToday", { what })
: t("daily.item.upcomingVisit.bodyTomorrow", { what }),
status: "info",
actions: [
{
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups",
},
],
};
}
/**
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
* state was reached TODAY (the IO seam already applied the reached-once gate).
@@ -880,6 +935,16 @@ export function buildDailyDigest(
if (ecg) worthALook.push(ecg);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
// Beside the checkups rather than among them: the two are neighbours on the
// rail because a person reads "what am I meant to arrange" and "where am I
// meant to be" in one glance, and they stay separate items because one is
// overdue and the other is simply scheduled.
const visit = buildUpcomingVisitItem(
input.upcomingVisit ?? null,
input.now,
t,
);
if (visit) worthALook.push(visit);
// S11 — the calm, informational tension marker sits last: it is context, not
// an action that expires, so a time-sensitive dose / sync / check-in wins the
// bounded rail ahead of it.
+57
View File
@@ -32,6 +32,7 @@ import {
type DailyDigestCoachPlan,
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestUpcomingVisit,
type DailyDigestScore,
type DailyDigestSameTime,
type DailyDigestSyncIssue,
@@ -63,6 +64,16 @@ import {
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
/**
* How far ahead the rail mentions a booked appointment: today or tomorrow.
*
* The rail is a "what about today" surface, so anything further out is not a
* thing to act on this morning. Two days rather than one because an evening
* reader wants tomorrow's 08:00 appointment, and a 24-hour window would hide it
* from them.
*/
const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
/** Defensive cap on how many overdue reminders we read for the rail summary. */
const PREVENTIVE_DUE_READ_LIMIT = 20;
@@ -355,6 +366,7 @@ export async function loadDailyDigest(
planRows,
ecgRow,
arrivalRows,
nextVisitRow,
] = await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
@@ -428,6 +440,27 @@ export async function loadDailyDigest(
generatedAt: true,
},
}),
// The next PLANNED visit inside the two-day horizon. Read straight off the
// visit table, never off the reminder engine — see the mapping below for
// why the exclusion above must stay untouched.
prisma.encounter.findFirst({
where: {
userId: user.id,
deletedAt: null,
status: "PLANNED",
occurredAt: {
gt: now,
lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS),
},
},
orderBy: { occurredAt: "asc" },
select: {
id: true,
kind: true,
occurredAt: true,
practitioner: { select: { name: true } },
},
}),
]);
const score: DailyDigestScore | null = snapshot.healthScore
@@ -463,6 +496,29 @@ export async function loadDailyDigest(
label: row.label,
}));
/**
* The appointment falling today or tomorrow, read from the VISIT table.
*
* Not from the reminder table. An ENCOUNTER-origin reminder is excluded from
* every preventive-care read, which is right — an appointment is not a
* checkup — but a notification about something the app never shows is worse
* than none. So the appointment reaches the rail from the record it lives
* in, and the exclusion above stays exactly as it is. A diff here that
* touches `origin` is the wrong fix.
*
* `occurredAt > now` deliberately, not `>= todayStart`: an appointment that
* has already happened today is not something to be reminded about, and the
* person is more likely to be at it than looking at this.
*/
const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow
? {
id: nextVisitRow.id,
kind: nextVisitRow.kind,
occurredAt: nextVisitRow.occurredAt.toISOString(),
practitionerName: nextVisitRow.practitioner?.name ?? null,
}
: null;
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
// builder decides "new" and gates on the `insights` module. An ECG row only
// ever carries an AFib-screening verdict; the shared enum's other members
@@ -563,6 +619,7 @@ export async function loadDailyDigest(
morningRefreshedToday,
syncIssues,
preventiveDue,
upcomingVisit,
coachPlans,
milestone,
tensionWindow,
+2 -1
View File
@@ -35,6 +35,7 @@ export const PRIORITY_ITEM_KINDS = [
"ecg_new_recording",
"tension_window",
"same_time_baseline",
"upcoming_visit",
] as const;
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
@@ -52,7 +53,7 @@ export function isPriorityItemKind(value: unknown): value is PriorityItemKind {
* action; the user can only
* acknowledge them, so they're the only kinds the Today rail lets you
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
* `preventive_care`, `coach_checkin`, `upcoming_visit`) are ACTIONABLE — they clear on their
* own once the user acts (logs the dose, reconnects the integration, …) and
* are never dismissible; offering a dismiss on them would just let a user
* silence a still-open action item.
+6
View File
@@ -75,6 +75,12 @@ export const WIPE_MODELS = [
// but a parent deleted first leaves the child's own count reporting zero for
// rows that existed, and the confirmation screen quotes those counts.
"MoodContext",
// Derived, and wiped anyway. The forecast rows are recomputable from the
// entries above — which is why they stay out of the backup — but they are
// still a day-by-day statement about somebody's mood, and leaving them
// behind after a screen that promised to delete all health data would leave
// the shape of the record standing where the record used to be.
"MoodPrediction",
"MoodEntry",
"MoodEntryRollup",
"MoodReminderDispatch",
@@ -217,6 +217,73 @@ export function buildClinicalRecordsNotesSection(
.finalY + 8;
}
// Visits inside the window — what happened at a practice, and what came out
// of it. Present only when the VISITS leaf was selected AND the window held a
// visit (the aggregator gates `data.visits`); the leaf carries no module of
// its own, because a visit is core and the whole report is already gated by
// `doctorReport`. Same calm factual register as the illness table above: no
// colour, no status tint. The encrypted reason and outcome decrypt at render
// and never enter the stored selection.
if (data.visits && data.visits.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.visitsTitle"), margin, y);
y += 6;
const visitRows = data.visits.map((visit) => [
fmtDate(visit.occurredAt),
// The practice, with its specialty when there is one; an em dash when
// the visit named neither, which is honest rather than invented.
[visit.practitionerName, visit.practitionerSpecialty]
.filter(Boolean)
.join(" · ") || "—",
t(`encounters.kind.${visit.kind}`),
visit.reason ?? "—",
visit.outcome ?? "—",
visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—",
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.visitsColDate"),
t("doctorReport.visitsColPractice"),
t("doctorReport.visitsColKind"),
t("doctorReport.visitsColReason"),
t("doctorReport.visitsColOutcome"),
t("doctorReport.visitsColConditions"),
],
],
body: visitRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
// v1.27.x — structured allergy / intolerance records. Reference data
// (not time-windowed) the aggregator populates when the `allergies`
// toggle is ON (default) and rows exist. Stored fields only — substance,
+22
View File
@@ -274,6 +274,28 @@ export interface DoctorReportData {
* module is off or no episode overlaps the window; both builders then skip
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
*/
/**
* Visits inside the window — the contacts with the healthcare system this
* period held. Null when the leaf was not selected or none fell inside the
* window; both builders then skip the section. Optional so pre-v1.38
* fixtures still typecheck.
*
* `kind` rides as the enum constant: the renderer names it in the REPORT's
* language, which is the reading clinician's, not whoever generated it.
* `reason` and `outcome` are the decrypted free text and are null on a
* key-rotation gap — an absence rather than a fabricated line.
*/
visits?: Array<{
occurredAt: string;
kind: string;
status: string;
practitionerName: string | null;
practitionerSpecialty: string | null;
reason: string | null;
outcome: string | null;
/** Labels of the conditions this visit was filed against. */
conditionLabels: string[];
}> | null;
illnessEpisodes?: Array<{
/** User-facing condition label (e.g. "Erkältung"). */
label: string;
@@ -216,6 +216,17 @@ function seed() {
resolvedAt: new Date("2026-01-20T00:00:00.000Z"),
},
];
rows.encounter = [
{
occurredAt: new Date("2026-02-05T09:00:00.000Z"),
kind: "SPECIALIST",
status: "DONE",
reasonEncrypted: null,
outcomeEncrypted: null,
practitioner: { name: "Sample practice", specialty: "Cardiology" },
conditionLinks: [],
},
];
rows.allergy = [
{
substance: "Penicillin",
@@ -262,6 +273,7 @@ describe("per-leaf gating sweep", () => {
expect(allPayload.allergies).not.toBeNull();
expect(allPayload.familyHistory).not.toBeNull();
expect(allPayload.illnessEpisodes).not.toBeNull();
expect(allPayload.visits).not.toBeNull();
expect(allPayload.mood).not.toBeNull();
expect(allPayload.cycle).not.toBeNull();
expect(allPayload.anamnesis).not.toBeNull();
@@ -415,6 +427,7 @@ describe("zero read for unchosen leaves", () => {
["ALLERGIES", "allergy"],
["LAB_RESULTS", "labResult"],
["ILLNESS_EPISODES", "illnessEpisode"],
["VISITS", "encounter"],
["ANAMNESIS", "userHealthProfile"],
["ANAMNESIS", "healthProfileFactRevision"],
];
@@ -474,6 +487,7 @@ describe("the empty selection serves nothing", () => {
expect(payload.allergies).toBeNull();
expect(payload.familyHistory).toBeNull();
expect(payload.illnessEpisodes).toBeNull();
expect(payload.visits).toBeNull();
expect(payload.anamnesis).toBeNull();
expect(payload.cycle).toBeNull();
expect(payload.mood).toBeNull();
+72
View File
@@ -122,6 +122,78 @@ export async function loadIllnessEpisodes(
return mapped.length > 0 ? mapped : null;
}
/**
* Visits inside the window, with the conditions each was filed against.
*
* The two free-text columns are `Bytes` ciphertext and decrypt fail-soft, the
* same stance the allergy reaction takes: a key-rotation gap on one row reads
* as a visit with no reason recorded rather than as a failed report. `kind` and
* `status` cross as enum constants — the renderer names them in the REPORT's
* language, which is the reading clinician's.
*
* The condition labels come off the link rows directly rather than through the
* link service: this is a read of the OWNER's own record for the owner's own
* report, with no grant to narrow against and no redaction to apply, and the
* service's per-family visibility predicate has no meaning here.
*/
export async function loadVisits(
userId: string,
start: Date,
end: Date,
): Promise<DoctorReportData["visits"]> {
const rows = await prisma.encounter.findMany({
where: {
userId,
deletedAt: null,
occurredAt: { gte: start, lte: end },
},
orderBy: { occurredAt: "asc" },
select: {
occurredAt: true,
kind: true,
status: true,
reasonEncrypted: true,
outcomeEncrypted: true,
practitioner: { select: { name: true, specialty: true } },
conditionLinks: {
select: { episode: { select: { label: true, deletedAt: true } } },
},
},
});
const decrypt = (
value: Uint8Array | null,
field: "reason" | "outcome",
): string | null => {
if (!value || value.byteLength === 0) return null;
try {
return decryptFromBytes(value);
} catch {
getEvent()?.addWarning(
`doctor-report: visit ${field} decrypt failed for ${userId}`,
);
return null;
}
};
const mapped = rows.map((row) => ({
occurredAt: row.occurredAt.toISOString(),
kind: row.kind,
status: row.status,
practitionerName: row.practitioner?.name ?? null,
practitionerSpecialty: row.practitioner?.specialty ?? null,
reason: decrypt(row.reasonEncrypted, "reason"),
outcome: decrypt(row.outcomeEncrypted, "outcome"),
// A condition the person has since deleted is not named in a clinical
// document: the link row survives the soft delete, and printing a label
// they removed would put a withdrawn diagnosis in front of a doctor.
conditionLabels: row.conditionLinks
.filter((link) => link.episode?.deletedAt === null)
.map((link) => link.episode.label),
}));
return mapped.length > 0 ? mapped : null;
}
/**
* Structured allergy records. Reference data, not time-windowed — a
* penicillin allergy does not expire with the report window.
+6
View File
@@ -64,6 +64,7 @@ import {
loadAnamnesis,
loadFamilyHistory,
loadIllnessEpisodes,
loadVisits,
loadLabResults,
} from "./clinical-records";
@@ -498,6 +499,7 @@ export async function collectDoctorReportData(
cycle,
labResults,
illnessEpisodes,
visits,
allergies,
familyHistory,
anamnesis,
@@ -511,6 +513,9 @@ export async function collectDoctorReportData(
gate.admits("ILLNESS_EPISODES")
? loadIllnessEpisodes(userId, start, end)
: Promise.resolve(null),
gate.admits("VISITS")
? loadVisits(userId, start, end)
: Promise.resolve(null),
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
gate.admits("FAMILY_HISTORY")
? loadFamilyHistory(userId)
@@ -579,6 +584,7 @@ export async function collectDoctorReportData(
cycle,
labResults,
illnessEpisodes,
visits,
allergies,
familyHistory,
anamnesis,
+80 -1
View File
@@ -8,7 +8,10 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { listTargetsBySource, replaceTargets } from "@/lib/links";
import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
} from "@/lib/validations/inbound-documents";
/**
* Load the condition links for a page of documents in ONE grouped query.
@@ -39,6 +42,82 @@ export async function loadConditionLinks(
return map;
}
/**
* Load the VISIT links for a page of documents in ONE grouped query.
*
* Unlike the condition chips this filters tombstoned visits out: the condition
* behaviour is a long-standing one this refactor deliberately did not change,
* while a visit link is new and starts from the honest default — a deleted
* visit is not something the document still belongs to.
*/
export async function loadDocumentEncounterLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentEncounterLinkDto[]>> {
const map = new Map<string, DocumentEncounterLinkDto[]>();
if (documentIds.length === 0) return map;
const byDocument = await listTargetsBySource(prisma, {
userId,
sourceKind: "document",
sourceIds: documentIds,
targetKind: "encounter",
});
for (const [documentId, targets] of byDocument) {
map.set(
documentId,
targets.map((target) => ({
encounterId: target.id,
kind: target.label,
occurredAt: target.date,
})),
);
}
return map;
}
/**
* Narrow a client-sent visit-id list to the caller's LIVE visits.
*
* Same shape and same refusal as {@link narrowOwnedEpisodeIds}: null when any
* id names nothing, so an attacker probing another account's visit ids learns
* only "not found".
*/
export async function narrowOwnedEncounterIds(
userId: string,
encounterIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(encounterIds)];
if (unique.length === 0) return [];
const owned = await prisma.encounter.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 visit links inside the given client.
*
* Goes through the link service in the `document → encounter` direction, which
* is the same table the visit's own sheet writes from the other end, so the two
* surfaces cannot disagree about what is filed where.
*/
export async function replaceEncounterLinks(
tx: Prisma.TransactionClient,
userId: string,
documentId: string,
encounterIds: string[],
): Promise<void> {
await replaceTargets(tx, {
userId,
sourceKind: "document",
sourceId: documentId,
targetKind: "encounter",
targetIds: encounterIds,
});
}
/**
* Narrow a client-sent episode-id list to the caller's LIVE episodes.
* Returns the deduplicated owned ids, or null when any id is unknown /
+5
View File
@@ -19,6 +19,7 @@ import type {
import { servingClassFor } from "@/lib/documents/upload-policy";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
DocumentContentIndexSourceValue,
DocumentSummaryStateValue,
ExtractedFactDto,
@@ -199,6 +200,7 @@ export function serialiseDocument(
hasContentIndex = false,
contentIndexSource: DocumentContentIndexSourceValue | null = null,
hasThumbnail = false,
encounterLinks: DocumentEncounterLinkDto[] = [],
): InboundDocumentDto {
return {
id: doc.id,
@@ -219,6 +221,7 @@ export function serialiseDocument(
factCount: counts.factCount,
pendingCount: counts.pendingCount,
conditionLinks,
encounterLinks,
servingClass: servingClassFor(doc.mimeType),
hasContentIndex,
contentIndexSource: hasContentIndex ? contentIndexSource : null,
@@ -251,6 +254,7 @@ export function serialiseDocumentDetail(
hasContentIndex = false,
contentIndexSource: DocumentContentIndexSourceValue | null = null,
hasThumbnail = false,
encounterLinks: DocumentEncounterLinkDto[] = [],
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
@@ -286,6 +290,7 @@ export function serialiseDocumentDetail(
hasContentIndex,
contentIndexSource,
hasThumbnail,
encounterLinks,
),
facts: facts.map(serialiseFact),
summary,
@@ -0,0 +1,244 @@
/**
* The suggestion rule, pinned.
*
* Two properties carry the whole feature and both are easy to break without
* noticing:
*
* - the window is ±7 days and not "recently";
* - two candidates offer a picker with nothing pre-selected, and never the
* first one.
*
* Every fixture is RELATIVE to the moment the test runs (`now − 3 days`, never
* `2026-03-14`). A fixed-date fixture slides out of a relative window as the
* calendar moves and the test rots into a false green; this repository has
* shipped that bug before.
*
* The fake below honours the `where` it is given. A fake that ignores it would
* return every seeded row for every query, so the window assertions would pass
* against a module with no window at all — a check that cannot fail is worse
* than none.
*
* Mutation checks (both run, both seen red):
* - widen `ENCOUNTER_SUGGEST_WINDOW_DAYS` to 90 → the out-of-window cases go
* red naming the 30-day visit that started matching;
* - make the two-candidate branch return `{ kind: "one" }` with the first row
* → "offers a picker with nothing pre-selected" goes red.
*/
import { describe, it, expect } from "vitest";
import {
ENCOUNTER_SUGGEST_WINDOW_DAYS,
suggestEncounterForDate,
} from "../suggest-window";
import type { Prisma } from "@/generated/prisma/client";
const DAY_MS = 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
interface Row {
id: string;
userId: string;
occurredAt: Date;
status: string;
kind: string;
deletedAt: Date | null;
practitioner: { name: string } | null;
}
function row(over: Partial<Row> & { id: string; occurredAt: Date }): Row {
return {
userId: "u1",
status: "DONE",
kind: "ROUTINE",
deletedAt: null,
practitioner: null,
...over,
};
}
/**
* An in-memory `encounter.findMany` that actually applies the filter.
*
* It evaluates every clause the module writes — the record id, the tombstone,
* the status set and both window edges — so a module that dropped one of them
* returns rows this fake would have excluded, and the assertion below fails.
*/
function fakeTx(rows: Row[]): Prisma.TransactionClient {
return {
encounter: {
findMany: async (args: {
where: {
userId: string;
deletedAt: null;
status: { in: string[] };
occurredAt: { gte: Date; lte: Date };
};
take?: number;
}) => {
const { where } = args;
const matched = rows
.filter((r) => r.userId === where.userId)
.filter((r) =>
where.deletedAt === null ? r.deletedAt === null : true,
)
.filter((r) => where.status.in.includes(r.status))
.filter(
(r) =>
r.occurredAt.getTime() >= where.occurredAt.gte.getTime() &&
r.occurredAt.getTime() <= where.occurredAt.lte.getTime(),
)
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
return matched.slice(0, args.take ?? matched.length);
},
},
} as unknown as Prisma.TransactionClient;
}
const anchor = new Date();
const ago = (days: number, extraMs = 0) =>
new Date(anchor.getTime() - days * DAY_MS - extraMs);
const ahead = (days: number, extraMs = 0) =>
new Date(anchor.getTime() + days * DAY_MS + extraMs);
describe("the window", () => {
it("is seven days wide on each side", () => {
expect(ENCOUNTER_SUGGEST_WINDOW_DAYS).toBe(7);
});
it("admits a visit exactly seven days before the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(7) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: expect.objectContaining({ id: "e1" }),
});
});
it("admits a visit exactly seven days after the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ahead(7) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: expect.objectContaining({ id: "e1" }),
});
});
it("refuses a visit seven days and one minute before the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(7, MINUTE_MS) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("refuses a visit seven days and one minute after the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ahead(7, MINUTE_MS) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("refuses a visit thirty days out", async () => {
// The widened-window mutation lands here first: at ±90 days this row
// starts matching and the verdict stops being "none".
const result = await suggestEncounterForDate(
fakeTx([row({ id: "far", occurredAt: ahead(30) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
});
describe("the verdict", () => {
it("offers nothing when there is no candidate", async () => {
const result = await suggestEncounterForDate(fakeTx([]), {
userId: "u1",
anchor,
});
expect(result).toEqual({ kind: "none" });
});
it("pre-selects the single candidate, resolved", async () => {
const result = await suggestEncounterForDate(
fakeTx([
row({
id: "e1",
occurredAt: ago(3),
kind: "SPECIALIST",
practitioner: { name: "Praxis am Park" },
}),
]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: {
id: "e1",
occurredAt: ago(3).toISOString(),
status: "DONE",
kind: "SPECIALIST",
practitionerName: "Praxis am Park",
},
});
});
it("offers a picker with nothing pre-selected for two candidates", async () => {
const result = await suggestEncounterForDate(
fakeTx([
row({ id: "e1", occurredAt: ago(3) }),
row({ id: "e2", occurredAt: ago(1) }),
]),
{ userId: "u1", anchor },
);
// The shape is the assertion: there is no field on a "many" verdict a
// caller could read as a pre-selection.
expect(result.kind).toBe("many");
expect(result).not.toHaveProperty("encounter");
if (result.kind !== "many") throw new Error("unreachable");
expect(result.encounters.map((e) => e.id)).toEqual(["e1", "e2"]);
});
});
describe("what is never a candidate", () => {
it("a soft-deleted visit", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1), deletedAt: new Date() })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("a cancelled visit, and a no-show", async () => {
// Neither happened, so neither produced the record being filed. Admitting
// them would widen the set with rows that are wrong most of the time.
const result = await suggestEncounterForDate(
fakeTx([
row({ id: "e1", occurredAt: ago(1), status: "CANCELLED" }),
row({ id: "e2", occurredAt: ago(2), status: "NO_SHOW" }),
]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("another account's visit", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1), userId: "u2" })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("an unusable anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1) })]),
{ userId: "u1", anchor: new Date("not a date") },
);
expect(result).toEqual({ kind: "none" });
});
});
+11 -2
View File
@@ -64,7 +64,15 @@ export interface EncounterDTO {
* happened, or once the one-shot reminder is spent.
*/
reminderNextDueAt: string | null;
/** Present on the detail response; omitted from the list. */
/**
* The three link families, resolved.
*
* Present on the list as well as the detail. Omitting them from the list
* would break two things at once: the card counts what a visit produced, and
* the edit sheet seeds its pickers from the row it was handed — an edit
* opened from a linkless row would save three empty arrays and unfile
* everything. The list pays three grouped queries for the whole page.
*/
links?: EncounterLinksDTO;
/**
* What the write could not do, named.
@@ -130,7 +138,8 @@ export function toEncounterDTO(
* Two arrays rather than one sorted list, resolved server-side: "what is
* coming up" and "what happened" are read in opposite directions, and making
* the client split and re-sort one array would put that decision in two
* places.
* places. Each row carries its links, for the reasons stated at
* `EncounterDTO.links`.
*/
export interface EncounterListDTO {
upcoming: EncounterDTO[];
+82 -2
View File
@@ -12,7 +12,12 @@ import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links";
import {
listTargets,
listTargetsBySource,
replaceTargets,
type LinkTargetKind,
} from "@/lib/links";
import { grantCoversDomain } from "@/lib/sharing/grants";
import type { ShareDomain } from "@/lib/sharing/scope";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
@@ -75,11 +80,21 @@ export async function actingDomainVisibility(
return (domain) => grantCoversDomain(grant, domain);
}
/** Which section of the record each link family's TARGET belongs to. */
/**
* Which section of the record each link family's TARGET belongs to.
*
* Exhaustive over `LinkTargetKind` by the compiler, so a link kind added to
* the service cannot reach a visit's link list without somebody deciding which
* domain governs its names. `encounter` is here for that reason and is never
* read from this side — a visit does not point at another visit — but a
* `Partial` here would turn "nobody classified it" into "undefined", and an
* undefined domain read as a falsy check is a leak.
*/
const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
document: "documents",
labResult: "labs",
conditionEpisode: "illness",
encounter: "profile",
};
/**
@@ -134,6 +149,71 @@ export async function loadEncounterLinks(
return { documents, labResults, conditions };
}
/**
* The same three families for a PAGE of visits, in three grouped queries.
*
* Two grouped reads per family rather than two per visit — `listTargetsBySource`
* is the batched arm of the same service, so the list pays six round trips for
* a page instead of six per row.
*
* The list carries links for two reasons that are both defects when it does
* not. The card shows counts of what a visit produced, and a card reading zero
* for a visit with three documents is a lie the person cannot see past. And the
* edit sheet seeds its pickers from the row it was handed: an edit opened from a
* list row that did not know its links would save a replace-set of three empty
* arrays and silently unfile everything the visit had collected.
*
* Redaction is the same rule, applied per family, from the same predicate.
*/
export async function loadEncounterLinksForMany(
tx: Prisma.TransactionClient,
userId: string,
encounterIds: string[],
visible: (domain: ShareDomain) => boolean,
): Promise<Map<string, EncounterLinksDTO>> {
const out = new Map<string, EncounterLinksDTO>();
if (encounterIds.length === 0) return out;
const read = async (targetKind: LinkTargetKind) =>
listTargetsBySource(tx, {
userId,
sourceKind: "encounter",
sourceIds: encounterIds,
targetKind,
});
const [documents, labResults, conditions] = await Promise.all([
read("document"),
read("labResult"),
read("conditionEpisode"),
]);
const family = (
map: Map<string, { id: string; label: string; date: string | null }[]>,
id: string,
targetKind: LinkTargetKind,
) => {
const rows = map.get(id) ?? [];
if (visible(TARGET_DOMAIN[targetKind])) {
return rows.map((row) => ({ ...row, redacted: false }));
}
return rows.map((row) => ({
id: row.id,
label: null,
date: null,
redacted: true,
}));
};
for (const id of encounterIds) {
out.set(id, {
documents: family(documents, id, "document"),
labResults: family(labResults, id, "labResult"),
conditions: family(conditions, id, "conditionEpisode"),
});
}
return out;
}
/**
* Apply whichever of the three link arrays the request actually named.
*
+150
View File
@@ -0,0 +1,150 @@
/**
* "Gehört das zu einem Termin?" — one rule, one constant, one module.
*
* Three moments ask it: a document arriving in the vault, a lab panel being
* committed from an extraction, and a lab panel typed by hand. They must ask
* the same question, so none of them owns the answer.
*
* ── The rule ────────────────────────────────────────────────────────────────
*
* candidates = the caller's own live visits, DONE or PLANNED, whose instant
* is within ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days of the
* anchor
*
* exactly one → pre-select it, visibly, with one undo
* two or more → offer a picker with NOTHING pre-selected
* zero → offer nothing at all
*
* The middle branch is the whole point. Pre-selecting the first of two is the
* failure that teaches a person to stop reading suggestions, which costs more
* than the suggestion ever saved. This module therefore returns the SHAPE of
* the answer rather than a best guess, and a caller cannot collapse "many" into
* "one" without writing the collapse itself.
*
* ── What it is not ──────────────────────────────────────────────────────────
*
* It is not an AI call. The match is a date-window query over the person's own
* rows: it costs one index scan, cannot hallucinate, and resolves on an account
* with no provider configured — which matters, because the document assist
* itself refuses without one.
*
* It never blocks. Every caller treats the verdict as an offer; a save works
* with it ignored, dismissed, or never rendered.
*
* ── Why CANCELLED and NO_SHOW are not candidates ────────────────────────────
*
* Both name an appointment that did not happen, so neither produced the letter
* or the panel being filed. Admitting them would widen the candidate set with
* rows that are wrong most of the time, which pushes the honest single-match
* case into the picker branch and trains exactly the distrust the two-candidate
* rule exists to prevent.
*/
import type { Prisma } from "@/generated/prisma/client";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
/**
* The half-width of the match window, in days, exported so the three call
* sites and the tests read the same number. A literal at three call sites is
* three numbers that agree until one of them is edited.
*/
export const ENCOUNTER_SUGGEST_WINDOW_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* A bound on how many candidates are read. Anything above one already means
* "offer a picker", so the exact count past this cap changes no decision — and
* an unbounded read on a busy account would load a page of rows to answer a
* yes/no question.
*/
const MAX_CANDIDATES = 20;
/**
* One candidate, resolved. Enough to render a row without a second round trip
* and without the client re-deriving a label the server already holds.
*/
export interface EncounterSuggestion {
id: string;
occurredAt: string;
status: EncounterStatus;
kind: EncounterKind;
/** The practice, when the visit names one. `null` is an absence, not a gap. */
practitionerName: string | null;
}
/**
* The verdict. A discriminated union rather than an array plus a boolean,
* because "one" and "many" are different offers and a caller that receives an
* array will eventually take `[0]`.
*/
export type EncounterSuggestionResult =
| { kind: "one"; encounter: EncounterSuggestion }
| { kind: "many"; encounters: EncounterSuggestion[] }
| { kind: "none" };
/** The statuses a visit can carry and still be the source of a filed record. */
const CANDIDATE_STATUSES: EncounterStatus[] = ["DONE", "PLANNED"];
/**
* The window around an anchor instant, inclusive on both edges.
*
* Exported so a caller that needs the bounds for a different query — the
* report offer reads the same idea forward-only — states them from here rather
* than re-deriving the arithmetic.
*/
export function encounterSuggestWindow(anchor: Date): { from: Date; to: Date } {
const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
return {
from: new Date(anchor.getTime() - half),
to: new Date(anchor.getTime() + half),
};
}
/**
* Resolve the visit a record dated `anchor` most plausibly belongs to.
*
* `userId` is the caller's resolved record id and is fed straight to the
* `where`; this module never accepts one from a request body.
*/
export async function suggestEncounterForDate(
tx: Prisma.TransactionClient,
input: { userId: string; anchor: Date },
): Promise<EncounterSuggestionResult> {
if (Number.isNaN(input.anchor.getTime())) return { kind: "none" };
const { from, to } = encounterSuggestWindow(input.anchor);
const rows = await tx.encounter.findMany({
where: {
userId: input.userId,
deletedAt: null,
status: { in: CANDIDATE_STATUSES },
occurredAt: { gte: from, lte: to },
},
// Nearest to the anchor would be the kinder order, but Prisma cannot sort
// by a computed distance; the caller renders a picker in chronological
// order, which is how a person reads their own week anyway.
orderBy: { occurredAt: "asc" },
take: MAX_CANDIDATES,
select: {
id: true,
occurredAt: true,
status: true,
kind: true,
practitioner: { select: { name: true } },
},
});
const encounters: EncounterSuggestion[] = rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
status: row.status,
kind: row.kind,
practitionerName: row.practitioner?.name ?? null,
}));
if (encounters.length === 0) return { kind: "none" };
if (encounters.length === 1) return { kind: "one", encounter: encounters[0] };
return { kind: "many", encounters };
}
+2
View File
@@ -367,6 +367,8 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
"Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.",
MoodEntryRollup:
"A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.",
MoodPrediction:
"What the account's own past days imply about a day, fitted from `MoodEntry` + `MoodContext` and the linked modules. The nightly `mood-prognosis-refresh` job rebuilds it after a restore, and rebuilding it is better than carrying it: a restored row would state a forecast made by a model version that may no longer exist, against data the restore has just moved.",
CyclePrediction:
"A forecast derived from `MenstrualCycle` history; regenerated on the next cycle read.",
StrainTrimpCache:
+8
View File
@@ -261,6 +261,14 @@ export function buildFhirDocumentBundle(
// --- Condition + Encounter per illness/condition episode (opt-in) ------
// v1.18.1 — present only when the illness module is enabled AND the window
// No visits section, and the absence is a decision written at the
// `Encounter` model in `prisma/schema.prisma`: FHIR splits the future from
// the past across `Appointment` and `Encounter`, which is a serialisation
// question this release does not answer. `FHIR_REST_RESOURCE_TYPES` stays
// untouched for the same reason — a capability statement naming a type with
// no route module lies. The PDF carries the visits; the Bundle does not, and
// a reader who notices the gap should find this comment rather than a bug.
//
// held an episode (the aggregator gates `data.illnessEpisodes`). Each
// episode emits a patient-reported Condition (generic SNOMED root, label on
// `code.text`) plus a bounding Encounter that references it.
+119
View File
@@ -17,6 +17,7 @@ import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
@@ -321,6 +322,113 @@ async function buildMoodDimensionBlock(userId: string): Promise<{
};
}
/**
* The day's two readings for the model, with the age of the second one.
*
* Absence is a present field rather than a missing one: `{ present: false }`
* with the reason and the count, so the model says "not enough entries yet"
* instead of inventing a number. That shape is a contract elsewhere in the
* product already and it is the reason this block is built even when there is
* nothing to report.
*
* The wording rule travels with the values. A model handed a number and no
* instruction writes the sentence its training suggests, and the sentence its
* training suggests about a forecast is a causal one.
*/
interface MoodPrognosisFeature {
present: boolean;
/** Why there is nothing, when there is nothing. */
reason?: string;
/** Days of the account's own history the ladder was read against. */
entries: number;
/** The day the forecast is about. */
date?: string;
/** The expected value, 0-10. Never to be stated without `n` and the band. */
predicted?: number;
ciLow?: number | null;
ciHigh?: number | null;
/** Complete days the fit used. State it in any sentence that uses the value. */
n?: number;
modelVersion?: string;
/** The person's own rating for that day, unchanged. */
selfAssessment?: number | null;
/** Where that rating sat against the band: within, above or below. */
deviation?: string | null;
/** Whole days between the forecast's day and today. */
daysAgo?: number | null;
/** Whether it is fresh enough to back a present-tense sentence. */
current?: boolean;
/** Present only when it is not, and says in words what the flag says. */
staleNotice?: string;
/** The wording rule, stated rather than implied. */
note: string;
}
/**
* The forecast block, or an honest absence.
*
* One reader for every surface: this calls the same `readMoodPrognosis` the
* route calls, so the Coach and the page cannot disagree about what today's
* comparison is. The freshness rule is the shared one — `isCurrentForTodayClaim`
* against the same `TODAY_CLAIM_MAX_AGE_DAYS` — because a second constant for
* "how old may a value be before today stops being true about it" is how two
* surfaces start narrating the same reading differently.
*/
async function buildMoodPrognosisBlock(userId: string): Promise<{
prognosis?: MoodPrognosisFeature;
}> {
const reading = await readMoodPrognosis(
userId,
new Date(),
await resolveUserTimezone(userId),
);
if (!reading.present) {
return {
prognosis: {
present: false,
reason: reading.reason,
entries: reading.entries,
note:
"There is no expected-value comparison for this account yet. Say so " +
"plainly if it comes up; do not estimate one, and do not describe " +
"the absence as a bad sign.",
},
};
}
return {
prognosis: {
present: true,
entries: reading.entries,
date: reading.date,
predicted: reading.predicted,
ciLow: reading.ciLow,
ciHigh: reading.ciHigh,
n: reading.n,
modelVersion: reading.modelVersion,
selfAssessment: reading.selfAssessment,
deviation: reading.deviation,
daysAgo: reading.ageDays,
current: reading.current,
...(reading.current
? {}
: {
staleNotice: `This comparison is about ${reading.date}. State it as that day, with the date; do not describe it as today.`,
}),
note:
"Two separate readings of one day: `selfAssessment` is what the person " +
"recorded and leads; `predicted` is what a comparison with their own " +
"past days would have expected, built from `n` days. Never merge them, " +
"never present `predicted` as the person's mood, and never state it " +
"without `n` and the band. Phrase it as a counterfactual — a value " +
"around X would have been expected — and never say that anything " +
"caused anything: the comparison is an association over the account's " +
"own days and supports no cause.",
},
};
}
export interface AggregatedFeatures {
weight?: {
latest: number;
@@ -467,6 +575,16 @@ export interface AggregatedFeatures {
withoutAvg: number;
}>;
};
/**
* v1.38 — the day's two readings and the distance between them.
*
* Always present once the mood module is on, because an honest absence is
* information: `present: false` with a reason and a count keeps the model
* from filling the gap with an estimate. The block carries its own age, so
* a comparison about the day before yesterday cannot back a sentence about
* today.
*/
prognosis?: MoodPrognosisFeature;
};
sleep?: {
avg7: number | null;
@@ -1363,6 +1481,7 @@ export async function extractFeatures(
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
...(await buildMoodContextBlock(userId)),
...(await buildMoodPrognosisBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
@@ -106,6 +106,8 @@ describe("evaluateMeasurementReminderDue — window boundary", () => {
interface FakeReminder {
id: string;
/** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */
origin?: string;
measurementType: string | null;
intervalDays: number | null;
rrule: string | null;
@@ -248,6 +250,46 @@ describe("runMeasurementReminderTick", () => {
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
});
it("dispatches an appointment nudge with its completion affordance suppressed", async () => {
// `measure_done` / `measure_later` and the iOS "Erledigt" action all
// resolve the reminder by id, and every by-id surface refuses an
// ENCOUNTER-origin row. The producer knows the origin, so it is the
// producer that says the dispatch carries no action; the channels drop
// their own affordance from the flag.
const { prisma } = makePrisma({
reminders: [
reminder({ origin: "ENCOUNTER", measurementType: null, id: "appt-1" }),
],
measurementMatch: null,
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
const payload = dispatch.mock.calls[0][0];
expect(payload.suppressActions).toBe(true);
// The id still rides the dispatch — it identifies the nudge for the
// client-managed suppression tag. What is withheld is the offer to act.
expect(payload.metadata?.reminderId).toBe("appt-1");
});
it("leaves a checkup nudge actionable", async () => {
// The negative control. Without it the assertion above would still pass
// against a producer that suppressed every reminder's affordance.
const { prisma } = makePrisma({
reminders: [reminder({})],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined();
});
it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => {
const { prisma } = makePrisma({
reminders: [reminder({ measurementType: null })],
@@ -0,0 +1,152 @@
/**
* The nightly mood-forecast rebuild.
*
* Three properties, each of which fails silently if it breaks:
*
* 1. **The sweep visits accounts that hold rows, not only accounts that
* qualify.** An account that deleted its entries drops out of the
* eligibility half of the cohort, and if that were the whole cohort it
* would keep last month's forecast forever, under whatever date the last
* pass stamped on it. Nothing on the surface would say so.
* 2. **A refusal clears.** Below the threshold, module off, no pattern — all
* three leave the account with no rows rather than with stale ones.
* 3. **One account's bad data costs one account.** The pass counts the
* failure and carries on; only an all-accounts failure fails the job.
*
* The cohort query and the module gate are mocked, which is what makes the
* decision path testable without a container. The arithmetic underneath has
* its own tests, and the whole thing is exercised end to end against real
* Postgres in `tests/integration/mood-prognosis-ladder.test.ts`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/jobs/reminder/shared", () => ({
getWorkerPrisma: () => ({
moodEntry: { groupBy: groupByMock },
moodPrediction: {
findMany: predictionFindManyMock,
deleteMany: deleteManyMock,
},
}),
}));
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: async (
_name: string,
fn: (evt: { addMeta: () => void }) => Promise<unknown>,
) => fn({ addMeta: () => {} }),
}));
const groupByMock = vi.fn();
const predictionFindManyMock = vi.fn();
const deleteManyMock = vi.fn();
import {
MOOD_PROGNOSIS_REFRESH_CRON,
MOOD_PROGNOSIS_REFRESH_QUEUE,
handleMoodPrognosisRefresh,
runMoodPrognosisRefresh,
} from "@/lib/jobs/mood-prognosis-refresh";
import { MIN_ENTRIES_FOR_FORECAST } from "@/lib/analytics/mood-prognosis/thresholds";
const NOW = new Date("2026-08-08T02:35:00.000Z");
beforeEach(() => {
vi.clearAllMocks();
groupByMock.mockResolvedValue([
{ userId: "user-1", _count: { _all: MIN_ENTRIES_FOR_FORECAST } },
{ userId: "user-2", _count: { _all: 120 } },
]);
predictionFindManyMock.mockResolvedValue([]);
deleteManyMock.mockResolvedValue({ count: 0 });
});
describe("mood-prognosis-refresh", () => {
it("runs at 04:35, in the slot the neighbouring jobs leave free", () => {
expect(MOOD_PROGNOSIS_REFRESH_CRON).toBe("35 4 * * *");
expect(MOOD_PROGNOSIS_REFRESH_QUEUE).toBe("mood-prognosis-refresh");
});
it("refreshes every eligible account", async () => {
const refresh = vi.fn(async () => ({ rows: 7, refusal: null }));
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
expect(summary.scanned).toBe(2);
// The count is the point: a cohort query that matched nothing would
// satisfy "no bad rows" exactly as well as a working sweep.
expect(refresh).toHaveBeenCalledTimes(2);
expect(refresh).toHaveBeenCalledWith("user-1", NOW);
expect(summary.refreshed).toBe(2);
expect(summary.rowsWritten).toBe(14);
});
it("leaves an account below the entry cut out of the cohort", async () => {
groupByMock.mockResolvedValue([
{ userId: "user-1", _count: { _all: MIN_ENTRIES_FOR_FORECAST - 1 } },
]);
const refresh = vi.fn(async () => ({ rows: 1, refusal: null }));
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
expect(summary.scanned).toBe(0);
expect(refresh).not.toHaveBeenCalled();
});
it("still visits an account that holds rows but no longer qualifies", async () => {
groupByMock.mockResolvedValue([]);
predictionFindManyMock.mockResolvedValue([{ userId: "user-stale" }]);
const refresh = vi.fn(async () => ({
rows: 0,
refusal: "too-few-days" as const,
}));
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
// Without this arm the account would keep its forecast forever: it is not
// in the eligible set any more, so nothing would ever come back for it.
expect(summary.scanned).toBe(1);
expect(refresh).toHaveBeenCalledWith("user-stale", NOW);
expect(summary.refused).toBe(1);
expect(summary.refreshed).toBe(0);
});
it("computes nothing and stores nothing while the module is off, and clears what it stored before", async () => {
const refresh = vi.fn(async () => ({ rows: 7, refusal: null }));
const summary = await runMoodPrognosisRefresh(NOW, {
isAvailable: async () => false,
refresh,
});
expect(refresh).not.toHaveBeenCalled();
expect(summary.moduleOff).toBe(2);
expect(summary.rowsWritten).toBe(0);
expect(deleteManyMock).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(deleteManyMock).toHaveBeenCalledTimes(2);
});
it("counts an account whose pass throws and carries on", async () => {
const refresh = vi
.fn()
.mockRejectedValueOnce(new Error("bad rows"))
.mockResolvedValueOnce({ rows: 3, refusal: null });
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
expect(summary.failed).toBe(1);
expect(summary.refreshed).toBe(1);
expect(summary.rowsWritten).toBe(3);
});
it("fails the job only when every account failed", async () => {
const outcome = await handleMoodPrognosisRefresh([]);
// The default refresh path reaches the real loader, which the mocked
// client cannot serve, so every account throws — which is exactly the
// all-accounts case.
expect(outcome.ok).toBe(false);
});
});
+3
View File
@@ -121,6 +121,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
"medications_evaluated",
"metric_assessments_warmed",
"missed_doses_minted",
"module_off",
"mood_entries_migrated",
"mood_pruned",
"notifications_dispatched",
@@ -147,8 +148,10 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
"rearmed",
"records_read",
"refreshed",
"refused",
"reminders_due",
"removed",
"rows_written",
"restore_drill_age_days",
"restore_drill_ciphertext_bytes",
"restore_drill_intake_events",
+8
View File
@@ -338,12 +338,20 @@ export async function runMeasurementReminderTick(
return { title: rendered.title, message: rendered.body };
};
// An appointment nudge is dispatched without its Done / Later
// affordance. It rides the same event type as a checkup — one engine,
// one cron, deliberately — but every by-id reminder surface refuses an
// ENCOUNTER-origin row: satisfying one would mark an appointment
// attended from a notification, and postponing one would move a date the
// visit record still believes it owns. The channels each drop their own
// affordance from this flag rather than re-deriving the origin.
const outcome = await dispatchImpl({
eventType: "MEASUREMENT_REMINDER",
userId: reminder.user.id,
title,
message: body,
renderForRecipient,
...(reminder.origin === "ENCOUNTER" ? { suppressActions: true } : {}),
metadata: {
scheduledAt: now.toISOString(),
reminderId: reminder.id,
+241
View File
@@ -0,0 +1,241 @@
/**
* Nightly rebuild of the mood forecast rows.
*
* **This is not an AI surface.** A ridge regression over a dozen standardised
* columns is arithmetic: no provider, no key, no egress, no consent gate. It
* is stated here because the shape of the feature invites the assumption in
* both directions — nobody should wire a consent check that does not apply,
* and nobody should assume one exists where none is needed. If a later change
* narrates this forecast through a language model, THAT call site inherits
* `ai-consent-enforcement-guard.test.ts` and has to declare its surface.
*
* **This file is the only writer of `MoodPrediction`**, held there by
* `src/__tests__/mood-prediction-write-surface-guard.test.ts`. The reason is
* the product rule: a day carries the person's own rating and the value their
* past days imply, the two are never merged, and the second one is not
* editable. A route that wrote this table would make it editable. The cycle
* module learned the other half of the same lesson the expensive way — a
* forecast written as a side effect of opening a page is a function of
* browsing rather than of data.
*
* **A pass replaces; it never accumulates.** Every account it touches ends the
* pass holding exactly the rows this run wrote, or none. An account that has
* fallen below the threshold, switched the module off, or stopped showing a
* pattern is left with nothing rather than with last week's answer under a
* fresh-looking date. That is the stale rule, chosen over marking rows
* superseded because a derived row is cheap to rebuild and a superseded row is
* one more state every reader has to handle correctly.
*
* Runs at 04:35 Europe/Berlin. The 03:xx block is retention; 04:10 is the
* document purge, 04:20 the cycle forecast, 04:25 the achievement sweep, 04:30
* the insight pre-generation, and 04:45/50/55 the recovery, stress and strain
* scores. 04:35 is free, and it sits after the inputs this job reads have
* settled. (04:40 is not free: the geo backfill runs at `40 * * * *`.)
*/
import type { Job } from "pg-boss";
import {
computeForecast,
type ForecastRefusal,
} from "@/lib/analytics/mood-prognosis/forecast";
import {
PROGNOSIS_FIT_WINDOW_DAYS,
dayKeyBefore,
loadPrognosisDays,
} from "@/lib/analytics/mood-prognosis/load";
import { MIN_ENTRIES_FOR_FORECAST } from "@/lib/analytics/mood-prognosis/thresholds";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { withBackgroundEvent } from "@/lib/logging/background";
import { isModuleEnabled } from "@/lib/modules/gate";
import { getWorkerPrisma } from "./reminder/shared";
export const MOOD_PROGNOSIS_REFRESH_QUEUE = "mood-prognosis-refresh";
export const MOOD_PROGNOSIS_REFRESH_CRON = "35 4 * * *";
export interface MoodPrognosisRefreshPayload {
triggeredAt: string;
}
export interface MoodPrognosisRefreshSummary {
/** Accounts the cohort query returned, plus any holding stale rows. */
scanned: number;
/** Accounts whose forecast rows now reflect their data. */
refreshed: number;
/** Rows written across every account. */
rowsWritten: number;
/** Accounts the fit refused, whose rows were cleared. */
refused: number;
/** Accounts whose module is off, whose rows were cleared. */
moduleOff: number;
/** Accounts whose pass threw. */
failed: number;
}
/**
* Rebuild one account's rows, or clear them.
*
* The write and the delete are the two calls the guard allows in this file,
* and they are here rather than beside the arithmetic so the single-writer
* claim is a property of one module rather than a convention across two.
*/
async function refreshAccount(
userId: string,
now: Date,
): Promise<{ rows: number; refusal: ForecastRefusal | null }> {
const prisma = getWorkerPrisma();
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const days = await loadPrognosisDays(userId, now, user?.timezone ?? null);
const result = computeForecast(days);
if (result.status === "refused") {
await prisma.moodPrediction.deleteMany({ where: { userId } });
return { rows: 0, refusal: result.reason };
}
const written: string[] = [];
for (const row of result.rows) {
const data = {
predicted: row.predicted,
ciLow: row.ciLow,
ciHigh: row.ciHigh,
n: row.n,
modelVersion: row.modelVersion,
features: JSON.stringify(row.contributions),
computedAt: now,
};
await prisma.moodPrediction.upsert({
where: { userId_date: { userId, date: row.date } },
create: { userId, date: row.date, ...data },
update: data,
});
written.push(row.date);
}
// Everything outside this pass goes, so no account holds a row from an older
// fit beside a row from this one.
await prisma.moodPrediction.deleteMany({
where: { userId, date: { notIn: written } },
});
return { rows: written.length, refusal: null };
}
/**
* Refresh every eligible account. Exported for the test; the handler below is
* the queue binding.
*
* The cohort is the union of two sets, and the second one is the whole point:
* accounts with enough recent rated days to be worth fitting, AND accounts
* that already hold forecast rows. Without the second, an account that deleted
* its entries would drop out of the cohort and keep its old forecast forever —
* the sweep would never visit it again to clear it.
*/
export async function runMoodPrognosisRefresh(
now: Date = new Date(),
options: {
isAvailable?: (userId: string) => Promise<boolean>;
refresh?: typeof refreshAccount;
} = {},
): Promise<MoodPrognosisRefreshSummary> {
const isAvailable =
options.isAvailable ??
((userId: string) => isModuleEnabled(userId, "mood"));
const refresh = options.refresh ?? refreshAccount;
const prisma = getWorkerPrisma();
const since = dayKeyBefore(now, PROGNOSIS_FIT_WINDOW_DAYS);
const [eligible, holders] = await Promise.all([
prisma.moodEntry.groupBy({
by: ["userId"],
where: {
deletedAt: null,
moodA1: { not: null },
date: { gte: since },
},
_count: { _all: true },
}),
prisma.moodPrediction.findMany({
distinct: ["userId"],
select: { userId: true },
}),
]);
const userIds = new Set<string>();
for (const group of eligible) {
// The cheap cut, so an account with a handful of entries never pays for a
// window read. The real gate is the ladder inside the fit, which counts
// DAYS rather than rows.
if (group._count._all >= MIN_ENTRIES_FOR_FORECAST)
userIds.add(group.userId);
}
for (const holder of holders) userIds.add(holder.userId);
const summary: MoodPrognosisRefreshSummary = {
scanned: userIds.size,
refreshed: 0,
rowsWritten: 0,
refused: 0,
moduleOff: 0,
failed: 0,
};
for (const userId of userIds) {
try {
if (!(await isAvailable(userId))) {
// An operator- or user-disabled module computes nothing and stores
// nothing — and gives back anything it stored before.
await prisma.moodPrediction.deleteMany({ where: { userId } });
summary.moduleOff += 1;
continue;
}
const outcome = await refresh(userId, now);
if (outcome.refusal !== null) {
summary.refused += 1;
continue;
}
summary.refreshed += 1;
summary.rowsWritten += outcome.rows;
} catch {
// One account's bad data must not cost every other account its forecast.
// The count is what makes a systemic failure visible.
summary.failed += 1;
}
}
return summary;
}
export async function handleMoodPrognosisRefresh(
jobs: Job<MoodPrognosisRefreshPayload>[],
): Promise<JobOutcome> {
void jobs;
return withBackgroundEvent("job.mood-prognosis-refresh", async (evt) => {
const summary = await runMoodPrognosisRefresh();
evt.addMeta("mood_prognosis_scanned", summary.scanned);
evt.addMeta("mood_prognosis_refreshed", summary.refreshed);
evt.addMeta("mood_prognosis_rows", summary.rowsWritten);
evt.addMeta("mood_prognosis_refused", summary.refused);
evt.addMeta("mood_prognosis_module_off", summary.moduleOff);
evt.addMeta("mood_prognosis_failed", summary.failed);
// Every account failing is a systemic fault (a dead pool, a schema drift),
// not one account's bad data. Anything short of that stays a success with
// the count on the event.
if (summary.failed > 0 && summary.failed === summary.scanned) {
return jobFailed(
`mood-prognosis-refresh failed for all ${summary.failed} accounts`,
);
}
return jobDone({
users_scanned: summary.scanned,
refreshed: summary.refreshed,
rows_written: summary.rowsWritten,
refused: summary.refused,
module_off: summary.moduleOff,
users_failed: summary.failed,
});
});
}
@@ -195,6 +195,12 @@ import {
handleCyclePredictionRefresh,
type CyclePredictionRefreshPayload,
} from "@/lib/jobs/cycle-prediction-refresh";
import {
MOOD_PROGNOSIS_REFRESH_QUEUE,
MOOD_PROGNOSIS_REFRESH_CRON,
handleMoodPrognosisRefresh,
type MoodPrognosisRefreshPayload,
} from "@/lib/jobs/mood-prognosis-refresh";
import {
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
ACHIEVEMENT_UNLOCK_SWEEP_CRON,
@@ -365,6 +371,10 @@ const allQueues = [
// scans. Without this entry the schedule silently no-ops and the forecast
// is only as fresh as the last time somebody opened the calendar.
CYCLE_PREDICTION_REFRESH_QUEUE,
// v1.38 — nightly rebuild of the mood forecast rows. Without this entry the
// schedule silently no-ops: nothing else writes them, so the surface would
// answer "not enough data yet" forever on an account that has plenty.
MOOD_PROGNOSIS_REFRESH_QUEUE,
// v1.35.3 — nightly pin for newly unlocked achievements. Without this entry
// the schedule silently no-ops and no unlock date is ever made durable.
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
@@ -521,6 +531,10 @@ const schedules: ScheduleEntry[] = [
CYCLE_PREDICTION_REFRESH_CRON,
cronIsTheRetry,
],
// v1.38 — daily 04:35 Europe/Berlin rebuild of the mood forecast rows.
// Tomorrow's tick is the retry: the fit moves on a day scale and a night
// late writes the same rows from the same days.
[MOOD_PROGNOSIS_REFRESH_QUEUE, MOOD_PROGNOSIS_REFRESH_CRON, cronIsTheRetry],
// v1.35.3 — daily 04:25 Europe/Berlin pin for newly unlocked achievements.
// Tomorrow's tick is the retry: the date written is the computed completion
// date, so a night late writes the identical row.
@@ -743,6 +757,14 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleCyclePredictionRefresh,
);
// v1.38 — nightly mood-forecast rebuild. Single-flight: two ticks would fit
// the same windows twice and the second pass would write the identical rows.
await createAndWork<MoodPrognosisRefreshPayload>(
boss,
MOOD_PROGNOSIS_REFRESH_QUEUE,
{ localConcurrency: 1 },
handleMoodPrognosisRefresh,
);
// v1.35.3 — nightly achievement-unlock pin. Single-flight: the underlying
// createMany is idempotent on the (userId, achievementId) unique, so a
// second concurrent tick would only re-walk the same accounts.
+1
View File
@@ -117,6 +117,7 @@ const subsystemSurface = {
"push-attempt-cleanup": { audience: "system" },
"arrival-reaction-cleanup": { audience: "system" },
"cycle-prediction-refresh": { audience: "system" },
"mood-prognosis-refresh": { audience: "system" },
"achievement-unlock-sweep": { audience: "system" },
"measurement-tombstone-cleanup": { audience: "system" },
"coach-message-cleanup": { audience: "system" },
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more