diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4b2e8ebe2..b21305370 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) => ({