From 1bdc5ea4eefdc5d5e85bd2f3b353ba85915cc957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 20:57:58 +0200 Subject: [PATCH 01/10] test(backup): bind MoodEntry to a named backup select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood read was an inline `include`, so nothing could compare it to the model the way `measurement-backup-completeness.test.ts` compares the measurement select. Name it — `MOOD_ENTRY_BACKUP_SELECT` and `MOOD_ENTRY_TAG_LINK_SELECT` — and add the sibling guard that binds both to `prisma/schema.prisma`. The mapper's parameter is spelled `moodEntry` rather than `e` because the guard proves a column reaches a payload by matching `: moodEntry.`, and `e` is also the intake-event mapper's parameter one section above: a matcher on `: e.` would match across both and could never fail. The guard is red as it stands, and names the defect: `note`, `noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt` and `updatedAt` are selected and written to no payload. The fix follows. --- .../mood-backup-completeness.test.ts | 263 ++++++++++++++++++ .../__tests__/full-backup-payload.test.ts | 8 +- src/lib/export/full-backup-payload.ts | 79 ++++-- 3 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/mood-backup-completeness.test.ts diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts new file mode 100644 index 000000000..ca7eb3b0f --- /dev/null +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -0,0 +1,263 @@ +/** + * Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and + * `MoodEntryTagLink` models to the disaster-recovery backup selects. + * + * The sibling of `measurement-backup-completeness.test.ts`, written as its own + * file rather than as a second describe inside it, because that guard's own + * note asks for model-by-model growth and one file per model keeps every + * failure message about one model. + * + * The failure it exists to catch, stated by the defect that prompted it: the + * mood payload carried `id, date, mood, score, tags, source, loggedAt, + * externalId, deletedAt, factors` and nothing else. The free-text note, the + * row's timezone and the last-writer-wins counter were selected by the query + * and written to no payload, so a restored entry came back without its text, + * read its day boundaries under the legacy Europe/Berlin assumption, and lost + * the next sync round to a paired device still holding a higher counter. The + * backup validation schema is `.passthrough()`, so nothing complained at parse + * time either. Nothing here was a wrong value; every one of them was silence. + * + * Both ends of the comparison are literals — a Prisma model block and a named + * select object — so the matcher is exact. Every matcher also asserts a + * non-zero match count: an empty match set fails rather than passing quietly, + * which is how the bearer-scope guard stayed green for months while matching + * nothing at all. + * + * Mutation check: comment out `tz: true` in `MOOD_ENTRY_BACKUP_SELECT` and + * this goes red naming that column; add a scalar to the model without adding + * it to the select and it goes red naming the new one; rename the mapper's + * `moodEntry` parameter and the serialization assertion goes red on all of + * them at once. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +const ROOT = process.cwd(); +const SCHEMA = join(ROOT, "prisma", "schema.prisma"); +const BACKUP_BUILDER = join( + ROOT, + "src", + "lib", + "export", + "full-backup-payload.ts", +); + +/** + * Columns the backup is correct to omit, each with the reason. The reasons are + * length-checked below: an exclusion is a claim, and a claim needs saying. + */ +const EXCLUDED_MOOD_ENTRY_COLUMNS: Record = { + userId: "user-scoped backup; owner is re-derived from the restoring account", +}; + +const EXCLUDED_TAG_LINK_COLUMNS: Record = { + moodEntryId: "re-bound to the restored parent row's id, never carried", + moodTagId: + "the link travels by the tag's key; a source-instance id need not exist in the target catalogue", + createdAt: + "the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one", +}; + +function modelBlock(schema: string, model: string): string { + const start = schema.indexOf(`model ${model} {`); + expect(start, `model ${model} not found`).toBeGreaterThan(-1); + const end = schema.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + return schema.slice(start, end); +} + +/** Names of every `model` block, so relation fields can be told apart. */ +function declaredModels(schema: string): Set { + const out = new Set(); + for (const m of schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)) { + out.add(m[1]); + } + return out; +} + +/** + * Scalar (column-backed) fields of a model block: everything that is not a + * relation to another model. Enums and native scalars both map to columns and + * therefore both belong in a backup. + */ +function scalarFields(block: string, models: Set): string[] { + const fields: string[] = []; + for (const rawLine of block.split("\n").slice(1)) { + const line = rawLine.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line); + if (!m) continue; + const [, name, type, isList] = m; + if (isList) continue; + if (models.has(type)) continue; + if (line.includes("@relation")) continue; + fields.push(name); + } + return fields; +} + +/** Keys of a named `const = {` select literal. */ +function selectFields(source: string, constant: string): string[] { + const start = source.indexOf(`const ${constant} = {`); + expect(start, `${constant} not found`).toBeGreaterThan(-1); + const end = source.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + const body = source.slice(start, end); + return [...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*): true,$/gm)].map( + (m) => m[1], + ); +} + +/** + * Columns either serialization arm actually reads off the mapped row. + * + * The capture is the RIGHT-hand side — the column — not the payload key, which + * matters here in a way it did not for `Measurement`: the mood payload renames + * `moodLoggedAt` to `loggedAt` on the wire, so a left-hand matcher would report + * the column as dropped while the value rides perfectly well. + * + * The two arms share one restorable wire format, so this is a union and + * therefore a weaker claim than the select comparison: it catches a column that + * is selected and then written to neither payload, not one carried by only a + * single arm. + */ +function serialisedFields(source: string, variable: string): Set { + const matches = [ + ...source.matchAll( + new RegExp(`[:(,]\\s*${variable}\\.([a-zA-Z_$][\\w$]*)`, "g"), + ), + ]; + expect( + matches.length, + `no \`${variable}.\` reads found — the mapper parameter was renamed and this matcher now proves nothing`, + ).toBeGreaterThan(0); + return new Set(matches.map((m) => m[1])); +} + +describe("mood backup completeness", () => { + const schema = readFileSync(SCHEMA, "utf8"); + const source = readFileSync(BACKUP_BUILDER, "utf8"); + const models = declaredModels(schema); + + const entryColumns = scalarFields(modelBlock(schema, "MoodEntry"), models); + const entrySelected = selectFields(source, "MOOD_ENTRY_BACKUP_SELECT"); + const linkColumns = scalarFields( + modelBlock(schema, "MoodEntryTagLink"), + models, + ); + const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT"); + + it("parses both ends into a plausible shape", () => { + expect(models.size).toBeGreaterThan(50); + expect(entryColumns.length).toBeGreaterThan(10); + expect(entryColumns).toContain("noteEncrypted"); + expect(entryColumns).toContain("syncVersion"); + expect(entryColumns).not.toContain("user"); + expect(entryColumns).not.toContain("tagLinks"); + expect(entrySelected.length).toBeGreaterThan(10); + + expect(linkColumns).toEqual( + expect.arrayContaining([ + "moodEntryId", + "moodTagId", + "createdAt", + "rating", + ]), + ); + expect(linkColumns).not.toContain("moodEntry"); + expect(linkColumns).not.toContain("moodTag"); + expect(linkSelected.length).toBeGreaterThan(0); + }); + + it("the backup select covers every MoodEntry column", () => { + const missing = entryColumns.filter( + (c) => !entrySelected.includes(c) && !(c in EXCLUDED_MOOD_ENTRY_COLUMNS), + ); + + if (missing.length > 0) { + const report = missing.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `MOOD_ENTRY_BACKUP_SELECT omits ${missing.length} column(s) of the MoodEntry model:\n${report}\n\n` + + "A disaster-recovery restore brings these back at their column " + + "default. Add each to the select and to both serialization arms, " + + "extend `moodEntrySchema` and the restore write — or add it to " + + "EXCLUDED_MOOD_ENTRY_COLUMNS with the reason the backup is correct " + + "to drop it.", + ); + } + }); + + it("every selected MoodEntry column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "moodEntry"); + const dropped = entrySelected.filter((c) => !emitted.has(c)); + + if (dropped.length > 0) { + const report = dropped.map((c) => ` ❌ ${c}`).join("\n"); + throw new Error( + `${dropped.length} selected MoodEntry column(s) reach neither serialization arm:\n${report}\n\n` + + "The query pays for them and no payload carries them, so a restore " + + "cannot put them back. Emit each from the disaster-recovery arm " + + "(and, where it is readable and not a tombstone, from the portable " + + "arm too).", + ); + } + }); + + it("the tag-link select covers every MoodEntryTagLink column", () => { + const missing = linkColumns.filter( + (c) => !linkSelected.includes(c) && !(c in EXCLUDED_TAG_LINK_COLUMNS), + ); + expect( + missing, + `MOOD_ENTRY_TAG_LINK_SELECT omits MoodEntryTagLink column(s): ${missing.join(", ")}`, + ).toEqual([]); + }); + + it("every selected tag-link column reaches a serialized payload", () => { + const emitted = serialisedFields(source, "tagLink"); + const dropped = linkSelected.filter((c) => !emitted.has(c)); + expect( + dropped, + `selected but serialized by neither arm: ${dropped.join(", ")}`, + ).toEqual([]); + }); + + it("both structured-tag arms are carried, not just the rated one", () => { + // The defect this file was written for: the tag-link read filtered + // `rating: { not: null }`, and a BINARY link carries a NULL rating by + // definition. Every present/absent tag — the kind most people pick — was + // excluded from the file and could not come back. + // + // Asserted on a boolean rather than on the file contents so a failure + // reads as one line instead of printing the whole builder back. + expect( + source.includes("rating: { not: null }"), + "the tag-link read filters out NULL ratings again; BINARY links are being dropped from the backup", + ).toBe(false); + expect( + source.includes("structuredTags"), + "no `structuredTags` key in the payload — the BINARY links have no carrier", + ).toBe(true); + }); + + it("the exclusion lists carry no stale entry", () => { + for (const [column, reason] of Object.entries( + EXCLUDED_MOOD_ENTRY_COLUMNS, + )) { + expect( + entryColumns, + `${column} is excluded but is no longer a MoodEntry column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } + for (const [column, reason] of Object.entries(EXCLUDED_TAG_LINK_COLUMNS)) { + expect( + linkColumns, + `${column} is excluded but is no longer a MoodEntryTagLink column`, + ).toContain(column); + expect(reason.length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 4d76bc57e..3632d9042 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -409,7 +409,13 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user-1" }, - include: expect.any(Object), + select: expect.objectContaining({ + id: true, + noteEncrypted: true, + tz: true, + syncVersion: true, + deletedAt: true, + }), }), ); expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith( diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 57ef86451..08298bf65 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -103,6 +103,46 @@ const MEASUREMENT_BACKUP_SELECT = { updatedAt: true, } as const satisfies Prisma.MeasurementSelect; +/** + * Every restorable `MoodEntry` scalar. `userId` is absent for the same reason + * it is absent above: the backup is user-scoped and the owner is re-derived + * from the restoring account. + * + * Named rather than inlined so `mood-backup-completeness.test.ts` can compare + * it field-by-field against the model in `prisma/schema.prisma`, exactly as the + * measurement guard does for the select above. + */ +const MOOD_ENTRY_BACKUP_SELECT = { + id: true, + date: true, + mood: true, + score: true, + tags: true, + note: true, + noteEncrypted: true, + source: true, + externalId: true, + moodLoggedAt: true, + tz: true, + syncedAt: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.MoodEntrySelect; + +/** + * A structured-tag link, as it rides the wire. The link is carried by the + * tag's KEY, not by its id: a restore resolves the key against the target + * instance's catalogue, where the source instance's row id may not exist at + * all. `kind` comes along so the mapper can split RATED factors from BINARY + * tags without a second query. + */ +const MOOD_ENTRY_TAG_LINK_SELECT = { + rating: true, + moodTag: { select: { key: true, kind: true } }, +} as const satisfies Prisma.MoodEntryTagLinkSelect; + /** * Build the canonical full-backup payload for `userId`. Portable exports omit * tombstones and document ciphertext; disaster-recovery payloads preserve @@ -207,13 +247,11 @@ export async function buildFullBackupPayload( prisma.moodEntry.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { moodLoggedAt: "desc" }, - include: { + select: { + ...MOOD_ENTRY_BACKUP_SELECT, tagLinks: { where: { rating: { not: null } }, - select: { - rating: true, - moodTag: { select: { key: true } }, - }, + select: MOOD_ENTRY_TAG_LINK_SELECT, }, }, }), @@ -394,19 +432,24 @@ export async function buildFullBackupPayload( skipped: e.skipped, source: e.source, })), - moodEntries: moodEntries.map((e) => ({ - id: e.id, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - externalId: e.externalId, - deletedAt: e.deletedAt?.toISOString() ?? null, - factors: e.tagLinks.map((link) => ({ - key: link.moodTag.key, - rating: link.rating!, + // The parameter is spelled out rather than the `e` every other mapper here + // uses, and deliberately: `mood-backup-completeness.test.ts` proves a + // selected column reaches a payload by matching `: moodEntry.`. A matcher + // on `: e.` would also match the intake-event mapper above it and could + // therefore never fail. + moodEntries: moodEntries.map((moodEntry) => ({ + id: moodEntry.id, + date: moodEntry.date, + mood: moodEntry.mood, + score: moodEntry.score, + tags: moodEntry.tags, + source: moodEntry.source, + loggedAt: moodEntry.moodLoggedAt.toISOString(), + externalId: moodEntry.externalId, + deletedAt: moodEntry.deletedAt?.toISOString() ?? null, + factors: moodEntry.tagLinks.map((tagLink) => ({ + key: tagLink.moodTag.key, + rating: tagLink.rating!, })), })), customMoodTags: customMoodTags.map((tag) => ({ From 222047e0d48b355235705c9bb29e6e0ee990ea41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:13:42 +0200 Subject: [PATCH 02/10] fix(backup): carry every mood column and both tag arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disaster-recovery payload dropped the free-text note, the row's timezone and the last-writer-wins counter, and the tag-link read filtered `rating: { not: null }` — which is the definition of a BINARY link, so every present/absent tag a person picked was missing from the file too. Both arms now mirror the measurement pair: the recovery arm carries the note ciphertext verbatim plus `tz`, `syncedAt`, `syncVersion`, `createdAt` and `updatedAt`; the portable arm carries the decrypted note, `tz` and no ciphertext. BINARY keys ride in their own `structuredTags` array rather than as a nullable rating inside `factors`, because a restore resolves `factors` against RATED tags and names what it cannot resolve — a rating-less entry there would collide with both. Absent means empty, so a file written before this parses and restores exactly as it did. The two link arms partition the links exhaustively: a malformed link comes back named as unresolvable instead of falling between them. --- .../mood-backup-completeness.test.ts | 49 ++++++-- .../__tests__/full-backup-payload.test.ts | 105 +++++++++++++++++- src/lib/export/full-backup-payload.ts | 79 +++++++++---- 3 files changed, 204 insertions(+), 29 deletions(-) diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index ca7eb3b0f..ef483325d 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -110,6 +110,31 @@ function selectFields(source: string, constant: string): string[] { ); } +/** + * The builder with its comment lines removed. + * + * Every matcher below runs against this rather than against the raw file, + * because prose describing the defect reads exactly like the defect: the first + * draft of this guard went red on its own explanation of the filter it exists + * to forbid. Whole comment lines only — a trailing comment after code is left + * alone, which is safe here because the only risk it carries is a matcher + * accepting a column named in prose, and the select comparison beside it is + * literal either way. + */ +function stripCommentLines(source: string): string { + return source + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return !( + trimmed.startsWith("//") || + trimmed.startsWith("/*") || + trimmed.startsWith("*") + ); + }) + .join("\n"); +} + /** * Columns either serialization arm actually reads off the mapped row. * @@ -138,7 +163,7 @@ function serialisedFields(source: string, variable: string): Set { describe("mood backup completeness", () => { const schema = readFileSync(SCHEMA, "utf8"); - const source = readFileSync(BACKUP_BUILDER, "utf8"); + const source = stripCommentLines(readFileSync(BACKUP_BUILDER, "utf8")); const models = declaredModels(schema); const entryColumns = scalarFields(modelBlock(schema, "MoodEntry"), models); @@ -225,16 +250,22 @@ describe("mood backup completeness", () => { }); it("both structured-tag arms are carried, not just the rated one", () => { - // The defect this file was written for: the tag-link read filtered - // `rating: { not: null }`, and a BINARY link carries a NULL rating by - // definition. Every present/absent tag — the kind most people pick — was - // excluded from the file and could not come back. + // The defect this file was written for: the tag-link read carried a + // filter, and a BINARY link's rating is NULL by definition, so every + // present/absent tag — the kind most people pick — was excluded from the + // file and could not come back from it. The claim is therefore about the + // read itself: it takes the links whole, unfiltered. // - // Asserted on a boolean rather than on the file contents so a failure - // reads as one line instead of printing the whole builder back. + // Asserted on booleans rather than on the file contents so a failure reads + // as one line instead of printing the whole builder back. + const tagLinkRead = /tagLinks:\s*\{([\s\S]*?)\}/.exec(source); expect( - source.includes("rating: { not: null }"), - "the tag-link read filters out NULL ratings again; BINARY links are being dropped from the backup", + tagLinkRead, + "no `tagLinks:` read found in the backup builder — this matcher proves nothing", + ).not.toBeNull(); + expect( + /where/.test(tagLinkRead![1]), + "the tag-link read filters again; a filter here is what dropped every BINARY link from the backup", ).toBe(false); expect( source.includes("structuredTags"), diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 3632d9042..e55bf7b69 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -38,6 +38,8 @@ import { restoreProfileData } from "../profile-backup"; const deletedAt = new Date("2026-07-19T12:00:00.000Z"); const measurementNote = encryptToBytes("canonical measurement note"); const sideEffectNote = encryptToBytes("nausea after the evening dose"); +const moodNotePlaintext = "quiet evening, slept badly"; +const moodNote = encryptToBytes(moodNotePlaintext); const appSettings = { id: "singleton", @@ -193,11 +195,27 @@ function makePrisma() { mood: "OKAY", score: 3, tags: null, + note: null, + noteEncrypted: moodNote, source: "MOODLOG", externalId: "moodlog-42", moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"), + tz: "America/New_York", + syncedAt: new Date("2026-07-19T08:00:01.000Z"), + syncVersion: 3, deletedAt, - tagLinks: [{ rating: 4, moodTag: { key: "sleep_quality" } }], + createdAt: new Date("2026-07-19T08:00:00.000Z"), + updatedAt: new Date("2026-07-19T09:00:00.000Z"), + tagLinks: [ + { + rating: 4, + moodTag: { key: "sleep_quality", kind: "RATED" }, + }, + { + rating: null, + moodTag: { key: "headache", kind: "BINARY" }, + }, + ], }, ]), }, @@ -491,7 +509,16 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { id: "mood-tombstone", externalId: "moodlog-42", deletedAt: deletedAt.toISOString(), + tz: "America/New_York", + syncedAt: "2026-07-19T08:00:01.000Z", + syncVersion: 3, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T09:00:00.000Z", + note: null, + noteEncrypted: Buffer.from(moodNote).toString("base64"), factors: [{ key: "sleep_quality", rating: 4 }], + // The BINARY link the old `rating: { not: null }` filter excluded. + structuredTags: ["headache"], }, ], nutrientDays: [ @@ -1154,3 +1181,79 @@ describe("buildFullBackupPayload — profile and custom metrics", () => { expect(counts.intradayProfiles).toBe(1); }); }); + +describe("buildFullBackupPayload — mood arms", () => { + it("carries the ciphertext and the whole row on the disaster-recovery arm", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const [entry] = (payload as { moodEntries: Array> }) + .moodEntries; + expect(entry).toEqual( + expect.objectContaining({ + id: "mood-tombstone", + note: null, + noteEncrypted: Buffer.from(moodNote).toString("base64"), + tz: "America/New_York", + syncedAt: "2026-07-19T08:00:01.000Z", + syncVersion: 3, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T09:00:00.000Z", + deletedAt: deletedAt.toISOString(), + factors: [{ key: "sleep_quality", rating: 4 }], + structuredTags: ["headache"], + }), + ); + }); + + it("carries the readable note and no ciphertext on the portable arm", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const [entry] = (payload as { moodEntries: Array> }) + .moodEntries; + // Same split the measurement arm makes: the readable artefact carries the + // decrypted text and nothing an operator could not read, and the counters + // that only mean something to this instance stay behind. + expect(entry).toEqual( + expect.objectContaining({ + note: moodNotePlaintext, + tz: "America/New_York", + structuredTags: ["headache"], + }), + ); + expect(entry).not.toHaveProperty("noteEncrypted"); + expect(entry).not.toHaveProperty("syncVersion"); + expect(entry).not.toHaveProperty("syncedAt"); + expect(JSON.stringify(entry)).not.toContain( + Buffer.from(moodNote).toString("base64"), + ); + // The tag-link read is unfiltered on both arms; a BINARY link is the one + // most people have and it was the one the file never carried. + expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + tagLinks: { select: expect.any(Object) }, + }), + }), + ); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 08298bf65..9f4506de5 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -249,10 +249,11 @@ export async function buildFullBackupPayload( orderBy: { moodLoggedAt: "desc" }, select: { ...MOOD_ENTRY_BACKUP_SELECT, - tagLinks: { - where: { rating: { not: null } }, - select: MOOD_ENTRY_TAG_LINK_SELECT, - }, + // Every link, not only the rated ones. This read used to filter + // `rating: { not: null }`, and a BINARY link carries a NULL rating by + // definition — so the present/absent tags, the kind most people pick, + // were absent from the file and could not come back from it. + tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT }, }, }), // The account's OWN mood tags. The seeded catalogue is reference data every @@ -437,21 +438,61 @@ export async function buildFullBackupPayload( // selected column reaches a payload by matching `: moodEntry.`. A matcher // on `: e.` would also match the intake-event mapper above it and could // therefore never fail. - moodEntries: moodEntries.map((moodEntry) => ({ - id: moodEntry.id, - date: moodEntry.date, - mood: moodEntry.mood, - score: moodEntry.score, - tags: moodEntry.tags, - source: moodEntry.source, - loggedAt: moodEntry.moodLoggedAt.toISOString(), - externalId: moodEntry.externalId, - deletedAt: moodEntry.deletedAt?.toISOString() ?? null, - factors: moodEntry.tagLinks.map((tagLink) => ({ - key: tagLink.moodTag.key, - rating: tagLink.rating!, - })), - })), + moodEntries: moodEntries.map((moodEntry) => { + // The two link arms partition the entry's links exhaustively: a link is + // a rated factor when its tag is RATED and it actually carries a score, + // and a structured tag otherwise. Total on purpose — a malformed link + // (a RATED tag whose rating went missing) rides as a tag key and comes + // back NAMED as unresolvable on restore, rather than falling between the + // two arms and disappearing the way every BINARY link used to. + const isRatedFactor = (link: (typeof moodEntry.tagLinks)[number]) => + link.moodTag.kind === "RATED" && link.rating !== null; + + const factors = moodEntry.tagLinks + .filter(isRatedFactor) + .map((tagLink) => ({ + key: tagLink.moodTag.key, + rating: tagLink.rating as number, + })); + // BINARY link keys. Their own array rather than a nullable rating inside + // `factors`: the restore resolves `factors` against `kind: "RATED"` and + // reports what it cannot resolve, so a rating-less entry in that array + // would collide with both. A separate key leaves every backup file + // written before this parsing and restoring exactly as it did. + const structuredTags = moodEntry.tagLinks + .filter((link) => !isRatedFactor(link)) + .map((tagLink) => tagLink.moodTag.key); + + return { + ...(disasterRecovery + ? { + note: moodEntry.note, + noteEncrypted: moodEntry.noteEncrypted + ? Buffer.from(moodEntry.noteEncrypted).toString("base64") + : null, + syncedAt: moodEntry.syncedAt.toISOString(), + syncVersion: moodEntry.syncVersion, + createdAt: moodEntry.createdAt.toISOString(), + updatedAt: moodEntry.updatedAt.toISOString(), + } + : { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }), + id: moodEntry.id, + date: moodEntry.date, + mood: moodEntry.mood, + score: moodEntry.score, + tags: moodEntry.tags, + source: moodEntry.source, + loggedAt: moodEntry.moodLoggedAt.toISOString(), + externalId: moodEntry.externalId, + // The zone the `date` string is anchored to. Without it a restored row + // falls back to the legacy Europe/Berlin reading and an account that + // logged elsewhere gets its day boundaries quietly moved. + tz: moodEntry.tz, + deletedAt: moodEntry.deletedAt?.toISOString() ?? null, + factors, + structuredTags, + }; + }), customMoodTags: customMoodTags.map((tag) => ({ ...(disasterRecovery ? { id: tag.id } : {}), key: tag.key, From 18edd34c33dc1aac8127b77e60ec4475a1ddae7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:37:06 +0200 Subject: [PATCH 03/10] fix(backup): restore the mood note, zone, counter and binary tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moodEntrySchema` gains `note`, `noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt`, `updatedAt` and `structuredTags`; every one of them optional or defaulted, so a backup file written before this parses unchanged and restores exactly as it did. The restore writes them. The note follows the measurement pair: ciphertext verbatim when the file carries it, a portable file's plaintext re-encrypted on the way in, legacy column null either way. `tz` absent still means the legacy Europe/Berlin reading, which is what those rows meant. `syncVersion` comes back at the value the file recorded — restoring at the default handed the next round to any paired device holding a higher number, and the device would then overwrite the recovered entry with what it had. The link rebuild re-creates both arms: rated factors from `factors`, ticked tags from `structuredTags` with a NULL rating. An unresolvable tag key is reported under its own `moodTag` family, beside the rated one, because an operator needs to know which of the two an entry lost. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../api/admin/backups/[id]/restore/route.ts | 77 ++++++++++++++++++- src/components/admin/backups-section.tsx | 3 + src/lib/export/restore-skips.ts | 8 +- src/lib/validations/backup.ts | 18 +++++ 10 files changed, 107 insertions(+), 5 deletions(-) diff --git a/messages/de.json b/messages/de.json index 7468d4f41..455bad74f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Zyklussymptom", "restoreSkippedIllnessSymptom": "Krankheitssymptom", "restoreSkippedMoodFactor": "Stimmungsfaktor", + "restoreSkippedMoodTag": "Stimmungs-Tag", "docsLink": "Dokumentation" }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index 1b9ce1268..2041766c0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Cycle symptom", "restoreSkippedIllnessSymptom": "Illness symptom", "restoreSkippedMoodFactor": "Mood factor", + "restoreSkippedMoodTag": "Mood tag", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index 35193f614..bafa72e6e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Síntoma del ciclo", "restoreSkippedIllnessSymptom": "Síntoma de enfermedad", "restoreSkippedMoodFactor": "Factor de ánimo", + "restoreSkippedMoodTag": "Etiqueta de ánimo", "docsLink": "Documentación" }, "danger-zone": { diff --git a/messages/fr.json b/messages/fr.json index b3df467d1..5324f563a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Symptôme de cycle", "restoreSkippedIllnessSymptom": "Symptôme de maladie", "restoreSkippedMoodFactor": "Facteur d'humeur", + "restoreSkippedMoodTag": "Étiquette d'humeur", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/it.json b/messages/it.json index 7f96ce248..38af4fc16 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Sintomo del ciclo", "restoreSkippedIllnessSymptom": "Sintomo di malattia", "restoreSkippedMoodFactor": "Fattore dell'umore", + "restoreSkippedMoodTag": "Etichetta dell'umore", "docsLink": "Documentazione" }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index 2da87ee8b..702ad81c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Objaw cyklu", "restoreSkippedIllnessSymptom": "Objaw choroby", "restoreSkippedMoodFactor": "Czynnik nastroju", + "restoreSkippedMoodTag": "Tag nastroju", "docsLink": "Dokumentacja" }, "danger-zone": { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3c22417f3..8ede74cac 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -684,6 +684,10 @@ const handler = apiHandler( entry.factors.map((factor) => factor.key), ); const factorKeys = [...new Set(referencedFactorKeys)]; + const referencedTagKeys = payload.moodEntries.flatMap( + (entry) => entry.structuredTags, + ); + const tagKeys = [...new Set(referencedTagKeys)]; // Re-create the account's own tag definitions first. The lookup // below used to ask only for the seeded catalogue (`userId: null`), // so a single custom rated tag made the whole restore throw @@ -740,6 +744,30 @@ const handler = apiHandler( referencedFactorKeys, ); + // The BINARY half of the same taxonomy, looked up the same way and + // reported the same way. It gets its own family because an + // operator reading the report needs to know whether a rated + // factor's score or a ticked tag went missing; they are different + // losses on the same entry. + const tagRows = + tagKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: tagKeys }, + kind: "BINARY", + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id])); + recordUnknownKeys( + skips, + "moodTag", + tagKeys.filter((key) => !tagByKey.has(key)), + referencedTagKeys, + ); + for (const entry of payload.moodEntries) { const moodLoggedAt = new Date(entry.loggedAt); const restoredData = { @@ -747,10 +775,44 @@ const handler = apiHandler( mood: entry.mood, score: entry.score, tags: entry.tags ?? null, + // The note comes back the way every other dual-column note + // does: ciphertext verbatim when the file carries it, and a + // portable file's plaintext re-encrypted on the way in, with + // the legacy column left null either way. + note: null, + noteEncrypted: + entry.noteEncrypted == null + ? encryptNote(entry.note ?? null) + : decodeEncryptedBytes(entry.noteEncrypted), source: entry.source ?? "MOODLOG", externalId: entry.externalId ?? null, moodLoggedAt, + // The zone the `date` string is anchored to. A file written + // before this rode the payload carries nothing, and NULL is + // what such a row meant anyway: the legacy Europe/Berlin + // reading. So an old file keeps its old day boundaries and a + // new one keeps the zone the person actually logged in. + tz: entry.tz ?? null, + // Mood reconciles last-writer-wins by `syncVersion`. Restoring + // at the schema default would hand the next sync round to any + // paired device still holding a higher number, and the device + // would overwrite the recovered row with what it had — a + // restore that loses to the client is worse than one that + // fails. An old file carries nothing here and keeps the + // default, which is the value those rows were written with. + ...(entry.syncVersion === undefined + ? {} + : { syncVersion: entry.syncVersion }), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + ...(entry.syncedAt + ? { syncedAt: new Date(entry.syncedAt) } + : {}), + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + ...(entry.updatedAt + ? { updatedAt: new Date(entry.updatedAt) } + : {}), }; const createData = { userId: ownerId, ...restoredData }; const restored = entry.id @@ -798,8 +860,19 @@ const handler = apiHandler( ] : []; }); - if (factorLinks.length > 0) { - await tx.moodEntryTagLink.createMany({ data: factorLinks }); + // The other arm. A BINARY link carries no score — the presence + // of the row is the signal — so `rating` is NULL by definition, + // which is exactly what the backup's old tag-link filter took to + // mean "nothing here". + const tagLinks = entry.structuredTags.flatMap((key) => { + const moodTagId = tagByKey.get(key); + return moodTagId + ? [{ moodEntryId: restored.id, moodTagId, rating: null }] + : []; + }); + const links = [...factorLinks, ...tagLinks]; + if (links.length > 0) { + await tx.moodEntryTagLink.createMany({ data: links }); } } } diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 2df37779f..76c3acede 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -194,6 +194,9 @@ function catalogueLabel( if (catalogue === "illnessSymptom") { return t("admin.section.backups.restoreSkippedIllnessSymptom"); } + if (catalogue === "moodTag") { + return t("admin.section.backups.restoreSkippedMoodTag"); + } return t("admin.section.backups.restoreSkippedMoodFactor"); } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e57756edd..326ea093b 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -1,8 +1,9 @@ /** * What a restore could not put back, and how much of it there was. * - * Three of the restore's lookups resolve a key against a SEEDED CATALOGUE — - * cycle symptoms, illness symptoms, mood factors. The catalogue is reference + * Four of the restore's lookups resolve a key against a SEEDED CATALOGUE — + * cycle symptoms, illness symptoms, rated mood factors and the mood tags a + * person ticks present or absent. The catalogue is reference * data the instance owns, not data the file carries, so it legitimately drifts * between the day a backup is written and the day it is read: a key gets * renamed, a symptom is retired, the file is a year old, the instance is three @@ -28,7 +29,8 @@ */ /** Which seeded catalogue a key failed to resolve against. */ -export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor"; +export type SkippedCatalogue = + "cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b4afaf650..3f1a981e1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -255,11 +255,29 @@ const moodEntrySchema = z mood: z.string().min(1), score: z.number().int().min(0).max(10), tags: moodEntryTagsSchema, + // The free text, in both storage shapes. A portable export carries the + // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus + // whatever legacy plaintext the row still holds. Same pair, and same + // handling on restore, as `Measurement.notes` / `notesEncrypted`. + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), source: z.string().min(1).optional(), externalId: z.string().nullable().optional(), loggedAt: isoDateTime, + // The IANA zone the `date` string is anchored to. Absent means the legacy + // Europe/Berlin reading, which is what a row written before v1.4.25 means + // by a NULL here — so an old file keeps its old meaning exactly. + tz: z.string().nullable().optional(), + syncedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), factors: z.array(moodFactorSchema).default([]), + // BINARY structured-tag keys. Separate from `factors`, which keeps its + // RATED-only meaning, so every file written before this one parses + // unchanged and restores identically: absent reads as none. + structuredTags: z.array(z.string().min(1)).default([]), }) .passthrough(); From fd2bd67ff31e42f99dc99eb46c58be6c7d1126c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 21:40:41 +0200 Subject: [PATCH 04/10] feat(labs): keep the reference range a report prints beside a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lab runs its own method on its own device, so the window a physician evaluates a value against is the one printed next to it on that report, not a catalog band averaged over every lab. Reading a report pulled the value and dropped that range: the extraction asked for it, the commit handed it to the biomarker resolver, and the resolver only uses it when it mints a NEW marker. For every analyte the user already had — the normal case after a first panel — the printed range was read and then thrown away. Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` to `LabResult` and carry the range from the report to the reading. The text is stored whether or not numbers can be read out of it, because "bis 5,0" and "negativ" state a window too; a range that could not be parsed is kept verbatim, never replaced with invented bounds. `resolveEffectiveReferenceRange` is the one place the precedence lives: the source window wins for that single reading, and the catalog stays the net for readings without one. A printed string with no derivable bound does not displace the catalog. Every surface that judges a reading routes through it — the API verdict, the doctor report, the insights blocks and per-marker assessment, the coach snapshot, the MCP read and the FHIR `referenceRange` — so a value cannot read in range on one surface and out on another. The API keeps `referenceLow` / `referenceHigh` as the window in force, so a client that reads only those two renders the right range and the right verdict with no change of its own; the catalog band, the source window, its origin and a divergence flag ride alongside for clients that want to show both. Read side: the chart draws the source window as a second, visually distinct band with the reading it came from named beneath it, the reading rows name the printed range and the catalog band where the two disagree, and the doctor-report PDF prints the report's own string with a footnote naming the saved range. Entry side: one optional field on the lab form, folded away until asked for, and the printed range is editable on the scan review screen. The range parser covers the notations German and English reports actually print — two-sided, one-sided, comma or dot decimals, thousands separators, trailing units — reads units without ever converting them, and refuses to guess on anything ambiguous. --- docs/api/openapi.yaml | 181 ++++++++++- messages/de.json | 26 +- messages/en.json | 26 +- messages/es.json | 26 +- messages/fr.json | 26 +- messages/it.json | 26 +- messages/pl.json | 26 +- .../migration.sql | 14 + prisma/schema.prisma | 34 +- ...en-outcome-response-consumer-guard.test.ts | 7 + .../api/admin/backups/[id]/restore/route.ts | 3 + .../inbound/[id]/facts/[factId]/route.ts | 1 + src/app/api/insights/labs-changes/route.ts | 6 + src/app/api/labs/[id]/route.ts | 35 ++ src/app/api/labs/ocr/commit/route.ts | 15 +- src/app/api/labs/route.ts | 16 +- .../__tests__/lab-biomarker-detail.test.tsx | 7 + src/components/labs/lab-biomarker-chart.tsx | 129 +++++++- src/components/labs/lab-form.tsx | 73 +++++ src/components/labs/lab-history-list.tsx | 9 +- src/components/labs/lab-list.tsx | 5 + src/components/labs/ocr-review-dialog.tsx | 1 + src/components/labs/ocr-review-types.ts | 3 + src/components/labs/ocr-row-editor.tsx | 26 ++ src/components/labs/source-range-note.tsx | 69 ++++ src/components/labs/types.ts | 22 +- src/components/labs/use-ocr-extract.ts | 1 + src/lib/ai/coach/labs-snapshot.ts | 21 +- .../clinical-records-notes-section.ts | 59 +++- src/lib/doctor-report-types.ts | 15 + src/lib/doctor-report/clinical-records.ts | 22 +- src/lib/documents/commit.ts | 26 +- src/lib/documents/extract.ts | 14 +- src/lib/export/records-backup.ts | 7 + .../fhir/__tests__/lab-observations.test.ts | 25 ++ src/lib/fhir/resources/labs.ts | 17 +- src/lib/fhir/types.ts | 7 + .../insights/__tests__/labs-changes.test.ts | 12 +- src/lib/insights/biomarker-status.ts | 37 ++- .../insights/correlation-channel-series.ts | 3 + src/lib/insights/feature-blocks.ts | 17 +- src/lib/insights/labs-changes.ts | 25 +- .../effective-reference-range.test.ts | 128 ++++++++ .../__tests__/parse-reference-range.test.ts | 219 +++++++++++++ src/lib/labs/__tests__/serialise.test.ts | 3 + src/lib/labs/ocr-extract.ts | 21 +- src/lib/labs/parse-reference-range.ts | 207 ++++++++++++ src/lib/labs/reference-range.ts | 110 +++++++ src/lib/labs/serialise.ts | 196 ++++++++---- src/lib/mcp/rich-reads.ts | 17 +- src/lib/openapi/routes/labs.ts | 17 +- src/lib/openapi/routes/ocr.ts | 17 +- src/lib/validations/backup.ts | 5 + src/lib/validations/inbound-documents.ts | 18 ++ src/lib/validations/labs-ocr.ts | 20 ++ src/lib/validations/labs.ts | 60 ++++ .../lab-source-reference-range.test.ts | 298 ++++++++++++++++++ 57 files changed, 2281 insertions(+), 175 deletions(-) create mode 100644 prisma/migrations/0303_lab_source_reference_range/migration.sql create mode 100644 src/components/labs/source-range-note.tsx create mode 100644 src/lib/labs/__tests__/effective-reference-range.test.ts create mode 100644 src/lib/labs/__tests__/parse-reference-range.test.ts create mode 100644 src/lib/labs/parse-reference-range.ts create mode 100644 tests/integration/lab-source-reference-range.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eddfd2b8c..6ee4395b9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9147,6 +9147,16 @@ paths: type: number referenceHigh: type: number + sourceReferenceLow: + type: number + sourceReferenceHigh: + type: number + sourceReferenceText: + anyOf: + - type: string + maxLength: 120 + - type: string + const: "" takenAt: type: string format: date-time @@ -9254,6 +9264,22 @@ paths: anyOf: - type: number - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - anyOf: + - type: string + maxLength: 120 + - type: string + const: "" + - type: "null" takenAt: type: string format: date-time @@ -9430,6 +9456,12 @@ paths: type: number referenceHigh: type: number + referenceText: + anyOf: + - type: string + maxLength: 120 + - type: string + const: "" takenAt: type: string format: date-time @@ -11616,6 +11648,11 @@ paths: anyOf: - type: number - type: "null" + referenceText: + anyOf: + - type: string + maxLength: 120 + - type: "null" effectiveDate: anyOf: - type: string @@ -32970,6 +33007,30 @@ components: anyOf: - type: number - type: "null" + catalogReferenceLow: + anyOf: + - type: number + - type: "null" + catalogReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - type: string + - type: "null" + referenceOrigin: + $ref: "#/components/schemas/LabReferenceRangeOrigin" + referenceDivergesFromCatalog: + type: boolean takenAt: type: string source: @@ -32992,6 +33053,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - hasNote @@ -32999,12 +33067,31 @@ components: - createdAt - updatedAt additionalProperties: false - description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is - non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links - the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / - `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never - recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the - single-resource GET returns the decrypted `note`. + description: >- + A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; + `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the + user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED + server-side from the Biomarker. + + + Three reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` + are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are + correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / + `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no + numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. + `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the + catalog band state different limits — a surface showing only one of the two is showing a partial answer. The + client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; + `hasNote` flags its presence and the single-resource GET returns the decrypted `note`. + LabReferenceRangeOrigin: + type: string + enum: + - source + - catalog + - none + description: Which window `referenceLow` / `referenceHigh` came from. `source` = the range printed on this reading's own + report, which wins for that reading because it is the window the physician evaluated the value against. + `catalog` = the user's Biomarker band, the net for readings with no printed range. `none` = no window on file. LabReferenceRangeStatus: type: string enum: @@ -33012,9 +33099,9 @@ components: - below - above - unknown - description: "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. - Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and - informative — not an alarming red." + description: "Server-computed, NEUTRAL reference-range verdict, always against `referenceLow` / `referenceHigh` (the + window in force for that reading). `unknown` when no usable bounds are on file. Inclusive bounds: a value on the + limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red." CreateLabResultResponse: type: object properties: @@ -33082,6 +33169,30 @@ components: anyOf: - type: number - type: "null" + catalogReferenceLow: + anyOf: + - type: number + - type: "null" + catalogReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - type: string + - type: "null" + referenceOrigin: + $ref: "#/components/schemas/LabReferenceRangeOrigin" + referenceDivergesFromCatalog: + type: boolean takenAt: type: string source: @@ -33106,6 +33217,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - rangeStatus @@ -33288,6 +33406,14 @@ components: anyOf: - type: number - type: "null" + referenceText: + anyOf: + - type: string + - type: "null" + description: The reference range EXACTLY as printed on the report, verbatim ("3,5 - 5,0", "< 116", "bis 5,0", + "negativ"). `referenceLow` / `referenceHigh` carry the same window reduced to numbers WHEN it reduces to + numbers; this field carries it either way, so a window stated in words is not lost between the report and + the reading. takenAt: anyOf: - type: string @@ -33325,6 +33451,7 @@ components: - unit - referenceLow - referenceHigh + - referenceText - takenAt - confidence - biomarkerMatch @@ -33422,6 +33549,34 @@ components: anyOf: - type: number - type: "null" + catalogReferenceLow: + anyOf: + - type: number + - type: "null" + catalogReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - type: string + - type: "null" + referenceOrigin: + type: string + enum: + - source + - catalog + - none + referenceDivergesFromCatalog: + type: boolean takenAt: type: string source: @@ -33449,6 +33604,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - hasNote @@ -33456,6 +33618,7 @@ components: - createdAt - updatedAt additionalProperties: false + description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. ListBiomarkersEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index bd178a38a..dfdfd8e3e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Familienanamnese", "familyHistoryColRelationship": "Verwandtschaft", "familyHistoryColCondition": "Erkrankung", - "familyHistoryColAgeAtOnset": "Alter bei Beginn" + "familyHistoryColAgeAtOnset": "Alter bei Beginn", + "labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:", + "labsCatalogRangeLabel": "gespeicherter Bereich" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -8037,7 +8039,12 @@ "qualitativePlaceholder": "z. B. negativ", "qualNegative": "negativ", "qualPositive": "positiv", - "qualBorderline": "grenzwertig" + "qualBorderline": "grenzwertig", + "sourceRange": "Bereich laut Befund", + "sourceRangePlaceholder": "z. B. 3,5 - 5,0 oder < 5", + "sourceRangeAdd": "+ Bereich aus dem Befund ergänzen", + "sourceRangeHint": "Optional. Gilt nur für diesen Wert; dein gespeicherter Bereich bleibt unverändert.", + "sourceRangeUnparsed": "Wird so übernommen — daraus ließ sich kein Zahlenbereich lesen." }, "backToLabs": "Zurück zu den Laborwerten", "hasNote": "Notiz", @@ -8068,7 +8075,10 @@ "90": "90 T", "365": "1 J", "all": "Alle" - } + }, + "sourceRangeLabel": "Befundbereich", + "sourceBandLegend": "Zweites Band: der Bereich aus dem Befund vom {date}.", + "sourceBandMixed": "Zweites Band: der Bereich aus dem neuesten Befund. Frühere Befunde im Zeitraum nennen andere Bereiche." }, "biomarker": { "manageTitle": "Biomarker", @@ -8243,11 +8253,17 @@ "fileTooLarge": "Datei ist zu groß (max. 12 MB).", "fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.", "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", - "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch." + "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.", + "refTextLabel": "Bereich wie gedruckt", + "refTextPlaceholder": "z. B. 3,5 - 5,0" }, "loadMoreReadings": "Weitere Werte laden", "documentsLink": "Befund-Dokumente", - "notLinkedYet": "Noch keinem Biomarker zugeordnet" + "notLinkedYet": "Noch keinem Biomarker zugeordnet", + "sourceRange": { + "fromReport": "Befundbereich {range}", + "catalogDiffers": "dein Bereich {range}" + } }, "modules": { "cycle": { diff --git a/messages/en.json b/messages/en.json index 62a606b7c..5b7f7c10d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Family history", "familyHistoryColRelationship": "Relative", "familyHistoryColCondition": "Condition", - "familyHistoryColAgeAtOnset": "Age at onset" + "familyHistoryColAgeAtOnset": "Age at onset", + "labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:", + "labsCatalogRangeLabel": "saved range" }, "errorBoundary": { "title": "Something went wrong", @@ -8037,7 +8039,12 @@ "qualitativePlaceholder": "e.g. negative", "qualNegative": "negative", "qualPositive": "positive", - "qualBorderline": "borderline" + "qualBorderline": "borderline", + "sourceRange": "Range printed on the report", + "sourceRangePlaceholder": "e.g. 3.5 - 5.0 or < 5", + "sourceRangeAdd": "+ Add the range from the report", + "sourceRangeHint": "Optional. Applies to this reading only; your saved range stays as it is.", + "sourceRangeUnparsed": "Kept as written — no numeric range read from it." }, "backToLabs": "Back to lab results", "hasNote": "Note", @@ -8068,7 +8075,10 @@ "90": "90d", "365": "1y", "all": "All" - } + }, + "sourceRangeLabel": "Report range", + "sourceBandLegend": "Second band: the range printed on the report of {date}.", + "sourceBandMixed": "Second band: the range printed on the most recent report. Earlier reports in view state different ranges." }, "biomarker": { "manageTitle": "Biomarkers", @@ -8243,11 +8253,17 @@ "fileTooLarge": "File is too large (max 12 MB).", "fileType": "Upload a JPEG, PNG, WebP, or PDF.", "extractFailed": "Couldn't read the report. Try a clearer photo.", - "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead." + "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.", + "refTextLabel": "Range as printed", + "refTextPlaceholder": "e.g. 3.5 - 5.0" }, "loadMoreReadings": "Load more readings", "documentsLink": "Lab report documents", - "notLinkedYet": "Not linked to a biomarker yet" + "notLinkedYet": "Not linked to a biomarker yet", + "sourceRange": { + "fromReport": "Report range {range}", + "catalogDiffers": "your range {range}" + } }, "modules": { "cycle": { diff --git a/messages/es.json b/messages/es.json index 95921a55c..ba654e6b7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Antecedentes familiares", "familyHistoryColRelationship": "Parentesco", "familyHistoryColCondition": "Afección", - "familyHistoryColAgeAtOnset": "Edad de inicio" + "familyHistoryColAgeAtOnset": "Edad de inicio", + "labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:", + "labsCatalogRangeLabel": "rango guardado" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -8037,7 +8039,12 @@ "qualitativePlaceholder": "p. ej. negativo", "qualNegative": "negativo", "qualPositive": "positivo", - "qualBorderline": "límite" + "qualBorderline": "límite", + "sourceRange": "Rango impreso en el informe", + "sourceRangePlaceholder": "p. ej. 3,5 - 5,0 o < 5", + "sourceRangeAdd": "+ Añadir el rango del informe", + "sourceRangeHint": "Opcional. Se aplica solo a esta lectura; tu rango guardado no cambia.", + "sourceRangeUnparsed": "Se guarda tal cual: no se pudo leer un rango numérico." }, "backToLabs": "Volver a los resultados", "hasNote": "Nota", @@ -8068,7 +8075,10 @@ "90": "90 d", "365": "1 a", "all": "Todo" - } + }, + "sourceRangeLabel": "Rango del informe", + "sourceBandLegend": "Segunda banda: el rango impreso en el informe del {date}.", + "sourceBandMixed": "Segunda banda: el rango impreso en el informe más reciente. Informes anteriores del periodo indican rangos distintos." }, "biomarker": { "manageTitle": "Biomarcadores", @@ -8243,11 +8253,17 @@ "fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).", "fileType": "Sube un JPEG, PNG, WebP o PDF.", "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", - "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar." + "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.", + "refTextLabel": "Rango tal como aparece", + "refTextPlaceholder": "p. ej. 3,5 - 5,0" }, "loadMoreReadings": "Cargar más mediciones", "documentsLink": "Documentos de resultados", - "notLinkedYet": "Aún no vinculado a un biomarcador" + "notLinkedYet": "Aún no vinculado a un biomarcador", + "sourceRange": { + "fromReport": "Rango del informe {range}", + "catalogDiffers": "tu rango {range}" + } }, "modules": { "cycle": { diff --git a/messages/fr.json b/messages/fr.json index 60ceddd0d..88e3e18fa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Antécédents familiaux", "familyHistoryColRelationship": "Lien de parenté", "familyHistoryColCondition": "Affection", - "familyHistoryColAgeAtOnset": "Âge au début" + "familyHistoryColAgeAtOnset": "Âge au début", + "labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :", + "labsCatalogRangeLabel": "plage enregistrée" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -8037,7 +8039,12 @@ "qualitativePlaceholder": "p. ex. négatif", "qualNegative": "négatif", "qualPositive": "positif", - "qualBorderline": "limite" + "qualBorderline": "limite", + "sourceRange": "Plage imprimée sur le compte rendu", + "sourceRangePlaceholder": "p. ex. 3,5 - 5,0 ou < 5", + "sourceRangeAdd": "+ Ajouter la plage du compte rendu", + "sourceRangeHint": "Facultatif. Ne vaut que pour cette mesure ; votre plage enregistrée reste inchangée.", + "sourceRangeUnparsed": "Conservé tel quel : aucune plage numérique n'a pu en être lue." }, "backToLabs": "Retour aux résultats", "hasNote": "Note", @@ -8068,7 +8075,10 @@ "90": "90 j", "365": "1 a", "all": "Tout" - } + }, + "sourceRangeLabel": "Plage du compte rendu", + "sourceBandLegend": "Deuxième bande : la plage imprimée sur le compte rendu du {date}.", + "sourceBandMixed": "Deuxième bande : la plage imprimée sur le compte rendu le plus récent. Des comptes rendus antérieurs de la période indiquent d'autres plages." }, "biomarker": { "manageTitle": "Biomarqueurs", @@ -8243,11 +8253,17 @@ "fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).", "fileType": "Importez un JPEG, PNG, WebP ou PDF.", "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", - "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo." + "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.", + "refTextLabel": "Plage telle qu'imprimée", + "refTextPlaceholder": "p. ex. 3,5 - 5,0" }, "loadMoreReadings": "Charger plus de mesures", "documentsLink": "Documents de résultats", - "notLinkedYet": "Pas encore lié à un biomarqueur" + "notLinkedYet": "Pas encore lié à un biomarqueur", + "sourceRange": { + "fromReport": "Plage du compte rendu {range}", + "catalogDiffers": "votre plage {range}" + } }, "modules": { "cycle": { diff --git a/messages/it.json b/messages/it.json index fac161835..a29739d32 100644 --- a/messages/it.json +++ b/messages/it.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Anamnesi familiare", "familyHistoryColRelationship": "Parentela", "familyHistoryColCondition": "Condizione", - "familyHistoryColAgeAtOnset": "Età di esordio" + "familyHistoryColAgeAtOnset": "Età di esordio", + "labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:", + "labsCatalogRangeLabel": "intervallo salvato" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -8037,7 +8039,12 @@ "qualitativePlaceholder": "es. negativo", "qualNegative": "negativo", "qualPositive": "positivo", - "qualBorderline": "borderline" + "qualBorderline": "borderline", + "sourceRange": "Intervallo stampato sul referto", + "sourceRangePlaceholder": "es. 3,5 - 5,0 oppure < 5", + "sourceRangeAdd": "+ Aggiungi l'intervallo dal referto", + "sourceRangeHint": "Facoltativo. Vale solo per questa misura; l'intervallo salvato resta invariato.", + "sourceRangeUnparsed": "Conservato così com'è: non è stato letto alcun intervallo numerico." }, "backToLabs": "Torna ai risultati", "hasNote": "Nota", @@ -8068,7 +8075,10 @@ "90": "90 g", "365": "1 a", "all": "Tutto" - } + }, + "sourceRangeLabel": "Intervallo del referto", + "sourceBandLegend": "Seconda banda: l'intervallo stampato sul referto del {date}.", + "sourceBandMixed": "Seconda banda: l'intervallo stampato sul referto più recente. Referti precedenti nel periodo indicano intervalli diversi." }, "biomarker": { "manageTitle": "Biomarcatori", @@ -8243,11 +8253,17 @@ "fileTooLarge": "Il file è troppo grande (max 12 MB).", "fileType": "Carica un JPEG, PNG, WebP o PDF.", "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", - "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto." + "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.", + "refTextLabel": "Intervallo come stampato", + "refTextPlaceholder": "es. 3,5 - 5,0" }, "loadMoreReadings": "Carica altre misurazioni", "documentsLink": "Documenti dei referti", - "notLinkedYet": "Non ancora collegato a un biomarcatore" + "notLinkedYet": "Non ancora collegato a un biomarcatore", + "sourceRange": { + "fromReport": "Intervallo del referto {range}", + "catalogDiffers": "il tuo intervallo {range}" + } }, "modules": { "cycle": { diff --git a/messages/pl.json b/messages/pl.json index 8e8b9e4bd..a56973762 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Wywiad rodzinny", "familyHistoryColRelationship": "Pokrewieństwo", "familyHistoryColCondition": "Schorzenie", - "familyHistoryColAgeAtOnset": "Wiek zachorowania" + "familyHistoryColAgeAtOnset": "Wiek zachorowania", + "labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:", + "labsCatalogRangeLabel": "zapisany zakres" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -8037,7 +8039,12 @@ "qualitativePlaceholder": "np. ujemny", "qualNegative": "ujemny", "qualPositive": "dodatni", - "qualBorderline": "graniczny" + "qualBorderline": "graniczny", + "sourceRange": "Zakres wydrukowany na wyniku", + "sourceRangePlaceholder": "np. 3,5 - 5,0 lub < 5", + "sourceRangeAdd": "+ Dodaj zakres z wyniku", + "sourceRangeHint": "Opcjonalne. Dotyczy tylko tego pomiaru; zapisany zakres pozostaje bez zmian.", + "sourceRangeUnparsed": "Zapisane bez zmian — nie odczytano z tego zakresu liczbowego." }, "backToLabs": "Wróć do wyników", "hasNote": "Notatka", @@ -8068,7 +8075,10 @@ "90": "90 d", "365": "1 r", "all": "Wszystko" - } + }, + "sourceRangeLabel": "Zakres z wyniku", + "sourceBandLegend": "Drugie pasmo: zakres wydrukowany na wyniku z {date}.", + "sourceBandMixed": "Drugie pasmo: zakres z najnowszego wyniku. Wcześniejsze wyniki w tym okresie podają inne zakresy." }, "biomarker": { "manageTitle": "Biomarkery", @@ -8243,11 +8253,17 @@ "fileTooLarge": "Plik jest za duży (maks. 12 MB).", "fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", - "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie." + "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.", + "refTextLabel": "Zakres tak jak wydrukowano", + "refTextPlaceholder": "np. 3,5 - 5,0" }, "loadMoreReadings": "Wczytaj więcej pomiarów", "documentsLink": "Dokumenty wyników badań", - "notLinkedYet": "Jeszcze niepowiązane z biomarkerem" + "notLinkedYet": "Jeszcze niepowiązane z biomarkerem", + "sourceRange": { + "fromReport": "Zakres z wyniku {range}", + "catalogDiffers": "twój zakres {range}" + } }, "modules": { "cycle": { diff --git a/prisma/migrations/0303_lab_source_reference_range/migration.sql b/prisma/migrations/0303_lab_source_reference_range/migration.sql new file mode 100644 index 000000000..d4f6644c1 --- /dev/null +++ b/prisma/migrations/0303_lab_source_reference_range/migration.sql @@ -0,0 +1,14 @@ +-- Per-reading source reference range on a lab result. +-- +-- A lab report prints the window its own method and device produce, right +-- beside the value, and that window is what a physician evaluates against. +-- Until now the document reading pulled the value and dropped the range. +-- These three columns carry it: the derived numeric bounds where the printed +-- string yields them, plus the report's verbatim string, which is stored +-- whether or not it parsed. Nothing is backfilled — an existing row genuinely +-- has no source range on file and stays NULL. + +ALTER TABLE "lab_results" + ADD COLUMN "source_reference_low" DOUBLE PRECISION, + ADD COLUMN "source_reference_high" DOUBLE PRECISION, + ADD COLUMN "source_reference_text" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa1aeaa28..7adb641be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5005,9 +5005,41 @@ model LabResult { /// reference bounds + range verdict are meaningless for a qualitative row. valueText String? @map("value_text") unit String - /// Optional reference-range bounds as reported by the lab. + /// Reference-range bounds stamped onto the row at write time. Despite the + /// name these are NOT the lab's printed range: since v1.18.1 every write + /// path copies the linked `Biomarker`'s `lowerBound` / `upperBound` here as + /// historical truth, so a later catalog edit does not silently rewrite a + /// past reading. The range the LAB printed lives in `sourceReference*`. referenceLow Float? @map("reference_low") referenceHigh Float? @map("reference_high") + + /// The reference range as PRINTED ON THE SOURCE REPORT for this one reading. + /// + /// Labs run different methods on different devices, so the window a + /// physician evaluates against is the one printed beside the value on that + /// report — not a catalog band that averages every lab. These three columns + /// carry it: the parsed numeric bounds where a bound could be derived, and + /// `sourceReferenceText` as the report's own verbatim string ("< 5", + /// "bis 5,0", "negativ", "siehe Befund"). The text is kept whenever the + /// source printed one, parseable or not — absence reads as absence, and a + /// window that could not be derived is never invented. + /// + /// Precedence: for a single reading the source bounds win over the catalog + /// band; the catalog stays the net for readings that carry no source range. + /// `resolveEffectiveReferenceRange` in `src/lib/labs/reference-range.ts` is + /// the one place that rule is expressed, and every surface that judges a + /// reading routes through it. + /// + /// Plaintext, not `*Encrypted`: that suffix marks user prose (`noteEncrypted` + /// here, `Biomarker.contextEncrypted` next door). This is a structured + /// lab-report field of exactly the class of the plaintext `analyte` / `unit` + /// / `value` / `valueText` columns beside it — `valueText` already carries + /// the actual clinical RESULT ("positiv") in the clear, so encrypting the + /// window that result is judged against would protect strictly less while + /// making the column unreadable to the SQL the read paths run. + sourceReferenceLow Float? @map("source_reference_low") + sourceReferenceHigh Float? @map("source_reference_high") + sourceReferenceText String? @map("source_reference_text") /// When the sample was taken (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). takenAt DateTime @map("taken_at") diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index 904107571..335b152f5 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -724,6 +724,13 @@ const OCR_EXCEPTIONS: Readonly> = { "inserted.unit": OCR_ROW_EXCEPTION, "inserted.referenceLow": OCR_ROW_EXCEPTION, "inserted.referenceHigh": OCR_ROW_EXCEPTION, + "inserted.catalogReferenceLow": OCR_ROW_EXCEPTION, + "inserted.catalogReferenceHigh": OCR_ROW_EXCEPTION, + "inserted.sourceReferenceLow": OCR_ROW_EXCEPTION, + "inserted.sourceReferenceHigh": OCR_ROW_EXCEPTION, + "inserted.sourceReferenceText": OCR_ROW_EXCEPTION, + "inserted.referenceOrigin": OCR_ROW_EXCEPTION, + "inserted.referenceDivergesFromCatalog": OCR_ROW_EXCEPTION, "inserted.takenAt": OCR_ROW_EXCEPTION, "inserted.source": OCR_ROW_EXCEPTION, "inserted.hasNote": OCR_ROW_EXCEPTION, diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3c22417f3..d57c7fc0f 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -894,6 +894,9 @@ const handler = apiHandler( unit: lab.unit, referenceLow: lab.referenceLow ?? null, referenceHigh: lab.referenceHigh ?? null, + sourceReferenceLow: lab.sourceReferenceLow ?? null, + sourceReferenceHigh: lab.sourceReferenceHigh ?? null, + sourceReferenceText: lab.sourceReferenceText ?? null, takenAt: new Date(lab.takenAt), source: lab.source, noteEncrypted: diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts index 042b1387e..498a7ce25 100644 --- a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts +++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts @@ -57,6 +57,7 @@ function editToFactData(edit: InboundFactEdit): FactData { unit: edit.unit ?? null, referenceLow: edit.referenceLow ?? null, referenceHigh: edit.referenceHigh ?? null, + referenceText: edit.referenceText ?? null, effectiveDate: edit.effectiveDate ?? null, }; return data; diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 452af57da..ddfde9972 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -46,6 +46,9 @@ export const GET = apiHandler(async () => { value: true, referenceLow: true, referenceHigh: true, + sourceReferenceLow: true, + sourceReferenceHigh: true, + sourceReferenceText: true, takenAt: true, }, orderBy: { takenAt: "desc" }, @@ -58,6 +61,9 @@ export const GET = apiHandler(async () => { value: r.value as number, referenceLow: r.referenceLow, referenceHigh: r.referenceHigh, + sourceReferenceLow: r.sourceReferenceLow, + sourceReferenceHigh: r.sourceReferenceHigh, + sourceReferenceText: r.sourceReferenceText, takenAt: r.takenAt, })), ); diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index d143259fb..34afa72a7 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -143,6 +143,12 @@ export const PUT = apiHandler( // historical columns the serialiser ignores. Reject the attempt with a // 422 rather than silently no-op, so the client never believes an edit // landed. Edit such fields on the Biomarker itself instead. + // + // `sourceReference*` is deliberately NOT on that list. The catalog band is + // a property of the marker; the source window is a property of THIS + // reading — it is what one report printed beside one value — so it is + // editable on a linked row and there is nothing on the biomarker to edit + // it on instead. const isLinked = existing.biomarkerId !== null; if ( isLinked && @@ -194,6 +200,15 @@ export const PUT = apiHandler( if (d.unit !== undefined) data.unit = d.unit; if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow; if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh; + if (d.sourceReferenceLow !== undefined) { + data.sourceReferenceLow = d.sourceReferenceLow; + } + if (d.sourceReferenceHigh !== undefined) { + data.sourceReferenceHigh = d.sourceReferenceHigh; + } + if (d.sourceReferenceText !== undefined) { + data.sourceReferenceText = d.sourceReferenceText; + } if (d.takenAt !== undefined) data.takenAt = d.takenAt; // Inverted-range guard for a PARTIAL bound update. The schema-level @@ -212,6 +227,20 @@ export const PUT = apiHandler( errorCode: "labs.update.referenceRangeInvalid", }); } + // The same partial-update hole exists on the source window, and closes the + // same way. + if ( + isInvertedRange( + effectiveBound(d.sourceReferenceLow, existing.sourceReferenceLow), + effectiveBound(d.sourceReferenceHigh, existing.sourceReferenceHigh), + ) + ) { + return apiError( + "sourceReferenceLow must not exceed sourceReferenceHigh", + 422, + { errorCode: "labs.update.sourceReferenceRangeInvalid" }, + ); + } if (d.note !== undefined) { data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null; } @@ -239,6 +268,9 @@ export const PUT = apiHandler( panel: existing.panel, referenceLow: existing.referenceLow, referenceHigh: existing.referenceHigh, + sourceReferenceLow: existing.sourceReferenceLow, + sourceReferenceHigh: existing.sourceReferenceHigh, + sourceReferenceText: existing.sourceReferenceText, takenAt: existing.takenAt, }, after: { @@ -249,6 +281,9 @@ export const PUT = apiHandler( panel: updated.panel, referenceLow: updated.referenceLow, referenceHigh: updated.referenceHigh, + sourceReferenceLow: updated.sourceReferenceLow, + sourceReferenceHigh: updated.sourceReferenceHigh, + sourceReferenceText: updated.sourceReferenceText, takenAt: updated.takenAt, }, redacted: d.note !== undefined ? ["note"] : [], diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index d6bd0b431..b8700562e 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; import { serialiseLabResult } from "@/lib/labs/serialise"; import { linkOcrLabsToVaultDocument, @@ -151,12 +152,19 @@ async function commitOcrRows(request: NextRequest) { } const isQualitative = row.valueText !== undefined; + // The window the scan read off the report. The confirmed numeric bounds + // win over a re-parse of the string (the human may have corrected them on + // the review screen); the string rides along either way. + const printed = parseReferenceRange(row.referenceText, row.unit); + const sourceLow = row.referenceLow ?? printed?.low ?? null; + const sourceHigh = row.referenceHigh ?? printed?.high ?? null; + const sourceText = printed?.text ?? null; const biomarker = await resolveOrMintBiomarker(user.id, { analyte: row.analyte, // A qualitative reading has no numeric unit / range. unit: isQualitative ? (row.unit ?? "") : (row.unit as string), - referenceLow: isQualitative ? null : (row.referenceLow ?? null), - referenceHigh: isQualitative ? null : (row.referenceHigh ?? null), + referenceLow: isQualitative ? null : sourceLow, + referenceHigh: isQualitative ? null : sourceHigh, panel: row.panel ?? null, }); @@ -173,6 +181,9 @@ async function commitOcrRows(request: NextRequest) { unit: biomarker.unit, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, + sourceReferenceLow: isQualitative ? null : sourceLow, + sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceText: isQualitative ? null : sourceText, takenAt: row.takenAt, source: "OCR", noteEncrypted: null, diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 1ab53c06c..60e325499 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -184,6 +184,9 @@ async function postLabResult(request: NextRequest) { unit, referenceLow, referenceHigh, + sourceReferenceLow, + sourceReferenceHigh, + sourceReferenceText, takenAt, note, source, @@ -248,6 +251,13 @@ async function postLabResult(request: NextRequest) { unit: biomarker.unit, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, + // The window this reading's own report printed, kept beside the catalog + // stamp rather than folded into it: it governs THIS reading's verdict, + // and the catalog band stays the net for every reading without one. A + // qualitative reading has no window to record. + sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null), + sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null), + sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null), takenAt, // v1.25 (iOS #36) — provenance: the on-device-OCR path posts // `source: "OCR"`; an omitted field stays "MANUAL" so the legacy @@ -258,6 +268,7 @@ async function postLabResult(request: NextRequest) { }, }); invalidateUserHealthScore(user.id); + const dto = serialiseLabResult(created, biomarker); await auditLog("labResult.create", { userId: user.id, @@ -275,6 +286,9 @@ async function postLabResult(request: NextRequest) { structured: biomarkerId !== undefined, // v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR"). source: source ?? "MANUAL", + // Which window governs this reading's verdict, so a dashboard can see + // how often a report's own range displaces the catalog band. + referenceOrigin: dto.referenceOrigin, }, }); @@ -298,5 +312,5 @@ async function postLabResult(request: NextRequest) { source: "manual", }).catch(() => {}); - return apiSuccess(serialiseLabResult(created, biomarker), 201); + return apiSuccess(dto, 201); } diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index bec4b97b6..a58b764ac 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -64,6 +64,13 @@ function reading(id: string, value: number, takenAt: string): LabResultDto { unit: "mg/dL", referenceLow: 0, referenceHigh: 100, + catalogReferenceLow: 0, + catalogReferenceHigh: 100, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, takenAt, source: "MANUAL", hasNote: false, diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 96a48fad9..db6612d54 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { formatLabValue } from "@/lib/labs/format-value"; +import { formatReferenceRange } from "@/lib/labs/reference-range"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; @@ -32,6 +33,15 @@ import type { LabResultDto } from "./types"; * MUTED primary fill — never a danger colour — honouring the no-alarming- * colour rule. Every reading carries the same y-axis unit because a chart is * one biomarker only. + * + * A SECOND band draws the window a source report printed, when the readings in + * view carry one. It is visually distinct from the catalog band — an outlined + * band in the chart's secondary hue against the catalog's filled primary — and + * both stay muted; the in/out verdict is the badge's job, never the chart's + * colour. The source window is a per-reading fact, so the band is drawn from + * the NEWEST reading in view that carries one and the legend dates it. When + * the readings in view carry more than one distinct printed window the legend + * says so rather than implying one window covered the whole series. */ const RANGE_DAYS = [ @@ -46,6 +56,26 @@ type RangeKey = (typeof RANGE_DAYS)[number]["key"]; interface ChartPoint { timestamp: number; value: number; + /** The window this reading was judged against, for the tooltip. */ + referenceLow: number | null; + referenceHigh: number | null; + referenceOrigin: LabResultDto["referenceOrigin"]; + sourceReferenceText: string | null; +} + +/** The source window drawn as the second band, resolved from the readings in view. */ +interface SourceBand { + low: number | null; + high: number | null; + /** The reading the band was taken from. */ + takenAt: number; + /** True when the readings in view printed more than one distinct window. */ + mixed: boolean; +} + +/** Identity of a printed window, for counting distinct ones. */ +function bandKey(low: number | null, high: number | null): string { + return `${low ?? ""}|${high ?? ""}`; } export function LabBiomarkerChart({ @@ -87,9 +117,37 @@ export function LabBiomarkerChart({ return filtered.map((r) => ({ timestamp: new Date(r.takenAt).getTime(), value: r.value, + referenceLow: r.referenceLow, + referenceHigh: r.referenceHigh, + referenceOrigin: r.referenceOrigin, + sourceReferenceText: r.sourceReferenceText, })); }, [readings, range, nowMs]); + // The source band: the newest in-view reading whose report printed a window + // with usable bounds. Readings without one contribute nothing — an absent + // window is absent, not a reason to widen or narrow the band. + const sourceBand = useMemo(() => { + const withWindow = points.filter( + (p) => + p.referenceOrigin === "source" && + (p.referenceLow !== null || p.referenceHigh !== null), + ); + if (withWindow.length === 0) return null; + const newest = withWindow.reduce((a, b) => + b.timestamp > a.timestamp ? b : a, + ); + const distinct = new Set( + withWindow.map((p) => bandKey(p.referenceLow, p.referenceHigh)), + ); + return { + low: newest.referenceLow, + high: newest.referenceHigh, + takenAt: newest.timestamp, + mixed: distinct.size > 1, + }; + }, [points]); + // Y domain wraps both the data and the reference window so the band is // always visible even when every reading sits inside it. const yDomain = useMemo<[number, number]>(() => { @@ -97,15 +155,23 @@ export function LabBiomarkerChart({ const candidates = [...values]; if (lowerBound != null) candidates.push(lowerBound); if (upperBound != null) candidates.push(upperBound); + // The source band has to fit too, or the second band is drawn off-canvas + // and the chart silently shows one window while claiming two. + if (sourceBand?.low != null) candidates.push(sourceBand.low); + if (sourceBand?.high != null) candidates.push(sourceBand.high); if (candidates.length === 0) return [0, 1]; const min = Math.min(...candidates); const max = Math.max(...candidates); const pad = (max - min || Math.abs(max) || 1) * 0.1; return [min - pad, max + pad]; - }, [points, lowerBound, upperBound]); + }, [points, lowerBound, upperBound, sourceBand]); const animate = !prefersReducedMotion(); const primary = "var(--primary)"; + // A second muted hue from the chart palette. Distinct from the primary band + // by hue AND by treatment (outline vs fill), so the two are told apart + // without relying on colour alone. + const secondary = "var(--chart-2)"; return (
@@ -185,6 +251,42 @@ export function LabBiomarkerChart({ strokeDasharray="4 4" /> ) : null} + {/* The source window — outlined, not filled, so it reads as a + second band over the catalog one rather than a second fill + competing with it. Same no-alarm-colour rule. */} + {sourceBand && + sourceBand.low != null && + sourceBand.high != null ? ( + + ) : null} + {sourceBand && + sourceBand.low != null && + sourceBand.high == null ? ( + + ) : null} + {sourceBand && + sourceBand.high != null && + sourceBand.low == null ? ( + + ) : null} { const active = props.active ?? false; @@ -201,6 +303,22 @@ export function LabBiomarkerChart({ color: primary, }, ]; + // Name the window THIS reading was judged against. Two + // readings on one chart can carry different printed windows, + // so the band alone cannot answer it per point. + if (point.referenceOrigin === "source") { + rows.push({ + name: t("labs.chart.sourceRangeLabel"), + value: + point.sourceReferenceText ?? + `${formatReferenceRange( + point.referenceLow, + point.referenceHigh, + formatLabValue, + )} ${unit}`.trim(), + color: secondary, + }); + } return ( + {sourceBand ? ( +

+ {sourceBand.mixed + ? t("labs.chart.sourceBandMixed") + : t("labs.chart.sourceBandLegend", { + date: fmt.dateShortSmart(new Date(sourceBand.takenAt)), + })} +

+ ) : null}
)} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 476c5da8a..4e865e9dd 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -22,6 +22,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; 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 { useTranslations } from "@/lib/i18n/context"; @@ -118,6 +119,13 @@ export function LabForm({ const [valueText, setValueText] = useState(""); const [takenAt, setTakenAt] = useState(defaultTakenAtValue); 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 + // need it, and a field nobody fills is a field in the way. One text input, + // because that is how a report states it — "3,5 - 5,0", "< 5", "bis 5,0". + // The bounds are derived from what is typed; what is typed is kept verbatim. + const [sourceRangeOpen, setSourceRangeOpen] = useState(false); + const [sourceRange, setSourceRange] = useState(""); // v1.30.1 H3 — which submit button is in flight, so the OTHER one doesn't // also show a spinner. `!== null` replaces the old plain `submitting` // boolean for every disabled check below. @@ -193,11 +201,27 @@ export function LabForm({ setPendingAction(keepOpen ? "saveAndAddAnother" : "save"); try { + // Parse client-side so the user sees the derived window before saving, + // and send BOTH: the bounds where they could be read, the text always. + const printed = isQualitative + ? null + : parseReferenceRange(sourceRange, selected?.unit); const created = await apiPost("/api/labs", { biomarkerId, ...(isQualitative ? { valueText: qualitativeValue } : { value: numericValue }), + ...(printed + ? { + ...(printed.low !== null + ? { sourceReferenceLow: printed.low } + : {}), + ...(printed.high !== null + ? { sourceReferenceHigh: printed.high } + : {}), + sourceReferenceText: printed.text, + } + : {}), takenAt: new Date(takenAt).toISOString(), ...(note.trim() ? { note: note.trim() } : {}), }); @@ -221,6 +245,8 @@ export function LabForm({ setValue(""); setValueText(""); setNote(""); + // The next analyte off the same report has its own printed window. + setSourceRange(""); setError(null); onSavedKeepOpen?.(created); // Return focus to wherever the next entry starts so the flow @@ -252,6 +278,20 @@ export function LabForm({ ) : ""; + // What the typed string resolved to, shown back so the user can see whether + // a window was read out of it before the reading is saved. When none was, + // the hint says the text is kept as written rather than staying silent. + const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit); + const sourceRangeHint = parsedSourceRange + ? parsedSourceRange.low === null && parsedSourceRange.high === null + ? t("labs.form.sourceRangeUnparsed") + : `${formatReferenceRange( + parsedSourceRange.low, + parsedSourceRange.high, + formatLabValue, + )} ${selected?.unit ?? ""}`.trim() + : ""; + const footerNode = ( <> {onCancel ? ( @@ -415,6 +455,39 @@ export function LabForm({

) : null} + {resultType === "numeric" ? ( +
+ {sourceRangeOpen ? ( + <> + + setSourceRange(e.target.value)} + placeholder={t("labs.form.sourceRangePlaceholder")} + maxLength={120} + autoFocus + /> +

+ {sourceRangeHint || t("labs.form.sourceRangeHint")} +

+ + ) : ( + + )} +
+ ) : null} +