diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 482f846e8..1850562a2 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -60094,6 +60094,19 @@ components:
externalId:
description: Echoed back when the entry carried one, for client mapping.
type: string
+ droppedTagKeys:
+ description: "`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's
+ custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every
+ submitted key landed. The entry itself was written."
+ type: array
+ items:
+ type: string
+ droppedFactorKeys:
+ description: "`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor.
+ Present only when non-empty; absent means every submitted factor landed."
+ type: array
+ items:
+ type: string
required:
- index
- status
@@ -60735,6 +60748,19 @@ components:
- $ref: "#/components/schemas/MoodContextOutput"
- type: "null"
description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers.
+ droppedTagKeys:
+ description: "Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived,
+ another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An
+ archived tag the edit preserved is on the entry and is not listed."
+ type: array
+ items:
+ type: string
+ droppedFactorKeys:
+ description: "Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present
+ only when non-empty."
+ type: array
+ items:
+ type: string
required:
- id
- userId
@@ -62999,6 +63025,23 @@ components:
type: array
items:
$ref: "#/components/schemas/OcrExtractedRow"
+ pageCoverage:
+ description: "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total`
+ pages. Absent when the whole document was read."
+ type: object
+ properties:
+ read:
+ type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ total:
+ type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ required:
+ - read
+ - total
+ additionalProperties: false
required:
- reportDate
- providerType
diff --git a/messages/de.json b/messages/de.json
index 606ecda35..365f608ae 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -847,6 +847,7 @@
"formResetConfirmBody": "Damit werden die eingegebene Stimmung, Tags und Notiz gelöscht. Das lässt sich nicht rückgängig machen.",
"formResetConfirmAction": "Zurücksetzen",
"saveError": "Fehler beim Speichern",
+ "tagsNotSaved": "Gespeichert. Tags, die es nicht mehr gibt, wurden weggelassen.",
"deleteError": "Fehler beim Löschen",
"deletedToast": "Eintrag gelöscht",
"restoredToast": "Eintrag wiederhergestellt",
@@ -8992,6 +8993,7 @@
"reviewTitle": "Vor dem Speichern prüfen",
"reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.",
"foundCount": "{count} Werte gefunden",
+ "pagesCapped": "Nur die ersten {read} von {total} Seiten wurden gelesen. Werte auf späteren Seiten fehlen in dieser Liste.",
"confirmRow": "Diesen Wert übernehmen",
"analyteLabel": "Bezeichnung",
"resultLabel": "Ergebnis",
diff --git a/messages/en.json b/messages/en.json
index 72344cb64..e04b37a24 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -847,6 +847,7 @@
"formResetConfirmBody": "This clears the mood, tags and note you have entered. This cannot be undone.",
"formResetConfirmAction": "Reset",
"saveError": "Error saving",
+ "tagsNotSaved": "Saved. Tags that are no longer available were left off.",
"deleteError": "Error deleting",
"deletedToast": "Entry deleted",
"restoredToast": "Entry restored",
@@ -8992,6 +8993,7 @@
"reviewTitle": "Review before saving",
"reviewHint": "Check each reading, then save the ones you want to keep.",
"foundCount": "{count} readings found",
+ "pagesCapped": "Only the first {read} of {total} pages were read. Values on later pages are not in this list.",
"confirmRow": "Include this reading",
"analyteLabel": "Test name",
"resultLabel": "Result",
diff --git a/messages/es.json b/messages/es.json
index d2e383e31..97027af1f 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -847,6 +847,7 @@
"formResetConfirmBody": "Esto borra el estado de ánimo, las etiquetas y la nota que has introducido. No se puede deshacer.",
"formResetConfirmAction": "Restablecer",
"saveError": "Error al guardar",
+ "tagsNotSaved": "Guardado. Se omitieron las etiquetas que ya no están disponibles.",
"deleteError": "Error al eliminar",
"deletedToast": "Entrada eliminada",
"restoredToast": "Entrada restaurada",
@@ -8992,6 +8993,7 @@
"reviewTitle": "Revisar antes de guardar",
"reviewHint": "Revisa cada valor y guarda los que quieras conservar.",
"foundCount": "{count} valores encontrados",
+ "pagesCapped": "Solo se leyeron las primeras {read} de {total} páginas. Los valores de las páginas siguientes no están en esta lista.",
"confirmRow": "Incluir este valor",
"analyteLabel": "Nombre de la prueba",
"resultLabel": "Resultado",
diff --git a/messages/fr.json b/messages/fr.json
index bada54749..ffb3f280a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -847,6 +847,7 @@
"formResetConfirmBody": "Cela efface l’humeur, les tags et la note que vous avez saisis. Cette action est irréversible.",
"formResetConfirmAction": "Réinitialiser",
"saveError": "Erreur lors de l’enregistrement",
+ "tagsNotSaved": "Enregistré. Les étiquettes qui ne sont plus disponibles ont été omises.",
"deleteError": "Erreur lors de la suppression",
"deletedToast": "Entrée supprimée",
"restoredToast": "Entrée restaurée",
@@ -8992,6 +8993,7 @@
"reviewTitle": "Vérifier avant d'enregistrer",
"reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.",
"foundCount": "{count} valeurs trouvées",
+ "pagesCapped": "Seules les {read} premières pages sur {total} ont été lues. Les valeurs des pages suivantes ne figurent pas dans cette liste.",
"confirmRow": "Inclure cette valeur",
"analyteLabel": "Nom de l'analyse",
"resultLabel": "Résultat",
diff --git a/messages/it.json b/messages/it.json
index 2ba28ca6d..66e7ee9db 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -847,6 +847,7 @@
"formResetConfirmBody": "Questo cancella umore, tag e nota inseriti. L’operazione non può essere annullata.",
"formResetConfirmAction": "Reimposta",
"saveError": "Errore durante il salvataggio",
+ "tagsNotSaved": "Salvato. I tag non più disponibili sono stati omessi.",
"deleteError": "Errore durante l’eliminazione",
"deletedToast": "Voce eliminata",
"restoredToast": "Voce ripristinata",
@@ -8992,6 +8993,7 @@
"reviewTitle": "Controlla prima di salvare",
"reviewHint": "Controlla ogni valore, poi salva quelli da conservare.",
"foundCount": "{count} valori trovati",
+ "pagesCapped": "Sono state lette solo le prime {read} pagine su {total}. I valori delle pagine successive non sono in questo elenco.",
"confirmRow": "Includi questo valore",
"analyteLabel": "Nome dell'esame",
"resultLabel": "Risultato",
diff --git a/messages/ko.json b/messages/ko.json
index b1fb0504d..fd7eb9f96 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -847,6 +847,7 @@
"formResetConfirmBody": "입력한 기분, 태그, 메모가 모두 지워져요. 되돌릴 수 없어요.",
"formResetConfirmAction": "초기화",
"saveError": "저장하지 못했어요",
+ "tagsNotSaved": "저장했습니다. 더 이상 사용할 수 없는 태그는 제외했습니다.",
"deleteError": "삭제하지 못했어요",
"deletedToast": "기록을 삭제했어요",
"restoredToast": "기록을 되살렸어요",
@@ -8992,6 +8993,7 @@
"reviewTitle": "저장 전에 확인하기",
"reviewHint": "측정값을 하나씩 확인하고, 남길 항목만 저장하세요.",
"foundCount": "측정값 {count}개를 찾았어요",
+ "pagesCapped": "전체 {total}쪽 중 처음 {read}쪽만 읽었습니다. 이후 쪽의 값은 이 목록에 없습니다.",
"confirmRow": "이 측정값 포함",
"analyteLabel": "검사 항목",
"resultLabel": "결과",
diff --git a/messages/pl.json b/messages/pl.json
index f3c2c7b3b..122c19159 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -847,6 +847,7 @@
"formResetConfirmBody": "Spowoduje to usunięcie wprowadzonego nastroju, tagów i notatki. Tej operacji nie można cofnąć.",
"formResetConfirmAction": "Resetuj",
"saveError": "Błąd zapisu",
+ "tagsNotSaved": "Zapisano. Pominięto tagi, które nie są już dostępne.",
"deleteError": "Błąd usuwania",
"deletedToast": "Wpis usunięty",
"restoredToast": "Wpis przywrócony",
@@ -8992,6 +8993,7 @@
"reviewTitle": "Sprawdź przed zapisaniem",
"reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.",
"foundCount": "Znaleziono {count} wyników",
+ "pagesCapped": "Odczytano tylko pierwsze {read} z {total} stron. Wartości z dalszych stron nie ma na tej liście.",
"confirmRow": "Uwzględnij ten wynik",
"analyteLabel": "Nazwa badania",
"resultLabel": "Wynik",
diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts
new file mode 100644
index 000000000..b7461f861
--- /dev/null
+++ b/src/__tests__/resolved-field-consumer-guard.test.ts
@@ -0,0 +1,289 @@
+/**
+ * Server-resolved fields are read, never rebuilt from their ingredients.
+ *
+ * Some answers depend on more than the browser can see: the record on screen
+ * (which may be a managed or shared one, not the signed-in account), the
+ * person's own targets, the operator's switches, the user's clock. The server
+ * resolves each of them once and publishes the result; a client that rebuilds
+ * one from the parts it happens to hold gets a plausible answer that is wrong
+ * in exactly the cases nobody tests. Three of those shipped at once:
+ *
+ * - the blood-pressure chart drew its target zones from `getBpTargets()` on
+ * the signed-in account's date of birth, losing the user's own band and,
+ * inside a managed record, using the viewer's age;
+ * - the intake pickers preselected on `active`, so an ended course could be
+ * the default dose;
+ * - Settings → Coach gated its tuning on the account's raw `disableCoach`,
+ * which answers for the viewer, not for the record.
+ *
+ * Each entry below names one resolved field and the ingredient patterns that
+ * rebuild it. A match in client code (`src/components`, `src/hooks`, and every
+ * `"use client"` module under `src/app` outside the API) fails, unless the
+ * exact file is allowlisted with the reason it is not a re-derivation — the
+ * switch that edits a stored value has to read that value.
+ *
+ * The guard cannot pass by reading nothing: the walk carries a floor, every
+ * allowlisted file must still match (a stale entry fails), every rule's
+ * pattern is checked against a sample of the shape it exists to catch, and
+ * the total match count across the tree must be non-zero.
+ *
+ * Its honest limit: the matchers are line-shaped. A re-derivation spread over
+ * several statements (an `active` check on one line, the `trackIntake` check
+ * on the next) is not seen. The rules catch the shapes that shipped; review
+ * catches the rest.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { stripComments, walkSourceFiles } from "./helpers/source-files";
+
+const ROOT = process.cwd();
+
+interface ResolvedFieldRule {
+ /** The server-resolved field clients must read. */
+ field: string;
+ /** Where the resolved value comes from. */
+ servedBy: string;
+ /** Ingredient shapes that rebuild the field in the browser. */
+ patterns: RegExp[];
+ /** A line each pattern must match, proving the matcher is live. */
+ samples: string[];
+ /**
+ * A line that reads the resolved field as well is not a rebuild of it; the
+ * rule skips lines matching this.
+ */
+ unlessLine?: RegExp;
+ /** Files (relative to the repo root) allowed to match, and why. */
+ allow: Record;
+ /**
+ * When set, the rule reads exactly these files instead of the client tree:
+ * for a pattern too common to ban everywhere (`.active`) that is a rebuild
+ * only where a dose is being offered. Each file must exist.
+ */
+ scope?: string[];
+}
+
+const RULES: ResolvedFieldRule[] = [
+ {
+ field: "blood-pressure target band",
+ servedBy:
+ "`/api/insights/targets` (BLOOD_PRESSURE range + bpDiastolic.range), resolved for the record on screen with the user's own band first",
+ patterns: [
+ /\bgetBpTargets\s*\(/,
+ /\bgetBpTargetsByAge\s*\(/,
+ /\bresolveEffectiveBpTargets\s*\(/,
+ /\bresolveBpTargetOverride\s*\(/,
+ ],
+ samples: [
+ "const t = getBpTargets(new Date(user.dateOfBirth));",
+ "getBpTargetsByAge(age, sex)",
+ "resolveEffectiveBpTargets(profile, thresholds)",
+ "resolveBpTargetOverride(profile, thresholds)",
+ ],
+ allow: {},
+ },
+ {
+ field: "intakeActionable / courseStatus",
+ servedBy:
+ "the medication list and detail reads (`src/lib/medications/intake-actionable.ts`)",
+ patterns: [
+ // Offering a dose from `active` and `trackIntake` alone.
+ /\bactive\b[^;\n]*\btrackIntake\b/,
+ /\btrackIntake\b[^;\n]*\bactive\b/,
+ // Course state from the end date against the clock.
+ /\b(endsOn|endDate)\b[^;\n]*(<=?|>=?)[^;\n]*\b(now|today|Date\.now\(\)|new Date\(\))/,
+ /\b(now|today|Date\.now\(\)|new Date\(\))[^;\n]*(<=?|>=?)[^;\n]*\b(endsOn|endDate)\b/,
+ // The server's own resolvers, imported into the browser.
+ /\bcourseStatusAt\s*\(/,
+ /\bresolveIntakeActionability\s*\(/,
+ ],
+ samples: [
+ "const canTake = med.active && med.trackIntake !== false;",
+ "if (m.trackIntake && m.active) offer();",
+ "const ended = med.endsOn != null && new Date(med.endsOn) < new Date();",
+ "const ended = today > endsOn;",
+ "courseStatusAt(med, now, tz)",
+ "resolveIntakeActionability(med, now, tz)",
+ ],
+ unlessLine: /\bintakeActionable\b|\bcourseStatus\b/,
+ allow: {},
+ },
+ {
+ field: "intakeActionable in the dose pickers' default",
+ servedBy:
+ "`intakeActionable` on the medication list read (`pickDefaultMedicationId`)",
+ // Listing active medications is fine anywhere (the dialog still lists
+ // ended courses for back-filling). Choosing the dose to OFFER from
+ // `active` is the rebuild, and these are the files that choose it.
+ scope: [
+ "src/lib/medications/default-medication.ts",
+ "src/components/medications/log-intake-dialog.tsx",
+ "src/components/dashboard/medication-intake-quick-add.tsx",
+ ],
+ patterns: [/\.active\b/],
+ samples: ["const actives = options.filter((m) => m.active);"],
+ unlessLine: /\bintakeActionable\b/,
+ allow: {},
+ },
+ {
+ field: "ai capability (coach and the rest)",
+ servedBy:
+ "`ai.capabilities` on `/api/auth/me`, read through `useAiCapability`",
+ patterns: [
+ /\.disableCoach\b/,
+ /\bflags\??\.coach\b/,
+ /from\s+["']@\/lib\/ai\/capabilities\/(resolve|load|gate|egress)["']/,
+ ],
+ samples: [
+ "const coachEnabled = !user?.disableCoach;",
+ "if (flags?.coach && x) show();",
+ 'import { resolveAiCapability } from "@/lib/ai/capabilities/resolve";',
+ ],
+ allow: {
+ "src/components/settings/ai/disable-coach-card.tsx":
+ "the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value",
+ "src/components/settings/modules-section.tsx":
+ "the module toggle row for the Coach: its checked state is the stored opt-out it writes",
+ "src/components/settings/managed-record-settings-section.tsx":
+ "the managed record's own settings form: the field's default is the record's stored opt-out",
+ "src/hooks/use-auth.ts":
+ "the `/me` payload normaliser: coerces the field against a stale payload, decides nothing",
+ },
+ },
+ {
+ field: "module surfaces",
+ servedBy:
+ "`modules` on `/api/auth/me` (record-scoped, grant-masked), read through `isSurfaceVisible` / `useNavModules`",
+ patterns: [
+ /\bisModuleEnabled\s*\(/,
+ /\bresolveModuleMap\s*\(/,
+ /\bmoduleAvailability\b/,
+ ],
+ samples: [
+ "if (await isModuleEnabled(userId, 'cycle')) {}",
+ "const modules = await resolveModuleMap(user.id);",
+ "const off = user.moduleAvailability?.cycle === false;",
+ ],
+ allow: {
+ "src/components/settings/modules-section.tsx":
+ "the Modules hub: it shows the operator-off state of each toggle row, which is what `moduleAvailability` is published for",
+ "src/hooks/use-auth.ts":
+ "the `/me` payload normaliser: types and coerces the field, decides nothing",
+ },
+ },
+];
+
+function isClientModule(rel: string, source: string): boolean {
+ if (rel.startsWith("src/components/") || rel.startsWith("src/hooks/")) {
+ return true;
+ }
+ return /^\s*["']use client["']/.test(stripComments(source).trimStart());
+}
+
+function clientFiles(): Array<{ rel: string; code: string }> {
+ const roots: Array<{ dir: string; floor: number }> = [
+ { dir: "src/components", floor: 900 },
+ { dir: "src/hooks", floor: 50 },
+ { dir: "src/app", floor: 900 },
+ ];
+ const out: Array<{ rel: string; code: string }> = [];
+ for (const { dir, floor } of roots) {
+ for (const file of walkSourceFiles(join(ROOT, dir), { floor })) {
+ const rel = `${dir}/${file}`;
+ if (rel.startsWith("src/app/api/")) continue;
+ if (rel.includes("/__tests__/") || /\.test\.tsx?$/.test(rel)) continue;
+ const source = readFileSync(join(ROOT, rel), "utf8");
+ if (!isClientModule(rel, source)) continue;
+ out.push({ rel, code: stripComments(source) });
+ }
+ }
+ return out;
+}
+
+const FILES = clientFiles();
+
+function scopedFiles(scope: string[]): Array<{ rel: string; code: string }> {
+ return scope.map((rel) => ({
+ rel,
+ code: stripComments(readFileSync(join(ROOT, rel), "utf8")),
+ }));
+}
+
+function matchesFor(rule: ResolvedFieldRule): Map {
+ const hits = new Map();
+ for (const { rel, code } of rule.scope ? scopedFiles(rule.scope) : FILES) {
+ code.split("\n").forEach((line, index) => {
+ if (rule.unlessLine?.test(line)) return;
+ if (!rule.patterns.some((pattern) => pattern.test(line))) return;
+ const list = hits.get(rel) ?? [];
+ list.push(`${rel}:${index + 1}: ${line.trim()}`);
+ hits.set(rel, list);
+ });
+ }
+ return hits;
+}
+
+describe("resolved-field consumer guard", () => {
+ it("walks a client tree of the size it expects", () => {
+ // Components, hooks and the `"use client"` pages come to roughly 885
+ // modules; a set far below that means the directive check or the walk
+ // broke, not that the tree got cleaner.
+ expect(FILES.length).toBeGreaterThan(700);
+ });
+
+ for (const rule of RULES) {
+ describe(rule.field, () => {
+ it("every pattern still matches the shape it exists to catch", () => {
+ for (const sample of rule.samples) {
+ expect(
+ rule.patterns.some((pattern) => pattern.test(sample)),
+ `no pattern matched the sample: ${sample}`,
+ ).toBe(true);
+ expect(rule.unlessLine?.test(sample) ?? false).toBe(false);
+ }
+ // And each pattern earns its place with at least one sample.
+ for (const pattern of rule.patterns) {
+ expect(
+ rule.samples.some((sample) => pattern.test(sample)),
+ `pattern ${pattern} has no sample`,
+ ).toBe(true);
+ }
+ });
+
+ it(`is read from ${rule.servedBy}, never rebuilt in client code`, () => {
+ const hits = matchesFor(rule);
+ const offenders = [...hits.entries()]
+ .filter(([rel]) => !(rel in rule.allow))
+ .flatMap(([, lines]) => lines);
+ expect(
+ offenders,
+ `client code rebuilds "${rule.field}" from its ingredients. ` +
+ `Read the server's resolved value instead (${rule.servedBy}), ` +
+ `or allowlist the file with the reason it is not a rebuild.`,
+ ).toEqual([]);
+ });
+
+ it("has no stale allowlist entry", () => {
+ const hits = matchesFor(rule);
+ const stale = Object.keys(rule.allow).filter((rel) => !hits.has(rel));
+ expect(
+ stale,
+ "allowlisted files that no longer match — remove the entry",
+ ).toEqual([]);
+ });
+ });
+ }
+
+ it("reads a non-empty set of matches across the tree", () => {
+ // The allowlisted readers are real matches; a guard whose matchers found
+ // nothing anywhere would pass on any tree.
+ const total = RULES.reduce(
+ (sum, rule) =>
+ sum + [...matchesFor(rule).values()].reduce((n, l) => n + l.length, 0),
+ 0,
+ );
+ expect(total).toBeGreaterThanOrEqual(6);
+ });
+});
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 2dfa2bc58..e150408eb 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -346,11 +346,12 @@ export const GET = apiHandler(async () => {
// defaults rather than to `undefined`, so the switch is what renders.
// No extra query — the session already loads the row this column is on.
notificationPrefs: parseNotificationPrefs(user.notificationPrefs ?? null),
- // v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
- // column is absent (partial-deploy rollback safety, see migration
- // 0078 commentary). Every Coach mount point on the client checks
- // `user.disableCoach` BELOW the operator-level `flags.coach`
- // short-circuit; both gates must agree to paint the affordance.
+ // v1.4.47 W3 — the caller's own stored Coach opt-out, published for the
+ // switch that edits it. Default `false` if the column is absent
+ // (partial-deploy rollback safety, see migration 0078 commentary). It is
+ // not a gate: whether the Coach shows for the record on screen is the
+ // resolved `ai.capabilities.coach` and `modules.coach` below, which fold
+ // in the record's own opt-out, the operator's switches and the grant.
disableCoach: user.disableCoach ?? false,
// v1.7.0 — health-record export identity fields. All optional.
fullName: user.fullName ?? null,
diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts
index 4dcd20385..742700fb7 100644
--- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts
+++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts
@@ -229,11 +229,15 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
vi.mocked(rasterizePdf).mockResolvedValue({
ok: true,
images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }],
+ pageCount: 1,
});
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(pdfReq());
expect(res.status).toBe(200);
+ // The whole PDF was read: no coverage note on the response.
+ const whole = (await res.json()) as { data: Record };
+ expect(whole.data).not.toHaveProperty("pageCoverage");
expect(rasterizePdf).toHaveBeenCalledOnce();
// The rendered page images flow through as `input_image`s; no native PDF
// document block is sent for a non-Anthropic provider.
@@ -245,6 +249,26 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
);
});
+ it("says on the response when only the first pages of a long PDF were read", async () => {
+ const tenPages = Array.from({ length: 10 }, () => ({
+ mediaType: "image/jpeg" as const,
+ dataBase64: "cGFnZQ==",
+ }));
+ vi.mocked(rasterizePdf).mockResolvedValue({
+ ok: true,
+ images: tenPages,
+ pageCount: 23,
+ });
+ vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
+
+ const res = await POST(pdfReq());
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { pageCoverage?: { read: number; total: number } };
+ };
+ expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 });
+ });
+
it("hands the slot back when the upload is not an image or PDF", async () => {
const form = new FormData();
form.append(
diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts
index 16ca28416..bb770f3b4 100644
--- a/src/app/api/labs/ocr/extract/route.ts
+++ b/src/app/api/labs/ocr/extract/route.ts
@@ -374,6 +374,7 @@ async function handleVisionExtract(
dataBase64: string;
}[];
let documents: { mediaType: "application/pdf"; dataBase64: string }[];
+ let pageCoverage: { read: number; total: number } | undefined;
if (mime === "application/pdf") {
if (pick.pdfSupported) {
@@ -404,6 +405,20 @@ async function handleVisionExtract(
}
images = raster.images;
documents = [];
+ // A PDF longer than the page cap is read from its first pages only.
+ // That used to live in the wide event alone; the person reviewing the
+ // rows is the one who needs to know the rest of the report was not
+ // read.
+ if (raster.pageCount > raster.images.length) {
+ pageCoverage = {
+ read: raster.images.length,
+ total: raster.pageCount,
+ };
+ annotate({
+ action: { name: "labs.ocr.pagesCapped" },
+ meta: { read: raster.images.length, total: raster.pageCount },
+ });
+ }
}
} else {
images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }];
@@ -431,7 +446,7 @@ async function handleVisionExtract(
0,
{ servedBy: pick.entry.providerType, reservedOwner: reservation.owner },
);
- return apiSuccess(result);
+ return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result);
} catch (err) {
// Provider/extraction failure — the call may still have burned tokens, so
// reconcile against the reserved estimate rather than refunding in full.
diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts
index 14ccdb37e..e79f93e6c 100644
--- a/src/app/api/mcp/tokens/route.ts
+++ b/src/app/api/mcp/tokens/route.ts
@@ -2,7 +2,9 @@
* MCP connector tokens — list + mint.
*
* The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT
- * the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the
+ * one of the write scopes the other token surfaces mint — the per-medication
+ * ingest pair `medication:ingest` + `medication::ingest`,
+ * `measurements:write`, `workouts:write`, `documents:write` — and NEVER the
* `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual /
* stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same
* `health:read` scope automatically for cloud connectors. Both surface here so
diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts
index f698a3a84..5b442dc17 100644
--- a/src/app/api/meta/capabilities/route.ts
+++ b/src/app/api/meta/capabilities/route.ts
@@ -109,11 +109,13 @@ export const GET = apiHandler(async () => {
// Bearer scope. All sourced from the canonical `rest.ts` constants.
restBaseUrl: "/api/fhir",
// The face checks a narrow `fhir:read` scope, and NO code path mints a
- // token carrying it — every mint grants a wildcard, the medication
- // ingest scope, or an MCP scope. So the face is reachable by the owner's
- // cookie session or a wildcard device token, and by nothing else. It is
- // reported that way rather than advertising a credential a client cannot
- // obtain.
+ // token carrying it — every mint grants a wildcard (login, passkey,
+ // refresh), the per-medication ingest pair (`medication:ingest` +
+ // `medication::ingest`), `measurements:write`, `workouts:write`,
+ // `documents:write`, or an MCP scope (`health:read`, `health:write`).
+ // So the face is reachable by the owner's cookie session or a wildcard
+ // device token, and by nothing else. It is reported that way rather than advertising a credential a
+ // client cannot obtain.
scopeMintable: false,
resourceTypes: FHIR_REST_RESOURCE_TYPES,
operations: [FHIR_EVERYTHING_OPERATION],
diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts
index 3570b2b13..fc01e1444 100644
--- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts
+++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts
@@ -126,8 +126,8 @@ beforeEach(() => {
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 });
txClient.moodContext.findUnique.mockResolvedValue(null);
- vi.mocked(replaceTagLinks).mockResolvedValue(undefined);
- vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined);
+ vi.mocked(replaceTagLinks).mockResolvedValue([]);
+ vi.mocked(replaceRatedFactorLinks).mockResolvedValue([]);
// v1.7.0 sync — PUT now looks the row up via `findFirst` with a
// `deletedAt: null` guard (refuses to resurrect-edit a tombstone).
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue(
@@ -316,6 +316,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => {
};
vi.mocked(replaceTagLinks).mockImplementationOnce(async () => {
draft.binary = ["calm"];
+ return [];
});
vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => {
draft.rated = [{ key: "factor_work", rating: 5 }];
diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts
index 94cb5f59e..816293996 100644
--- a/src/app/api/mood-entries/[id]/route.ts
+++ b/src/app/api/mood-entries/[id]/route.ts
@@ -25,6 +25,7 @@ import {
RatedFactorOutOfRangeError,
replaceRatedFactorLinks,
replaceTagLinks,
+ droppedLinkKeysForWire,
} from "@/lib/mood/tag-links";
type RouteParams = { params: Promise<{ id: string }> };
@@ -197,11 +198,15 @@ export const PUT = apiHandler(
// Omission preserves the corresponding link set. Explicit null/empty
// clears it, matching the validated update contract.
- if (data.tagKeys !== undefined) {
- await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx);
- }
+ // Each replacement returns the submitted keys it could not store,
+ // reported on the response rather than dropped in silence.
+ const droppedTagKeys =
+ data.tagKeys !== undefined
+ ? await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx)
+ : [];
+ let droppedFactorKeys: string[] = [];
if (data.ratedFactors !== undefined) {
- await replaceRatedFactorLinks(
+ droppedFactorKeys = await replaceRatedFactorLinks(
id,
user.id,
data.ratedFactors ?? [],
@@ -236,6 +241,7 @@ export const PUT = apiHandler(
return {
entry: updated,
contextOutcome,
+ droppedKeys: { droppedTagKeys, droppedFactorKeys },
persistedContext: storedContext,
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
@@ -282,6 +288,7 @@ export const PUT = apiHandler(
persistedRatedFactors,
persistedContext,
contextOutcome,
+ droppedKeys,
} = transactionOutcome.result;
await auditLog("moodEntry.update", {
@@ -315,7 +322,12 @@ export const PUT = apiHandler(
annotate({
action: { name: "mood-entries.update" },
- meta: { moodEntryId: id, mood_context: contextOutcome },
+ meta: {
+ moodEntryId: id,
+ mood_context: contextOutcome,
+ dropped_tag_keys: droppedKeys.droppedTagKeys.length,
+ dropped_factor_keys: droppedKeys.droppedFactorKeys.length,
+ },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
@@ -360,6 +372,10 @@ export const PUT = apiHandler(
ratedFactors: persistedRatedFactors,
// v1.38 — the stored context after the edit, or null when there is none.
context: persistedContext ? contextForWire(persistedContext) : null,
+ // Submitted keys that are not on the entry after the edit, each list
+ // present only when non-empty (an archived link the edit preserved is
+ // on the entry and is not listed).
+ ...droppedLinkKeysForWire(droppedKeys),
});
},
);
diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts
index 3af7aca11..08f0b7de7 100644
--- a/src/app/api/mood-entries/__tests__/route.test.ts
+++ b/src/app/api/mood-entries/__tests__/route.test.ts
@@ -66,8 +66,10 @@ vi.mock("@/lib/mood/tag-links", async () => {
"@/lib/mood/tag-links",
);
return {
- createTagLinks: vi.fn().mockResolvedValue(undefined),
+ createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS),
RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError,
+ droppedLinkKeysForWire: actual.droppedLinkKeysForWire,
+ NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS,
};
});
@@ -137,7 +139,10 @@ beforeEach(() => {
(fn as (tx: typeof txClient) => unknown)(txClient),
);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
- vi.mocked(createTagLinks).mockResolvedValue(undefined);
+ vi.mocked(createTagLinks).mockResolvedValue({
+ droppedTagKeys: [],
+ droppedFactorKeys: [],
+ });
// `reset` blanks the post-commit best-effort mock; restore the promise
// return so `recompute(...)` awaits.
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
@@ -349,6 +354,37 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", ()
expect(body.data.tagKeys).toEqual(["happy"]);
});
+ it("names the keys it did not store on the 201, and omits the fields when all landed", async () => {
+ txClient.moodEntry.create.mockResolvedValue({
+ id: "mood-1",
+ tags: null,
+ moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
+ mood: "GUT",
+ note: null,
+ source: "MANUAL",
+ date: "2026-06-01",
+ });
+ txClient.moodEntryTagLink.findMany.mockResolvedValue([
+ { moodTag: { key: "happy" } },
+ ]);
+ vi.mocked(createTagLinks).mockResolvedValueOnce({
+ droppedTagKeys: ["custom:archived"],
+ droppedFactorKeys: [],
+ });
+
+ const res = await POST(
+ postReq({ ...VALID_BODY, tagKeys: ["happy", "custom:archived"] }),
+ );
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as { data: Record };
+ expect(body.data.droppedTagKeys).toEqual(["custom:archived"]);
+ expect(body.data).not.toHaveProperty("droppedFactorKeys");
+
+ const clean = await POST(postReq({ ...VALID_BODY, tagKeys: ["happy"] }));
+ const cleanBody = (await clean.json()) as { data: Record };
+ expect(cleanBody.data).not.toHaveProperty("droppedTagKeys");
+ });
+
it("rolls the entry back when the tag-link write fails (no commit)", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-2",
diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts
index e897edbdc..35907e376 100644
--- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts
+++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts
@@ -55,8 +55,10 @@ vi.mock("@/lib/mood/tag-links", async () => {
"@/lib/mood/tag-links",
);
return {
- createTagLinks: vi.fn().mockResolvedValue(undefined),
+ createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS),
RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError,
+ droppedLinkKeysForWire: actual.droppedLinkKeysForWire,
+ NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS,
};
});
@@ -140,7 +142,10 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
- vi.mocked(createTagLinks).mockResolvedValue(undefined);
+ vi.mocked(createTagLinks).mockResolvedValue({
+ droppedTagKeys: [],
+ droppedFactorKeys: [],
+ });
});
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
@@ -317,6 +322,45 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
);
});
+ it("lists the keys that did not land on that entry's result, and only there", async () => {
+ vi.mocked(createTagLinks)
+ .mockResolvedValueOnce({
+ droppedTagKeys: ["custom:archived"],
+ droppedFactorKeys: ["factor_retired"],
+ })
+ .mockResolvedValueOnce({ droppedTagKeys: [], droppedFactorKeys: [] });
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ mood: "GUT",
+ moodLoggedAt: "2026-05-16T08:00:00.000Z",
+ tagKeys: ["movies", "custom:archived"],
+ ratedFactors: [{ key: "factor_retired", rating: 2 }],
+ },
+ {
+ mood: "OKAY",
+ moodLoggedAt: "2026-05-16T09:00:00.000Z",
+ tagKeys: ["movies"],
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const json = (await res.json()) as {
+ data: { entries: Array> };
+ };
+ expect(json.data.entries[0]).toMatchObject({
+ status: "inserted",
+ droppedTagKeys: ["custom:archived"],
+ droppedFactorKeys: ["factor_retired"],
+ });
+ // Nothing dropped: the fields are absent, so the result reads exactly
+ // as it did before they existed.
+ expect(json.data.entries[1]).not.toHaveProperty("droppedTagKeys");
+ expect(json.data.entries[1]).not.toHaveProperty("droppedFactorKeys");
+ });
+
it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => {
const res = await POST(
postReq({
diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts
index 3a504fa71..d22415e71 100644
--- a/src/app/api/mood-entries/bulk/route.ts
+++ b/src/app/api/mood-entries/bulk/route.ts
@@ -58,7 +58,11 @@ import { persistMoodContext } from "@/lib/mood/context";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
-import { createTagLinks } from "@/lib/mood/tag-links";
+import {
+ createTagLinks,
+ droppedLinkKeysForWire,
+ NO_DROPPED_LINK_KEYS,
+} from "@/lib/mood/tag-links";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -73,8 +77,9 @@ const bulkEntrySchema = z.object({
* Without this the bulk path Zod-stripped the field, so iOS-sent
* taxonomy links were silently dropped on the adopt-on-pair backfill.
* The server resolves each key to a `MoodTag` row and writes the
- * `MoodEntryTagLink` join; unknown keys are dropped silently (the
- * catalog is the source of truth). Bounds match the single-entry
+ * `MoodEntryTagLink` join; an unknown or archived key is not stored (the
+ * catalog is the source of truth) and is listed on that entry's result
+ * as `droppedTagKeys`. Bounds match the single-entry
* `structuredTagKeys` schema so one entry can't fan out an unbounded
* link set.
*/
@@ -178,6 +183,11 @@ interface EntryResult {
// result so iOS can map a server row id onto its local SwiftData row
// without re-deriving it. Omitted when the entry sent no externalId.
externalId?: string;
+ // Submitted keys the catalog did not resolve (unknown, archived, another
+ // account's custom tag, or the wrong kind). Present only when non-empty,
+ // on an "inserted" or "duplicate" result: the entry landed, these did not.
+ droppedTagKeys?: string[];
+ droppedFactorKeys?: string[];
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
@@ -251,6 +261,8 @@ async function postBulk(request: NextRequest): Promise {
const results: EntryResult[] = [];
let inserted = 0;
let duplicates = 0;
+ let droppedTagKeyCount = 0;
+ let droppedFactorKeyCount = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
@@ -437,7 +449,7 @@ async function postBulk(request: NextRequest): Promise {
// the row committed) left a persisted mood entry with no links while the
// per-entry catch reported the entry as "skipped" — the client believes
// nothing saved and a half-written row is on disk.
- const result = await prisma.$transaction(async (tx) => {
+ const written = await prisma.$transaction(async (tx) => {
const upserted = await tx.moodEntry.upsert({
where: probeWhere,
create: {
@@ -489,11 +501,14 @@ async function postBulk(request: NextRequest): Promise {
// an entry reported as skipped really did write nothing.
await persistMoodContext(tx, upserted.id, user.id, parsedContext.data);
+ // The keys the catalog did not resolve are reported on this entry's
+ // result: "inserted" alone told a client its tags had landed.
+ let droppedKeys = NO_DROPPED_LINK_KEYS;
if (
(entry.tagKeys && entry.tagKeys.length > 0) ||
(entry.ratedFactors && entry.ratedFactors.length > 0)
) {
- await createTagLinks(
+ droppedKeys = await createTagLinks(
upserted.id,
user.id,
entry.tagKeys ?? [],
@@ -502,8 +517,12 @@ async function postBulk(request: NextRequest): Promise {
);
}
- return upserted;
+ return { upserted, droppedKeys };
});
+ const result = written.upserted;
+ const droppedKeys = written.droppedKeys;
+ droppedTagKeyCount += droppedKeys.droppedTagKeys.length;
+ droppedFactorKeyCount += droppedKeys.droppedFactorKeys.length;
if (existing) {
duplicates += 1;
@@ -512,6 +531,7 @@ async function postBulk(request: NextRequest): Promise {
status: "duplicate",
id: result.id,
...(entry.externalId ? { externalId: entry.externalId } : {}),
+ ...droppedLinkKeysForWire(droppedKeys),
});
} else {
inserted += 1;
@@ -520,6 +540,7 @@ async function postBulk(request: NextRequest): Promise {
status: "inserted",
id: result.id,
...(entry.externalId ? { externalId: entry.externalId } : {}),
+ ...droppedLinkKeysForWire(droppedKeys),
});
}
} catch (err: unknown) {
@@ -586,6 +607,8 @@ async function postBulk(request: NextRequest): Promise {
inserted,
duplicates,
skipped: skipped.length,
+ dropped_tag_keys: droppedTagKeyCount,
+ dropped_factor_keys: droppedFactorKeyCount,
...(unstableShapes.length > 0
? unstableExternalIdMeta("mood.bulk", unstableShapes)
: {}),
diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts
index 86b14901a..9f23a965f 100644
--- a/src/app/api/mood-entries/route.ts
+++ b/src/app/api/mood-entries/route.ts
@@ -33,6 +33,8 @@ import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import {
createTagLinks,
+ droppedLinkKeysForWire,
+ NO_DROPPED_LINK_KEYS,
RatedFactorOutOfRangeError,
} from "@/lib/mood/tag-links";
@@ -219,6 +221,7 @@ async function postMoodEntry(request: NextRequest) {
persistedRatedFactors,
persistedContext,
contextOutcome,
+ droppedKeys,
} = await prisma.$transaction(async (tx) => {
// v1.12.1 — when the client supplies a source-stable `externalId`,
// upsert on the NULL-distinct `(userId, source, externalId)` key so
@@ -287,14 +290,16 @@ async function postMoodEntry(request: NextRequest) {
},
});
+ let droppedKeys = NO_DROPPED_LINK_KEYS;
if (
(tagKeys && tagKeys.length > 0) ||
(ratedFactors && ratedFactors.length > 0)
) {
- // Unknown / non-RATED keys are dropped inside the helper (the
- // catalog is the source of truth). An out-of-scale rating
- // throws `RatedFactorOutOfRangeError`, rolling the tx back.
- await createTagLinks(
+ // Unknown, archived or non-RATED keys are not stored (the catalog
+ // is the source of truth); the helper returns them so the response
+ // can say so. An out-of-scale rating throws
+ // `RatedFactorOutOfRangeError`, rolling the tx back.
+ droppedKeys = await createTagLinks(
created.id,
user.id,
tagKeys ?? [],
@@ -333,6 +338,7 @@ async function postMoodEntry(request: NextRequest) {
return {
entry: created,
contextOutcome,
+ droppedKeys,
persistedContext: storedContext,
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
@@ -360,7 +366,13 @@ async function postMoodEntry(request: NextRequest) {
// boolean, because "the request said nothing" and "the request cleared
// it" are different acts and a dashboard that folded them together
// could not tell an abandoned section from a deliberate one.
- meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome },
+ meta: {
+ moodEntryId: entry.id,
+ mood,
+ mood_context: contextOutcome,
+ dropped_tag_keys: droppedKeys.droppedTagKeys.length,
+ dropped_factor_keys: droppedKeys.droppedFactorKeys.length,
+ },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
@@ -398,6 +410,10 @@ async function postMoodEntry(request: NextRequest) {
// when the entry carries none, which is a different answer from an
// object full of nulls and reads as one.
context: persistedContext ? contextForWire(persistedContext) : null,
+ // The submitted keys that were not stored (unknown, archived, or the
+ // wrong kind), each list present only when non-empty. Without them
+ // a 201 read as "everything landed" when part of it had not.
+ ...droppedLinkKeysForWire(droppedKeys),
},
201,
);
diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx
index d2ad8102e..98df95c45 100644
--- a/src/app/insights/blood-pressure/page.tsx
+++ b/src/app/insights/blood-pressure/page.tsx
@@ -18,7 +18,7 @@ import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
-import { getBpTargets } from "@/lib/analytics/bp-targets";
+import { useBpTargetBand } from "@/lib/queries/use-bp-target-band";
/**
* v1.4.25 W4 — `/insights/blood-pressure`.
@@ -26,7 +26,8 @@ import { getBpTargets } from "@/lib/analytics/bp-targets";
* Routed Blood-Pressure sub-page. The mother page hosts the overview
* (hero + briefing + advisor); this page concentrates on BP-only depth:
* the BP chart with its chart-cog overlays, the per-section AI
- * assessment, and the chart's target zones tied to the user's age.
+ * assessment, and the chart's target zones — the band the server resolved
+ * for the record on screen (the user's own target, else the age default).
*
* v1.4.27 F17 — when the user has zero BP observations, the page
* short-circuits to an empty-state CTA pointing at
@@ -57,6 +58,7 @@ export default function InsightsBlutdruckPage() {
// series at once: the single chart reports per-type visible-range stats and
// each strip column reads its own half.
const { statsByType, onVisibleStats } = useChartDomainStats();
+ const bpTargets = useBpTargetBand();
if (isEmpty) {
return (
@@ -82,13 +84,11 @@ export default function InsightsBlutdruckPage() {
);
}
- const bpTargets =
- user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
const bpTargetZones = bpTargets
? [
{
- min: bpTargets.sysLow,
- max: bpTargets.sysHigh,
+ min: bpTargets.systolic.min,
+ max: bpTargets.systolic.max,
color: "var(--chart-3)",
opacity: 0.21,
label: t("charts.systolic"),
@@ -96,8 +96,8 @@ export default function InsightsBlutdruckPage() {
lineOpacity: 0.24,
},
{
- min: bpTargets.diaLow,
- max: bpTargets.diaHigh,
+ min: bpTargets.diastolic.min,
+ max: bpTargets.diastolic.max,
color: "var(--info)",
opacity: 0.21,
label: t("charts.diastolic"),
diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx
index 73bd791ae..23df5de21 100644
--- a/src/app/medications/page-client.tsx
+++ b/src/app/medications/page-client.tsx
@@ -665,6 +665,7 @@ export default function MedicationsPageClient() {
name: m.name,
dose: m.dose,
active: m.active,
+ intakeActionable: m.intakeActionable === true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
nextDueAt: m.nextDueAt,
diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx
index 343bedae8..9b0ca7e92 100644
--- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx
+++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx
@@ -98,7 +98,9 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => {
it("returns null when no active medications exist", () => {
expect(pickDefaultMedicationId([])).toBeNull();
expect(
- pickDefaultMedicationId([makeMed("a", { active: false })]),
+ pickDefaultMedicationId([
+ makeMed("a", { active: false, intakeActionable: false }),
+ ]),
).toBeNull();
});
diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx
index 29ae37927..91676c106 100644
--- a/src/components/dashboard/medication-intake-quick-add.tsx
+++ b/src/components/dashboard/medication-intake-quick-add.tsx
@@ -32,6 +32,7 @@ import {
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds";
/**
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
@@ -122,6 +123,9 @@ export function MedicationIntakeQuickAdd({
// logged-out mounts behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || DEFAULT_TIMEZONE;
+ // The account's own late / missed thresholds decide which dose counts as
+ // due for the preselection, the same tiers the medication cards show.
+ const dueThresholds = useMedicationDueThresholds();
// The record this dose lands in, or null in the caller's own.
const recordName = useActiveRecordName();
@@ -188,7 +192,7 @@ export function MedicationIntakeQuickAdd({
medications,
medicationOverride,
selectionNow,
- undefined,
+ dueThresholds,
userTz,
) ?? "";
const selectedMedication = medications.find((m) => m.id === medicationId);
diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx
index 1ef88b1c9..852746337 100644
--- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx
+++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx
@@ -10,6 +10,7 @@ import { ApiError } from "@/lib/api/api-fetch";
import {
extractErrorMessage,
handleFilePickerChange,
+ OcrPageCoverageNote,
OcrReviewDialog,
} from "../ocr-review-dialog";
@@ -201,3 +202,24 @@ describe("extractErrorMessage", () => {
);
});
});
+
+describe(" — a long PDF read from its first pages", () => {
+ it("says how many of the pages were read", () => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toContain('data-slot="ocr-page-coverage"');
+ expect(html).toContain("Only the first 10 of 23 pages were read.");
+ });
+
+ it("renders nothing when the whole document was read", () => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx
index 0a0b0a17c..707da5efa 100644
--- a/src/components/labs/ocr-review-dialog.tsx
+++ b/src/components/labs/ocr-review-dialog.tsx
@@ -138,6 +138,11 @@ export function OcrReviewDialog({
// because the person has not filled it in yet. Once shown they follow the
// edits, so fixing a field clears its mark at once.
const [showErrors, setShowErrors] = useState(false);
+ // Set when the server read only the first pages of a long PDF.
+ const [pageCoverage, setPageCoverage] = useState<{
+ read: number;
+ total: number;
+ } | null>(null);
const [focusRequest, setFocusRequest] = useState(0);
const reviewListRef = useRef(null);
@@ -184,6 +189,7 @@ export function OcrReviewDialog({
function reset() {
setStage("pick");
setShowErrors(false);
+ setPageCoverage(null);
setRows([]);
setPickedFile(null);
setVisitId(null);
@@ -207,6 +213,7 @@ export function OcrReviewDialog({
return;
}
setRows(seeded);
+ setPageCoverage(data.pageCoverage ?? null);
setStage("review");
},
onError: (err) => toast.error(extractErrorMessage(err, t)),
@@ -353,6 +360,8 @@ export function OcrReviewDialog({
{t("labs.ocr.foundCount", { count: rows.length })}
+
+
{showErrors && rowErrors.size > 0 ? (
);
}
+
+/**
+ * The server read only the first pages of a long PDF (`pageCoverage` on the
+ * extract response). Said on the review screen, beside the rows, because the
+ * person checking the list is the one who would otherwise assume the whole
+ * report was read. Renders nothing when the whole document was read.
+ */
+export function OcrPageCoverageNote({
+ coverage,
+}: {
+ coverage: { read: number; total: number } | null;
+}) {
+ const { t } = useTranslations();
+ if (!coverage) return null;
+ return (
+
+ {t("labs.ocr.pagesCapped", {
+ read: coverage.read,
+ total: coverage.total,
+ })}
+
+ );
+}
diff --git a/src/components/medications/__tests__/log-intake-dialog.test.tsx b/src/components/medications/__tests__/log-intake-dialog.test.tsx
index 2388cc65d..538e47e25 100644
--- a/src/components/medications/__tests__/log-intake-dialog.test.tsx
+++ b/src/components/medications/__tests__/log-intake-dialog.test.tsx
@@ -3,8 +3,15 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
+// The reminder-thresholds read: `undefined` = not loaded (the 120 / 240
+// defaults apply), otherwise the account's own tiers.
+let mockThresholds:
+ | { lateMinutes: number; missedMinutes: number; lowStockRunwayDays: null }
+ | undefined;
+
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ useQuery: () => ({ data: mockThresholds }),
}));
vi.mock("sonner", () => ({
@@ -49,6 +56,7 @@ function medication(
name: `Medication ${id}`,
dose: "5 mg",
active: true,
+ intakeActionable: true,
schedules: [],
lastTakenAt: null,
todayEventCount: 0,
@@ -82,6 +90,37 @@ describe(" medication default", () => {
afterEach(() => {
vi.useRealTimers();
+ mockThresholds = undefined;
+ });
+
+ it("grades the due dose by the account's own missed threshold", () => {
+ // 10:00 in Berlin, five hours after a 04:00–05:00 window closed. Under the
+ // 240-minute default the dose is past missed and the alphabetical
+ // fallback wins; an account that keeps a six-hour missed threshold still
+ // sees it as very late, so it is the default.
+ const meds = [
+ medication("first", { name: "Alpha" }),
+ medication("late", {
+ name: "Zulu",
+ nextDueAt: undefined,
+ schedules: [
+ {
+ windowStart: "04:00",
+ windowEnd: "05:00",
+ daysOfWeek: null,
+ label: null,
+ dose: null,
+ },
+ ],
+ }),
+ ];
+ expectSelected(renderDialog(meds), "first");
+ mockThresholds = {
+ lateMinutes: 120,
+ missedMinutes: 360,
+ lowStockRunwayDays: null,
+ };
+ expectSelected(renderDialog(meds), "late");
});
it("selects an overdue current-window medication ahead of array order", () => {
const html = renderDialog([
diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx
index 411ef022f..82df0513e 100644
--- a/src/components/medications/log-intake-dialog.tsx
+++ b/src/components/medications/log-intake-dialog.tsx
@@ -45,6 +45,7 @@ import {
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds";
interface LogIntakeSchedule extends ScheduleWindowInput {
label: string | null;
@@ -122,6 +123,9 @@ export function LogIntakeDialog({
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || DEFAULT_TIMEZONE;
+ // The account's own late / missed thresholds decide which dose counts as
+ // due for the preselection, the same tiers the medication cards show.
+ const dueThresholds = useMedicationDueThresholds();
const formId = useId();
const [medicationOverride, setMedicationOverride] = useState(
@@ -133,7 +137,7 @@ export function LogIntakeDialog({
medications,
medicationOverride,
selectionNow,
- undefined,
+ dueThresholds,
userTz,
) ?? "";
const [slot, setSlot] = useState(NO_SLOT);
diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx
index 23b18f965..9b14f4697 100644
--- a/src/components/mood/mood-form.tsx
+++ b/src/components/mood/mood-form.tsx
@@ -63,6 +63,10 @@ import {
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
+import {
+ droppedAnyLinkKeys,
+ type DroppedLinkKeysWire,
+} from "@/lib/mood/dropped-link-keys";
import { moodFaceIcon } from "./mood-tag-icons";
// One catalogue for the chip strip and for every server-side reader of what it
// writes. Split apart, the write side gained a locale and the read side did
@@ -219,7 +223,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const trimmedNote = note.trim();
- await apiPost("/api/mood-entries", {
+ const saved = await apiPost("/api/mood-entries", {
mood,
tags: tags.length > 0 ? tags : undefined,
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
@@ -243,6 +247,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
await invalidateKeys(queryClient, moodDependentKeys);
await refetchInactiveDailyReads(queryClient);
toast.success(t("common.saved"));
+ // The entry landed, but a tag picked here may have been archived or
+ // deleted elsewhere since the picker loaded; the server says so.
+ if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved"));
onSuccess?.();
} catch (err) {
setError(localizedApiError(err, t, "mood.saveError"));
diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx
index 3f2144d57..6d68cd3a2 100644
--- a/src/components/mood/mood-list.tsx
+++ b/src/components/mood/mood-list.tsx
@@ -112,6 +112,10 @@ import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { MOOD_LIST_PAGE_SIZE as PAGE_SIZE } from "@/lib/mood/list-page-size";
import { localizedApiError } from "@/lib/api/localized-error";
+import {
+ droppedAnyLinkKeys,
+ type DroppedLinkKeysWire,
+} from "@/lib/mood/dropped-link-keys";
// Re-export the score map under the legacy local name to keep the
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
@@ -471,7 +475,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
note: string | null;
moodLoggedAt: string;
}) => {
- await apiPut(`/api/mood-entries/${id}`, {
+ return apiPut(`/api/mood-entries/${id}`, {
mood,
tags,
tagKeys,
@@ -488,7 +492,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
moodLoggedAt,
});
},
- onSuccess: async () => {
+ onSuccess: async (saved) => {
+ // A tag picked in the dialog may have been archived or deleted
+ // elsewhere since it opened; the server names what it did not store.
+ if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved"));
await invalidateKeys(queryClient, moodDependentKeys);
await refetchInactiveDailyReads(queryClient);
setEditSeed({
diff --git a/src/components/settings/__tests__/coach-memory-while-off.test.tsx b/src/components/settings/__tests__/coach-memory-while-off.test.tsx
index 96eecaca1..ffa3ee4b0 100644
--- a/src/components/settings/__tests__/coach-memory-while-off.test.tsx
+++ b/src/components/settings/__tests__/coach-memory-while-off.test.tsx
@@ -24,6 +24,8 @@ vi.mock("@/hooks/use-auth", () => ({
role: "USER",
timezone: "Europe/Berlin",
disableCoach: authState.disableCoach,
+ // The server's module map mirrors the record's opt-out.
+ modules: { coach: !authState.disableCoach },
},
isAuthenticated: true,
isLoading: false,
diff --git a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx
index 6a2011886..21af3b076 100644
--- a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx
+++ b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx
@@ -34,6 +34,8 @@ vi.mock("@/hooks/use-auth", () => ({
role: "USER",
timezone: "Europe/Berlin",
disableCoach: !coach.available,
+ // The record's module map, which the section's gate reads.
+ modules: { coach: coach.available },
},
isAuthenticated: true,
isLoading: false,
diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx
index f24d59fff..20c9689d2 100644
--- a/src/components/settings/__tests__/coach-section.test.tsx
+++ b/src/components/settings/__tests__/coach-section.test.tsx
@@ -14,6 +14,13 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/settings/coach",
}));
+// Past hydration only where a test says so: the gate reads the account once
+// mounted, and the pre-hydration pass shows every card.
+const mountState = { mounted: false };
+vi.mock("@/hooks/use-mounted", () => ({
+ useMounted: () => mountState.mounted,
+}));
+
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
useInfiniteQuery: () => ({ data: undefined, isLoading: false }),
@@ -31,10 +38,18 @@ vi.mock("@/hooks/use-ai-capability", () => ({
}),
}));
-const authState: { disableCoach: boolean } = { disableCoach: false };
+const authState: {
+ disableCoach: boolean;
+ modules: { coach?: boolean } | undefined;
+} = { disableCoach: false, modules: undefined };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
- user: { id: "u1", role: "USER", disableCoach: authState.disableCoach },
+ user: {
+ id: "u1",
+ role: "USER",
+ disableCoach: authState.disableCoach,
+ modules: authState.modules,
+ },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
@@ -83,3 +98,24 @@ describe(" — SSR smoke", () => {
expect(html).not.toContain("settings.coach.activate.");
});
});
+
+describe(" — the tuning follows the record, not the account", () => {
+ it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => {
+ // A guardian with the Coach on, inside a managed record that has it off:
+ // `/me` publishes the viewer's own `disableCoach` and the RECORD's module
+ // map. The record's answer decides.
+ mountState.mounted = true;
+ authState.disableCoach = false;
+ authState.modules = { coach: false };
+ try {
+ expect(render()).not.toContain('id="coach-nudge"');
+ authState.disableCoach = true;
+ authState.modules = { coach: true };
+ expect(render()).toContain('id="coach-nudge"');
+ } finally {
+ mountState.mounted = false;
+ authState.disableCoach = false;
+ authState.modules = undefined;
+ }
+ });
+});
diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx
index e4a0ccc83..631029590 100644
--- a/src/components/settings/coach-section.tsx
+++ b/src/components/settings/coach-section.tsx
@@ -17,8 +17,12 @@
* 4. Coach memory — durable-fact review + forget controls.
*
* The nav entry is module-gated on `coach`. The preference + nudge cards
- * keep their own content gate on `!user.disableCoach`: hiding the Coach hides
- * its tuning. The memory (facts, stored conversations and reminders) is never
+ * keep their own content gate on the same published module state
+ * (`user.modules.coach`, resolved by the server for the record on screen
+ * from that record's opt-out and the operator's switches): hiding the Coach
+ * hides its tuning. The raw `disableCoach` column is the signed-in person's
+ * own, so inside a managed or shared record it answered for the wrong
+ * person. The memory (facts, stored conversations and reminders) is never
* gated: it is the person's own record, readable and deletable whatever the
* Coach's state. The reminders card turns read-only while the Coach is
* unavailable and, with the Coach hidden, shows only when rows exist.
@@ -39,6 +43,7 @@ import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
+import { isSurfaceVisible } from "@/lib/modules/surface";
export function CoachSection() {
const { isAuthenticated, user } = useAuth();
@@ -47,11 +52,13 @@ export function CoachSection() {
// `useMounted()` to keep the hydration render matching the SSR HTML.
const mounted = useMounted();
const authed = mounted && isAuthenticated;
- // The preference + memory cards ride the same content gate as the rest
- // of the Coach surface: when the user has hidden the Coach there is no
- // assistant to tune or remember anything. (`mounted`-gated so the SSR +
- // hydration renders agree.)
- const coachEnabled = !mounted || !user?.disableCoach;
+ // The preference + nudge cards ride the same content gate as the rest of
+ // the Coach surface: when the record has the Coach hidden there is no
+ // assistant to tune. Read from the server's module map for the record,
+ // never recomputed from the account's own columns. (`mounted`-gated so the
+ // SSR + hydration renders agree.)
+ const coachEnabled =
+ !mounted || isSurfaceVisible("settings:coach", user?.modules);
return (
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 65096bd96..dae619122 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -144,13 +144,14 @@ export interface AuthUser {
*/
dateFormat: DateFormatPreference;
/**
- * v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach
- * mount point (``, ``, the
- * inline `` pill, the `/targets` page CTA)
- * renders nothing. The gate sits BELOW the operator-level
- * `flags.coach` short-circuit — both must agree to render the
- * affordance. Defaults to `false` when the field is absent (e.g.
- * stale /me payload from a partial-deploy rollback).
+ * v1.4.47 W3 — the signed-in person's own stored Coach opt-out. It is
+ * the value the Activate-Coach switch and the module toggle edit, and
+ * nothing else should read it: it belongs to the account, not to the
+ * record on screen, and it is only one of the layers that decide whether
+ * the Coach is there. Every Coach surface gates on the resolved answers
+ * instead — `ai.capabilities.coach` (`useAiCapability("coach")`) for
+ * the affordances, `modules.coach` for the Coach's settings and tuning.
+ * Defaults to `false` when the field is absent (a stale /me payload).
*/
disableCoach: boolean;
/**
diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts
index 0c46b89fc..16fdd2587 100644
--- a/src/lib/analytics/effective-range.ts
+++ b/src/lib/analytics/effective-range.ts
@@ -371,6 +371,26 @@ export function resolveEffectiveBpTargets(
};
}
+/**
+ * The user's own blood-pressure band, or `null` when they have not set one.
+ *
+ * `resolveEffectiveBpTargets` answers "which band applies" and falls back to
+ * the age default; this answers "did the person pick it", which is what a
+ * surface needs to label the band as their own rather than as the guideline.
+ * A one-sided override (systolic only) still counts: the other half is the
+ * age default, exactly as `resolveEffectiveBpTargets` fills it.
+ */
+export function resolveBpTargetOverride(
+ profile: UserProfileForRange,
+ thresholdsJson: unknown,
+): BpTargets | null {
+ const hasOverride =
+ resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_SYS") != null ||
+ resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_DIA") != null;
+ if (!hasOverride) return null;
+ return resolveEffectiveBpTargets(profile, thresholdsJson);
+}
+
/**
* Convenience: resolve every supported metric at once. Useful for /targets
* and the insight generator.
diff --git a/src/lib/documents/__tests__/rasterize-pdf.test.ts b/src/lib/documents/__tests__/rasterize-pdf.test.ts
index a00340e8a..cb124491b 100644
--- a/src/lib/documents/__tests__/rasterize-pdf.test.ts
+++ b/src/lib/documents/__tests__/rasterize-pdf.test.ts
@@ -90,6 +90,9 @@ describe("rasterizePdf", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.images).toHaveLength(RASTER_MAX_PAGES);
+ // The page count travels with the result, so a caller can say the
+ // reading stopped short.
+ expect(result.pageCount).toBe(RASTER_MAX_PAGES + 4);
});
it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => {
diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts
index 402f61695..f61e74fc3 100644
--- a/src/lib/documents/rasterize-pdf.ts
+++ b/src/lib/documents/rasterize-pdf.ts
@@ -74,7 +74,16 @@ export interface RasterImage {
* would not render" (`render-failed` — malformed / encrypted / zero pages).
*/
export type RasterResult =
- | { ok: true; images: RasterImage[] }
+ | {
+ ok: true;
+ images: RasterImage[];
+ /**
+ * Pages in the PDF. Above `images.length` when the page cap cut the
+ * render short, which a caller showing the result has to say: the
+ * reading covers the first pages only.
+ */
+ pageCount: number;
+ }
| { ok: false; reason: "unsupported" | "render-failed" };
// Minimal structural types for the slice of the pdfjs API this module uses. A
@@ -212,7 +221,7 @@ export async function rasterizePdf(
action: { name: "documents.rasterize.ok" },
meta: { pages: images.length, cappedFrom: doc.numPages },
});
- return { ok: true, images };
+ return { ok: true, images, pageCount: doc.numPages };
} catch (err) {
annotate({
action: { name: "documents.rasterize.failed" },
diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts
index 49f3fa8ea..c20559389 100644
--- a/src/lib/google-health/__tests__/mappers.test.ts
+++ b/src/lib/google-health/__tests__/mappers.test.ts
@@ -34,6 +34,7 @@ import {
mapRespiratoryRate,
mapRestingHeartRate,
mapSleepSession,
+ mapSleepSessionDetailed,
mapSteps,
mapVo2Max,
mapWeight,
@@ -376,6 +377,37 @@ describe("mapVo2Max — daily-vo2-max summary", () => {
});
});
+describe("mapSleepSessionDetailed — unknown stage labels are counted", () => {
+ it("reports how many segments it skipped and which labels they carried", () => {
+ const session = mapSleepSessionDetailed({
+ sleep: {
+ stages: [
+ {
+ type: "DEEP",
+ startTime: "2026-06-02T02:00:00.000Z",
+ endTime: "2026-06-02T02:45:00.000Z",
+ },
+ {
+ type: "SLEEP_STAGE_TYPE_UNSPECIFIED",
+ startTime: "2026-06-02T04:00:00.000Z",
+ endTime: "2026-06-02T04:10:00.000Z",
+ },
+ {
+ type: "SLEEP_STAGE_TYPE_UNSPECIFIED",
+ startTime: "2026-06-02T04:10:00.000Z",
+ endTime: "2026-06-02T04:20:00.000Z",
+ },
+ ],
+ },
+ });
+ expect(session.rows).toHaveLength(1);
+ expect(session.unknownStageSegments).toBe(2);
+ expect(session.unknownStageLabels).toEqual([
+ "SLEEP_STAGE_TYPE_UNSPECIFIED",
+ ]);
+ });
+});
+
describe("mapSleepSession — per-stage segments", () => {
it("emits one SLEEP_DURATION row per known stage, skipping unknown labels", () => {
const rows = mapSleepSession({
diff --git a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts
index 4be90b284..4a9d99813 100644
--- a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts
+++ b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts
@@ -54,6 +54,7 @@ vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: maybeEnqueueMorningRefreshMock,
}));
+import { annotate } from "@/lib/logging/context";
import { syncUserSleep } from "../sync-sleep";
beforeEach(() => {
@@ -72,6 +73,8 @@ beforeEach(() => {
],
windowStart: new Date("2026-06-01T22:00:00.000Z"),
windowEnd: sleepMeasuredAt,
+ unknownStageSegments: 0,
+ unknownStageLabels: [],
});
upsertMock.mockImplementation(
async (
@@ -125,6 +128,8 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => {
],
windowStart: new Date("2026-06-01T22:00:00.000Z"),
windowEnd: sleepMeasuredAt,
+ unknownStageSegments: 0,
+ unknownStageLabels: [],
});
await syncUserSleep("user-1");
@@ -135,3 +140,28 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => {
expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", []);
});
});
+
+describe("google-health syncUserSleep — segments with an unknown stage", () => {
+ it("counts them on the wide event with their labels instead of dropping them silently", async () => {
+ fetchDataPointsMock.mockResolvedValue([{ raw: "a" }, { raw: "b" }]);
+ mapSleepSessionDetailedMock.mockReturnValue({
+ rows: [],
+ windowStart: null,
+ windowEnd: null,
+ unknownStageSegments: 2,
+ unknownStageLabels: ["SLEEP_STAGE_TYPE_UNSPECIFIED"],
+ });
+
+ await syncUserSleep("user-1");
+
+ expect(annotate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: expect.objectContaining({ name: "googleHealth.sleep.sync" }),
+ meta: {
+ unknown_stage_segments: 4,
+ unknown_stage_labels: "SLEEP_STAGE_TYPE_UNSPECIFIED",
+ },
+ }),
+ );
+ });
+});
diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts
index a94d7abed..d73666dc6 100644
--- a/src/lib/google-health/mappers.ts
+++ b/src/lib/google-health/mappers.ts
@@ -955,6 +955,15 @@ export interface GoogleHealthSleepSession {
/** Latest segment end across the session (UTC), or null if none map. */
windowEnd: Date | null;
rows: GoogleHealthMappedMeasurement[];
+ /**
+ * Segments skipped because their stage label is not one this mapper knows
+ * (`SLEEP_STAGE_TYPE_UNSPECIFIED`, or a label Google adds later). Counted
+ * rather than dropped in silence: the night total reads short by exactly
+ * these segments, and the sync's wide event is where that shows.
+ */
+ unknownStageSegments: number;
+ /** The distinct unknown labels, for the wide event (at most five). */
+ unknownStageLabels: string[];
}
/**
@@ -972,7 +981,8 @@ export interface GoogleHealthSleepSession {
* duplicate rows the night-total then double-counted. A segment's start instant
* is stable per block, and dropping the stage from the key means a mere
* re-classification (LIGHT→DEEP on the same block) UPDATES the row in place
- * rather than orphaning it. Unknown stage labels are skipped; a session with no
+ * rather than orphaning it. Unknown stage labels are skipped and counted
+ * (`unknownStageSegments`); a session with no
* parseable segment yields an empty `rows` (and a null window).
*
* Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's
@@ -989,10 +999,18 @@ export function mapSleepSessionDetailed(
const rows: GoogleHealthMappedMeasurement[] = [];
let windowStart: Date | null = null;
let windowEnd: Date | null = null;
+ let unknownStageSegments = 0;
+ const unknownStageLabels = new Set();
for (const seg of segments) {
const stage = mapGoogleHealthSleepStage(seg.stage);
- if (!stage) continue;
+ if (!stage) {
+ unknownStageSegments += 1;
+ if (unknownStageLabels.size < 5) {
+ unknownStageLabels.add(seg.stage.slice(0, 40));
+ }
+ continue;
+ }
const mins = minutesBetween(seg.startTime, seg.endTime);
if (mins === null || !(mins > 0)) continue;
const start = parseLocalInstant(seg.startTime as string, tz);
@@ -1010,7 +1028,14 @@ export function mapSleepSessionDetailed(
});
}
- return { anchor, windowStart, windowEnd, rows };
+ return {
+ anchor,
+ windowStart,
+ windowEnd,
+ rows,
+ unknownStageSegments,
+ unknownStageLabels: [...unknownStageLabels],
+ };
}
/**
diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts
index 7c16469f7..7bd1bf4be 100644
--- a/src/lib/google-health/sync-sleep.ts
+++ b/src/lib/google-health/sync-sleep.ts
@@ -70,6 +70,12 @@ export async function syncUserSleep(
const readings: GoogleHealthMeasurementUpsert[] = [];
const replaceWindows: GoogleHealthSleepReplaceWindow[] = [];
+ // Segments whose stage label the mapper does not know are skipped, so the
+ // night total reads short by them. Counted across the page for the wide
+ // event, with the labels, so a new upstream stage shows up instead of
+ // quietly shortening nights.
+ let unknownStageSegments = 0;
+ const unknownStageLabels = new Set();
for (const point of points) {
// Per-session throw-guard (mapper parity with sync-metrics): a single
// malformed session must not abort the sibling sessions in the same page.
@@ -85,6 +91,10 @@ export async function syncUserSleep(
noteHardFailure("mapSleepSession");
continue;
}
+ unknownStageSegments += session.unknownStageSegments;
+ for (const label of session.unknownStageLabels) {
+ if (unknownStageLabels.size < 5) unknownStageLabels.add(label);
+ }
if (session.rows.length === 0) continue;
for (const m of session.rows) {
readings.push({
@@ -130,6 +140,12 @@ export async function syncUserSleep(
annotate({
action: { name: "googleHealth.sleep.sync", details: { imported } },
+ meta: {
+ unknown_stage_segments: unknownStageSegments,
+ ...(unknownStageSegments > 0
+ ? { unknown_stage_labels: [...unknownStageLabels].join(",") }
+ : {}),
+ },
});
return imported;
}
diff --git a/src/lib/medications/__tests__/default-medication.test.ts b/src/lib/medications/__tests__/default-medication.test.ts
index 8390c4b69..adcd529e4 100644
--- a/src/lib/medications/__tests__/default-medication.test.ts
+++ b/src/lib/medications/__tests__/default-medication.test.ts
@@ -14,6 +14,7 @@ function medication(
id,
name: `Medication ${id}`,
active: true,
+ intakeActionable: true,
schedules: [],
lastTakenAt: null,
todayEventCount: 0,
@@ -62,6 +63,26 @@ describe("pickDefaultMedicationId", () => {
expect(pickDefaultMedicationId(options, now)).toBe("r");
expect(pickDefaultMedicationId([...options].reverse(), now)).toBe("r");
});
+
+ it("never preselects an ended course, even when it is the only active one", () => {
+ // Still active (not archived) but the course ended: the server answers
+ // `intakeActionable: false`, the list keeps it for back-filling, and the
+ // default lands on the medication that offers a dose today.
+ const options = [
+ medication("ended", {
+ name: "Amoxicillin",
+ active: true,
+ intakeActionable: false,
+ }),
+ medication("current", { name: "Ramipril" }),
+ ];
+ expect(pickDefaultMedicationId(options)).toBe("current");
+ expect(
+ pickDefaultMedicationId([
+ medication("ended", { active: true, intakeActionable: false }),
+ ]),
+ ).toBeNull();
+ });
});
describe("resolveMedicationSelectionId", () => {
diff --git a/src/lib/medications/default-medication.ts b/src/lib/medications/default-medication.ts
index dee3be9b9..c91e526ef 100644
--- a/src/lib/medications/default-medication.ts
+++ b/src/lib/medications/default-medication.ts
@@ -8,6 +8,13 @@ export interface DefaultMedicationOption {
id: string;
name: string;
active: boolean;
+ /**
+ * The server's answer to "does this medication offer a dose today"
+ * (`src/lib/medications/intake-actionable.ts`): active, intake tracked,
+ * and the course includes today. The preselection reads it and never
+ * re-derives it from `active`, so an ended course is never the default.
+ */
+ intakeActionable: boolean;
schedules: ScheduleWindowInput[];
lastTakenAt: string | null;
todayEventCount?: number | null;
@@ -27,8 +34,11 @@ const DEFAULT_THRESHOLDS: MedicationDueThresholds = {
/**
* Pick the medication that should be pre-selected when an intake form opens.
- * Current, late, and overdue schedule windows win; otherwise the fallback is
- * the single active medication or the first active name alphabetically.
+ * Only a medication that offers a dose today (`intakeActionable`) can be the
+ * default. Among those, current, late, and overdue schedule windows win;
+ * otherwise the fallback is the single candidate or the first name
+ * alphabetically. A list may still carry ended courses for back-filling a
+ * past dose; they stay pickable by hand but are never preselected.
*/
export function pickDefaultMedicationId(
options: DefaultMedicationOption[],
@@ -36,7 +46,7 @@ export function pickDefaultMedicationId(
thresholds: MedicationDueThresholds = DEFAULT_THRESHOLDS,
tz: string = DEFAULT_TIMEZONE,
): string | null {
- const actives = options.filter((medication) => medication.active);
+ const actives = options.filter((medication) => medication.intakeActionable);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
diff --git a/src/lib/mood/__tests__/dropped-link-keys.test.ts b/src/lib/mood/__tests__/dropped-link-keys.test.ts
new file mode 100644
index 000000000..862bf5867
--- /dev/null
+++ b/src/lib/mood/__tests__/dropped-link-keys.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+
+import { droppedAnyLinkKeys } from "@/lib/mood/dropped-link-keys";
+
+describe("droppedAnyLinkKeys — the web's read of a mood write response", () => {
+ it("is false for a response that lists nothing (the fields are absent)", () => {
+ expect(droppedAnyLinkKeys({})).toBe(false);
+ expect(droppedAnyLinkKeys(null)).toBe(false);
+ expect(droppedAnyLinkKeys({ droppedTagKeys: [] })).toBe(false);
+ });
+
+ it("is true when either list names a key", () => {
+ expect(droppedAnyLinkKeys({ droppedTagKeys: ["custom:gone"] })).toBe(true);
+ expect(droppedAnyLinkKeys({ droppedFactorKeys: ["factor_x"] })).toBe(true);
+ });
+});
diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts
index c14e7c7e6..0f2863893 100644
--- a/src/lib/mood/__tests__/tag-links.test.ts
+++ b/src/lib/mood/__tests__/tag-links.test.ts
@@ -310,3 +310,69 @@ describe("entry-ownership guard", () => {
});
});
});
+
+describe("the write helpers report the keys they did not store", () => {
+ it("createTagLinks returns the unresolved binary and factor keys", async () => {
+ // First lookup: rated factors (only `factor_work` is a live factor).
+ // Second lookup: binary keys (`happy` live; the other two are unknown or
+ // archived, which the `isActive` filter makes look the same).
+ moodTagFindMany
+ .mockResolvedValueOnce([
+ { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 },
+ ])
+ .mockResolvedValueOnce([{ id: "mt_happy", key: "happy" }]);
+
+ const dropped = await createTagLinks(
+ "entry-1",
+ "user-1",
+ ["happy", "custom:gone", "no_such_tag", "happy"],
+ undefined,
+ [
+ { key: "factor_work", rating: 3 },
+ { key: "factor_retired", rating: 2 },
+ ],
+ );
+
+ expect(dropped).toEqual({
+ droppedTagKeys: ["custom:gone", "no_such_tag"],
+ droppedFactorKeys: ["factor_retired"],
+ });
+ });
+
+ it("createTagLinks reports nothing when every key landed", async () => {
+ moodTagFindMany.mockResolvedValue([{ id: "mt_happy", key: "happy" }]);
+ expect(await createTagLinks("entry-1", "user-1", ["happy"])).toEqual({
+ droppedTagKeys: [],
+ droppedFactorKeys: [],
+ });
+ });
+
+ it("replaceTagLinks does not report a key whose archived link it preserved", async () => {
+ // `custom:old` is archived but already on the entry: the edit keeps it,
+ // so it is not dropped. `custom:never` resolves to nothing anywhere.
+ moodTagFindMany.mockResolvedValue([]);
+ linkFindMany.mockResolvedValue([
+ {
+ moodTagId: "mt_old",
+ moodTag: { isActive: false, kind: "BINARY", key: "custom:old" },
+ },
+ ]);
+ expect(
+ await replaceTagLinks("entry-1", "user-1", [
+ "custom:old",
+ "custom:never",
+ ]),
+ ).toEqual(["custom:never"]);
+ });
+
+ it("replaceRatedFactorLinks does not report an archived factor that stays on the entry", async () => {
+ moodTagFindMany.mockResolvedValue([]);
+ linkFindMany.mockResolvedValue([{ moodTag: { key: "factor_archived" } }]);
+ expect(
+ await replaceRatedFactorLinks("entry-1", "user-1", [
+ { key: "factor_archived", rating: 2 },
+ { key: "factor_unknown", rating: 2 },
+ ]),
+ ).toEqual(["factor_unknown"]);
+ });
+});
diff --git a/src/lib/mood/dropped-link-keys.ts b/src/lib/mood/dropped-link-keys.ts
new file mode 100644
index 000000000..18e7612c7
--- /dev/null
+++ b/src/lib/mood/dropped-link-keys.ts
@@ -0,0 +1,20 @@
+/**
+ * Client-side reader for the `droppedTagKeys` / `droppedFactorKeys` fields a
+ * mood write answers with (see `src/lib/mood/tag-links.ts`). Each list is
+ * present only when the server did not store some of the submitted keys —
+ * a tag archived in another tab, a custom tag deleted on another device —
+ * so its presence alone is the signal; the client re-derives nothing.
+ */
+export interface DroppedLinkKeysWire {
+ droppedTagKeys?: string[];
+ droppedFactorKeys?: string[];
+}
+
+export function droppedAnyLinkKeys(
+ response: DroppedLinkKeysWire | null | undefined,
+): boolean {
+ return (
+ (response?.droppedTagKeys?.length ?? 0) > 0 ||
+ (response?.droppedFactorKeys?.length ?? 0) > 0
+ );
+}
diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts
index 5ad4c07ed..685029f7b 100644
--- a/src/lib/mood/tag-links.ts
+++ b/src/lib/mood/tag-links.ts
@@ -6,8 +6,11 @@ import { prisma } from "@/lib/db";
*
* Resolve a list of catalog tag keys (`mood_tags.key`) to their ids and
* write the `mood_entry_tag_links` join for a mood entry. The catalog is
- * the source of truth: unknown keys are dropped silently so a stale
- * client can never mint a link to a tag the deployment doesn't carry.
+ * the source of truth: an unknown or archived key never mints a link, so a
+ * stale client cannot link a tag the deployment doesn't carry. The write
+ * helpers return the keys they did not store, and the routes report them
+ * per entry (`droppedTagKeys` / `droppedFactorKeys`), so a client is never
+ * told an entry landed whole when part of it did not.
*
* Every helper accepts an optional `db` client so the caller can thread
* the same `$transaction` client that wrote the entry — the entry row and
@@ -76,7 +79,8 @@ interface ResolvedRatedFactor {
* resolved `MoodTag`'s own `scaleMin..scaleMax`. The route maps this to a
* 422 (the per-tag scale is the real gate; the Zod schema only enforces
* the outer 1..5 envelope). Unknown / non-RATED keys are NOT an error —
- * they are dropped silently, matching the binary `tagKeys` posture.
+ * the entry still lands without them, matching the binary `tagKeys`
+ * posture, and the write helpers report them back as dropped.
*/
export class RatedFactorOutOfRangeError extends Error {
constructor(
@@ -92,10 +96,20 @@ export class RatedFactorOutOfRangeError extends Error {
}
}
+/**
+ * The submitted keys a write did not store: unknown to the catalog, archived,
+ * another account's custom tag, or the wrong kind for the field they came in.
+ * Empty arrays when everything landed.
+ */
+export interface DroppedLinkKeys {
+ droppedTagKeys: string[];
+ droppedFactorKeys: string[];
+}
+
/**
* Resolve rated-factor inputs against the catalog. Unknown keys, inactive
- * tags, and `kind = 'BINARY'` keys are dropped silently (the catalog is
- * the source of truth, same posture as `resolveTagKeysToIds`). A rating
+ * tags, and `kind = 'BINARY'` keys are not resolved (the catalog is the
+ * source of truth, same posture as `resolveTagKeysToIds`). A rating
* outside the resolved tag's `scaleMin..scaleMax` throws
* `RatedFactorOutOfRangeError` so the route returns 422. The last value
* wins on a duplicate key in the same payload.
@@ -104,7 +118,15 @@ export async function resolveRatedFactors(
factors: RatedFactorInput[],
db: TagLinkDb = prisma,
): Promise {
- if (factors.length === 0) return [];
+ return (await resolveRatedFactorsReporting(factors, db)).resolved;
+}
+
+/** `resolveRatedFactors`, plus the submitted keys that did not resolve. */
+async function resolveRatedFactorsReporting(
+ factors: RatedFactorInput[],
+ db: TagLinkDb,
+): Promise<{ resolved: ResolvedRatedFactor[]; dropped: string[] }> {
+ if (factors.length === 0) return { resolved: [], dropped: [] };
// Last-writer-wins on a duplicate key in the same submission.
const byKey = new Map();
for (const f of factors) byKey.set(f.key, f.rating);
@@ -135,7 +157,11 @@ export async function resolveRatedFactors(
}
resolved.push({ moodTagId: row.id, rating });
}
- return resolved;
+ const found = new Set(rows.map((row) => row.key));
+ return {
+ resolved,
+ dropped: [...byKey.keys()].filter((key) => !found.has(key)),
+ };
}
/**
@@ -161,8 +187,18 @@ export async function resolveTagKeysToIds(
ownerUserId?: string,
kind?: "BINARY" | "RATED",
): Promise {
+ return (await resolveTagKeysReporting(keys, db, ownerUserId, kind)).ids;
+}
+
+/** `resolveTagKeysToIds`, plus the submitted keys that did not resolve. */
+async function resolveTagKeysReporting(
+ keys: string[],
+ db: TagLinkDb,
+ ownerUserId?: string,
+ kind?: "BINARY" | "RATED",
+): Promise<{ ids: string[]; dropped: string[] }> {
const unique = Array.from(new Set(keys));
- if (unique.length === 0) return [];
+ if (unique.length === 0) return { ids: [], dropped: [] };
const ownerClause = ownerUserId
? { OR: [{ userId: null }, { userId: ownerUserId }] }
: { userId: null };
@@ -173,9 +209,13 @@ export async function resolveTagKeysToIds(
...(kind ? { kind } : {}),
...ownerClause,
},
- select: { id: true },
+ select: { id: true, key: true },
});
- return rows.map((row) => row.id);
+ const found = new Set(rows.map((row) => row.key));
+ return {
+ ids: rows.map((row) => row.id),
+ dropped: unique.filter((key) => !found.has(key)),
+ };
}
/**
@@ -191,6 +231,8 @@ export async function resolveTagKeysToIds(
* route 422) when a rating is out of the factor's scale. Asserts the
* entry belongs to `userId` before any write — a defensive guard against
* linking into an entry the acting session does not own.
+ *
+ * Returns the submitted keys that were not stored, for the route to report.
*/
export async function createTagLinks(
moodEntryId: string,
@@ -198,12 +240,13 @@ export async function createTagLinks(
keys: string[],
db: TagLinkDb = prisma,
ratedFactors: RatedFactorInput[] = [],
-): Promise {
+): Promise {
await assertEntryOwnership(moodEntryId, userId, db);
// Resolve rated factors FIRST so an out-of-range rating aborts before
// any write, and so the rated rows (which carry a value) take priority
// over a bare binary row for the same tag id under `skipDuplicates`.
- const resolvedFactors = await resolveRatedFactors(ratedFactors, db);
+ const { resolved: resolvedFactors, dropped: droppedFactorKeys } =
+ await resolveRatedFactorsReporting(ratedFactors, db);
const ratedTagIds = new Set(resolvedFactors.map((f) => f.moodTagId));
if (resolvedFactors.length > 0) {
@@ -218,16 +261,19 @@ export async function createTagLinks(
});
}
- const tagIds = (await resolveTagKeysToIds(keys, db, userId)).filter(
+ const binary = await resolveTagKeysReporting(keys, db, userId);
+ const tagIds = binary.ids.filter(
// A key already written as a rated link is not re-inserted as a
// bare binary row (that would lose the rating to `skipDuplicates`).
(id) => !ratedTagIds.has(id),
);
- if (tagIds.length === 0) return;
- await db.moodEntryTagLink.createMany({
- data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })),
- skipDuplicates: true,
- });
+ if (tagIds.length > 0) {
+ await db.moodEntryTagLink.createMany({
+ data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })),
+ skipDuplicates: true,
+ });
+ }
+ return { droppedTagKeys: binary.dropped, droppedFactorKeys };
}
/**
@@ -248,24 +294,31 @@ export async function createTagLinks(
* (`isActive: false`) are therefore preserved untouched no matter what
* the body carries; a purge (`DELETE /api/mood/tags/custom/[key]?purge=true`)
* remains the only path that removes them.
+ *
+ * Returns the submitted keys that are not on the entry afterwards. A key
+ * whose archived link was preserved is on the entry, so it is not reported.
*/
export async function replaceTagLinks(
moodEntryId: string,
userId: string,
keys: string[],
db: TagLinkDb = prisma,
-): Promise {
+): Promise {
await assertEntryOwnership(moodEntryId, userId, db);
- const desiredIds = new Set(
- await resolveTagKeysToIds(keys, db, userId, "BINARY"),
- );
+ const desired = await resolveTagKeysReporting(keys, db, userId, "BINARY");
+ const desiredIds = new Set(desired.ids);
const existing = await db.moodEntryTagLink.findMany({
where: { moodEntryId },
select: {
moodTagId: true,
- moodTag: { select: { isActive: true, kind: true } },
+ moodTag: { select: { isActive: true, kind: true, key: true } },
},
});
+ const preservedKeys = new Set(
+ existing
+ .filter((row) => !row.moodTag.isActive && row.moodTag.kind === "BINARY")
+ .map((row) => row.moodTag.key),
+ );
const existingIds = new Set(existing.map((row) => row.moodTagId));
const toDelete = existing
@@ -289,6 +342,7 @@ export async function replaceTagLinks(
skipDuplicates: true,
});
}
+ return desired.dropped.filter((key) => !preservedKeys.has(key));
}
/**
@@ -300,17 +354,40 @@ export async function replaceTagLinks(
* Throws `RatedFactorOutOfRangeError` (→ route 422) before any write when a
* rating is out of scale. Asserts the entry belongs to `userId` before any
* write.
+ *
+ * Returns the submitted factor keys that are not on the entry afterwards; an
+ * archived factor whose link survives is on the entry and is not reported.
*/
export async function replaceRatedFactorLinks(
moodEntryId: string,
userId: string,
factors: RatedFactorInput[],
db: TagLinkDb = prisma,
-): Promise {
+): Promise {
await assertEntryOwnership(moodEntryId, userId, db);
// Resolve (and range-check) before mutating so an out-of-range rating
// aborts the replace cleanly.
- const resolved = await resolveRatedFactors(factors, db);
+ const { resolved, dropped } = await resolveRatedFactorsReporting(factors, db);
+ // Looked up only when something did not resolve: which of those keys still
+ // sit on the entry as an archived factor the replacement leaves alone.
+ const preservedKeys =
+ dropped.length === 0
+ ? new Set()
+ : new Set(
+ (
+ await db.moodEntryTagLink.findMany({
+ where: {
+ moodEntryId,
+ moodTag: {
+ isActive: false,
+ kind: "RATED",
+ key: { in: dropped },
+ },
+ },
+ select: { moodTag: { select: { key: true } } },
+ })
+ ).map((row) => row.moodTag.key),
+ );
// Drop every active RATED link, including a legacy row whose rating is
// null. Same archive contract as `replaceTagLinks`: a link whose tag has
@@ -333,4 +410,30 @@ export async function replaceRatedFactorLinks(
skipDuplicates: true,
});
}
+ return dropped.filter((key) => !preservedKeys.has(key));
}
+
+/**
+ * The wire form of `DroppedLinkKeys`: each list only when it is non-empty.
+ * Absent means every submitted key of that kind landed, which keeps the
+ * common response byte-identical to what clients read before the fields
+ * existed.
+ */
+export function droppedLinkKeysForWire(
+ dropped: DroppedLinkKeys,
+): Partial {
+ return {
+ ...(dropped.droppedTagKeys.length > 0
+ ? { droppedTagKeys: dropped.droppedTagKeys }
+ : {}),
+ ...(dropped.droppedFactorKeys.length > 0
+ ? { droppedFactorKeys: dropped.droppedFactorKeys }
+ : {}),
+ };
+}
+
+/** No key dropped: the value a write that carried no keys reports. */
+export const NO_DROPPED_LINK_KEYS: DroppedLinkKeys = Object.freeze({
+ droppedTagKeys: [],
+ droppedFactorKeys: [],
+}) as DroppedLinkKeys;
diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts
index 5e80702f8..64f88bbde 100644
--- a/src/lib/openapi/routes/mood.ts
+++ b/src/lib/openapi/routes/mood.ts
@@ -401,6 +401,21 @@ const bulkMoodPayload = z
"iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs are idempotent.",
});
+// The submitted keys a write did not store. Shared by the bulk result and the
+// single-entry write responses so the two surfaces name the same thing.
+const droppedTagKeysField = z
+ .array(z.string())
+ .optional()
+ .describe(
+ "`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every submitted key landed. The entry itself was written.",
+ );
+const droppedFactorKeysField = z
+ .array(z.string())
+ .optional()
+ .describe(
+ "`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. Present only when non-empty; absent means every submitted factor landed.",
+ );
+
const bulkMoodEntryResult = z
.object({
index: z.number().int().nonnegative(),
@@ -418,6 +433,8 @@ const bulkMoodEntryResult = z
.string()
.optional()
.describe("Echoed back when the entry carried one, for client mapping."),
+ droppedTagKeys: droppedTagKeysField,
+ droppedFactorKeys: droppedFactorKeysField,
})
.meta({ id: "BulkMoodEntryResult" });
@@ -526,6 +543,12 @@ const moodEntryDto = z
.describe(
"The day's context, or null when the entry carries none. Null and an object of nulls are different answers.",
),
+ droppedTagKeys: droppedTagKeysField.describe(
+ "Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An archived tag the edit preserved is on the entry and is not listed.",
+ ),
+ droppedFactorKeys: droppedFactorKeysField.describe(
+ "Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present only when non-empty.",
+ ),
})
.meta({
id: "MoodEntryDTO",
diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts
index 82ded9d09..f894cf8aa 100644
--- a/src/lib/openapi/routes/ocr.ts
+++ b/src/lib/openapi/routes/ocr.ts
@@ -85,6 +85,15 @@ const extractResponse = z
reportDate: z.string().nullable(),
providerType: z.string(),
rows: z.array(extractedRow),
+ pageCoverage: z
+ .object({
+ read: z.number().int().positive(),
+ total: z.number().int().positive(),
+ })
+ .optional()
+ .describe(
+ "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` pages. Absent when the whole document was read.",
+ ),
})
.meta({
id: "OcrExtractResponse",
diff --git a/src/lib/queries/use-bp-target-band.ts b/src/lib/queries/use-bp-target-band.ts
new file mode 100644
index 000000000..3daa9d477
--- /dev/null
+++ b/src/lib/queries/use-bp-target-band.ts
@@ -0,0 +1,61 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+import { useAuth } from "@/hooks/use-auth";
+
+interface TargetRange {
+ min: number;
+ max: number;
+}
+
+/** The slice of `GET /api/insights/targets` the blood-pressure band needs. */
+interface BpTargetsSlice {
+ targets: ReadonlyArray<{ type: string; range: TargetRange | null }>;
+ bpDiastolic: { range: TargetRange | null };
+}
+
+export interface BpTargetBand {
+ systolic: TargetRange;
+ diastolic: TargetRange;
+}
+
+/**
+ * The blood-pressure target band for the record on screen, as the server
+ * resolved it.
+ *
+ * The chart used to rebuild the band in the browser from the signed-in
+ * account's date of birth. That lost a band the person had set themselves,
+ * and inside a shared or managed record it used the viewer's age rather than
+ * the record's. `/api/insights/targets` already resolves the band for the
+ * record being viewed, with the user's own target winning over the age
+ * default, and the reference panel under the chart reads the same payload —
+ * so the zones and the panel name one band.
+ *
+ * Shares the `insightsTargets()` cache entry with the panel; the payload
+ * shape is the route's, so both readers hold the same object.
+ */
+export function useBpTargetBand(): BpTargetBand | null {
+ const { isAuthenticated } = useAuth();
+ const { data } = useQuery({
+ queryKey: queryKeys.insightsTargets(),
+ queryFn: () => apiGet("/api/insights/targets"),
+ enabled: isAuthenticated,
+ });
+ return selectBpTargetBand(data ?? null);
+}
+
+/** Pure projection, exported for the unit test. */
+export function selectBpTargetBand(
+ data: BpTargetsSlice | null,
+): BpTargetBand | null {
+ if (!data) return null;
+ const systolic =
+ data.targets.find((entry) => entry.type === "BLOOD_PRESSURE")?.range ??
+ null;
+ const diastolic = data.bpDiastolic?.range ?? null;
+ if (!systolic || !diastolic) return null;
+ return { systolic, diastolic };
+}
diff --git a/src/lib/queries/use-reminder-thresholds.ts b/src/lib/queries/use-reminder-thresholds.ts
new file mode 100644
index 000000000..dab89e19b
--- /dev/null
+++ b/src/lib/queries/use-reminder-thresholds.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+import { useAuth } from "@/hooks/use-auth";
+import type { MedicationDueThresholds } from "@/lib/medications/default-medication";
+
+/**
+ * The account's own late / missed thresholds for the intake pickers.
+ *
+ * The cards, the table and the medications page each read
+ * `/api/settings/reminder-thresholds` under this key with the same fetcher
+ * shape (failure resolves to `null`), so the pickers share their cache entry.
+ * `undefined` until it lands or when it failed — the default picker then
+ * falls back to the 120 / 240-minute defaults every reader shares.
+ */
+export function useMedicationDueThresholds():
+ MedicationDueThresholds | undefined {
+ const { isAuthenticated } = useAuth();
+ const { data } = useQuery({
+ queryKey: queryKeys.settingsReminderThresholds(),
+ queryFn: async () => {
+ try {
+ return await apiGet<{
+ lateMinutes: number;
+ missedMinutes: number;
+ lowStockRunwayDays: number | null;
+ }>("/api/settings/reminder-thresholds");
+ } catch {
+ return null;
+ }
+ },
+ enabled: isAuthenticated,
+ staleTime: 5 * 60 * 1000,
+ });
+ if (!data) return undefined;
+ return { lateMinutes: data.lateMinutes, missedMinutes: data.missedMinutes };
+}
diff --git a/src/lib/targets/__tests__/bp-target-override.test.ts b/src/lib/targets/__tests__/bp-target-override.test.ts
new file mode 100644
index 000000000..1da20a698
--- /dev/null
+++ b/src/lib/targets/__tests__/bp-target-override.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveBpTargetOverride } from "@/lib/analytics/effective-range";
+import { selectBpTargetBand } from "@/lib/queries/use-bp-target-band";
+import { buildVitalTargets } from "../vitals-builder";
+
+const NOW = new Date("2026-07-21T12:00:00.000Z");
+
+// `restingPulseProxy` is listed so the literal stays valid whichever shape of
+// the input the builder carries; the assertion keeps the extra key legal.
+const base = {
+ recentMeasurements: [],
+ restingPulseProxy: [],
+ latestByType: {},
+ average30ByType: {},
+ heightCm: 178,
+ age: 40,
+ gender: null,
+ timezone: "UTC",
+ now: NOW,
+ weightTargetOverride: null,
+} as unknown as Parameters[0];
+
+const PROFILE_40 = {
+ heightCm: 178,
+ dateOfBirth: new Date("1986-03-01T12:00:00.000Z"),
+ gender: null,
+};
+
+describe("blood-pressure target: the user's own band", () => {
+ it("is null when no blood-pressure threshold is stored", () => {
+ expect(resolveBpTargetOverride(PROFILE_40, null)).toBeNull();
+ expect(
+ resolveBpTargetOverride(PROFILE_40, { WEIGHT: { min: 70, max: 75 } }),
+ ).toBeNull();
+ });
+
+ it("carries the stored band, filling the other half from the age default", () => {
+ expect(
+ resolveBpTargetOverride(PROFILE_40, {
+ BLOOD_PRESSURE_SYS: { min: 110, max: 135 },
+ }),
+ ).toEqual({ sysLow: 110, sysHigh: 135, diaLow: 70, diaHigh: 79 });
+ });
+
+ it("replaces the age band on the card and labels it as the user's", () => {
+ const card = buildVitalTargets({
+ ...base,
+ bpTargetOverride: { sysLow: 110, sysHigh: 135, diaLow: 65, diaHigh: 85 },
+ });
+ const bp = card.targets.find((t) => t.type === "BLOOD_PRESSURE");
+ expect(bp?.range).toEqual({ min: 110, max: 135 });
+ expect(bp?.source).toBe("Custom");
+ expect(card.bpRange).toEqual({
+ sysLow: 110,
+ sysHigh: 135,
+ diaLow: 65,
+ diaHigh: 85,
+ });
+ });
+
+ it("keeps the ESH age band when none is set", () => {
+ const bp = buildVitalTargets(base).targets.find(
+ (t) => t.type === "BLOOD_PRESSURE",
+ );
+ expect(bp?.range).toEqual({ min: 120, max: 129 });
+ expect(bp?.source).toBe("ESH 2023");
+ });
+});
+
+describe("selectBpTargetBand — the chart reads the server's band", () => {
+ it("projects the systolic card range and the diastolic range", () => {
+ expect(
+ selectBpTargetBand({
+ targets: [
+ { type: "WEIGHT", range: { min: 70, max: 75 } },
+ { type: "BLOOD_PRESSURE", range: { min: 110, max: 135 } },
+ ],
+ bpDiastolic: { range: { min: 65, max: 85 } },
+ }),
+ ).toEqual({
+ systolic: { min: 110, max: 135 },
+ diastolic: { min: 65, max: 85 },
+ });
+ });
+
+ it("draws no band when the server resolved none", () => {
+ expect(selectBpTargetBand(null)).toBeNull();
+ expect(
+ selectBpTargetBand({
+ targets: [{ type: "BLOOD_PRESSURE", range: null }],
+ bpDiastolic: { range: null },
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts
index 74903024b..f59b14b2b 100644
--- a/src/lib/targets/build-response.ts
+++ b/src/lib/targets/build-response.ts
@@ -17,6 +17,7 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import {
+ resolveBpTargetOverride,
resolveWeightTargetOverride,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
@@ -293,6 +294,17 @@ export async function buildTargetsResponse(user: AuthedUser) {
// set one, so the reference panel and the weight chart above it name the
// same band.
weightTargetOverride: resolveWeightTargetOverride(dbUser?.thresholdsJson),
+ // The blood-pressure card likewise names the user's own band when they
+ // set one. The insights page draws its chart zones from this same range,
+ // so the chart and the card below it cannot disagree.
+ bpTargetOverride: resolveBpTargetOverride(
+ {
+ heightCm,
+ dateOfBirth: dbUser?.dateOfBirth ?? null,
+ gender: dbUser?.gender ?? null,
+ },
+ dbUser?.thresholdsJson,
+ ),
});
const sleepTarget = buildSleepTarget({
sleepStageRows,
diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts
index 69b8b589d..f792643b4 100644
--- a/src/lib/targets/vitals-builder.ts
+++ b/src/lib/targets/vitals-builder.ts
@@ -65,6 +65,15 @@ interface VitalTargetsInput {
* always treated a user override. `null` keeps the height-derived band.
*/
weightTargetOverride: { min: number; max: number } | null;
+ /**
+ * The user's own blood-pressure band from `User.thresholdsJson`
+ * (`resolveBpTargetOverride`). When present it replaces the age-based ESH
+ * band on the card, its in-target share and its seven-day strip, and the
+ * source reads "Custom" — the same rule as the weight override above. The
+ * ESH classification of the latest reading stays guideline-derived: it
+ * names a category, not a goal. Absent or `null` keeps the age band.
+ */
+ bpTargetOverride?: BloodPressureTargetRange | null;
}
export interface VitalTargetsResult {
@@ -160,6 +169,7 @@ export function buildVitalTargets({
timezone,
now,
weightTargetOverride,
+ bpTargetOverride = null,
}: VitalTargetsInput): VitalTargetsResult {
const targets: TargetItem[] = [];
const consistencyClock = { timezone, now };
@@ -203,7 +213,9 @@ export function buildVitalTargets({
}),
});
- const bpRange = age != null ? getBpTargetsByAge(age, referenceSex) : null;
+ const bpRange =
+ bpTargetOverride ??
+ (age != null ? getBpTargetsByAge(age, referenceSex) : null);
const bpPairsByDay = buildBpPairsByDay(recentMeasurements, bpRange, timezone);
let bpClassification: TargetItem["classification"] = null;
if (
@@ -228,7 +240,7 @@ export function buildVitalTargets({
unit: "mmHg",
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
- source: "ESH 2023",
+ source: bpTargetOverride ? "Custom" : "ESH 2023",
...rollupFromDayMap({
dayBandByKey: bpPairsByDay,
...consistencyClock,
diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts
index aa18dd7e6..000a78ab5 100644
--- a/src/lib/validations/labs-ocr.ts
+++ b/src/lib/validations/labs-ocr.ts
@@ -132,6 +132,12 @@ export interface OcrExtractResponseDto {
reportDate: string | null;
providerType: string;
rows: OcrExtractedRowDto[];
+ /**
+ * Present only when a PDF was longer than the pages the scan reads
+ * (`RASTER_MAX_PAGES`): the rows come from the first `read` of `total`
+ * pages, and the review screen says so.
+ */
+ pageCoverage?: { read: number; total: number };
}
/**
diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts
index 388e1531f..50818240e 100644
--- a/tests/integration/mood-entries-bulk.test.ts
+++ b/tests/integration/mood-entries-bulk.test.ts
@@ -277,6 +277,70 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
expect(linkCount).toBe(0);
});
+ it("names the unknown and archived keys on the entry's result instead of reporting it whole", async () => {
+ const db = getPrismaClient();
+ const category = await db.moodTagCategory.findFirstOrThrow({
+ select: { id: true },
+ });
+ const archived = await db.moodTag.create({
+ data: {
+ categoryId: category.id,
+ key: "test_archived_tag",
+ labelKey: "mood.tags.test_archived_tag",
+ isActive: false,
+ },
+ });
+ try {
+ const { POST } = await import("@/app/api/mood-entries/bulk/route");
+ const res = await POST(
+ makeRequest({
+ entries: [
+ {
+ mood: "GUT",
+ moodLoggedAt: "2026-05-16T08:00:00.000Z",
+ tagKeys: ["movies", "test_archived_tag", "not_a_real_tag"],
+ ratedFactors: [
+ { key: "factor_work", rating: 4 },
+ { key: "factor_not_real", rating: 2 },
+ ],
+ },
+ {
+ mood: "OKAY",
+ moodLoggedAt: "2026-05-16T09:00:00.000Z",
+ tagKeys: ["movies"],
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const json = (await res.json()) as {
+ data: { inserted: number; entries: Array> };
+ };
+ expect(json.data.inserted).toBe(2);
+ expect(json.data.entries[0]).toMatchObject({
+ status: "inserted",
+ droppedTagKeys: ["test_archived_tag", "not_a_real_tag"],
+ droppedFactorKeys: ["factor_not_real"],
+ });
+ expect(json.data.entries[1]).not.toHaveProperty("droppedTagKeys");
+ expect(json.data.entries[1]).not.toHaveProperty("droppedFactorKeys");
+
+ // What the result says is what is on disk.
+ const first = await db.moodEntry.findUniqueOrThrow({
+ where: { id: json.data.entries[0].id as string },
+ select: {
+ tagLinks: { select: { moodTag: { select: { key: true } } } },
+ },
+ });
+ expect(first.tagLinks.map((l) => l.moodTag.key).sort()).toEqual([
+ "factor_work",
+ "movies",
+ ]);
+ } finally {
+ await db.moodTag.delete({ where: { id: archived.id } });
+ }
+ });
+
it("rejects an over-cap batch with 422 and the documented error code", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const entries = Array.from({ length: 501 }, (_, i) => ({