From 93198017d68897ebe7daa9abb0d1d9b4ccd194a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:11:23 +0200 Subject: [PATCH 01/98] feat(settings): keep list order and preview opt-ins on the server Add per-user columns for the labs, illness and checkups list presentation and for preview-feature opt-ins, each with a GET plus a preserve-when-absent write under /api/settings. The list hook now reads and writes the server value and carries a browser-stored order up once when the server holds none, so an existing arrangement follows the person to every device instead of resetting. --- docs/api/openapi.yaml | 256 +++++++++++++ .../migration.sql | 17 + prisma/schema.prisma | 13 + .../api/settings/module-list-layout/route.ts | 94 +++++ .../api/settings/preview-features/route.ts | 75 ++++ src/lib/__tests__/module-list-prefs.test.ts | 274 +++++++++---- src/lib/data-wipe/wipe-plan.ts | 3 + src/lib/export/account-settings-backup.ts | 2 + src/lib/export/backup-plan.ts | 2 + src/lib/module-list-prefs.ts | 359 +++++++++++------- src/lib/openapi/routes/settings.ts | 165 ++++++++ src/lib/query-keys/settings.ts | 9 + .../__tests__/module-list-layout.test.ts | 78 ++++ .../__tests__/preview-features.test.ts | 26 ++ src/lib/settings/module-list-layout.ts | 163 ++++++++ src/lib/settings/preview-features.ts | 31 ++ src/lib/validations/backup.ts | 2 + src/lib/validations/settings-prefs.ts | 37 ++ .../integration/settings-prefs-routes.test.ts | 216 +++++++++++ 19 files changed, 1614 insertions(+), 208 deletions(-) create mode 100644 prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql create mode 100644 src/app/api/settings/module-list-layout/route.ts create mode 100644 src/app/api/settings/preview-features/route.ts create mode 100644 src/lib/settings/__tests__/module-list-layout.test.ts create mode 100644 src/lib/settings/__tests__/preview-features.test.ts create mode 100644 src/lib/settings/module-list-layout.ts create mode 100644 src/lib/settings/preview-features.ts create mode 100644 src/lib/validations/settings-prefs.ts create mode 100644 tests/integration/settings-prefs-routes.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..ed29ff678 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14488,6 +14488,81 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/settings/module-list-layout: + get: + tags: + - Meta + summary: Get the list presentation of labs, illness and checkups + description: Every list resolved, defaults filled in, with `saved` per list. Never writes a row. + responses: + "200": + description: The resolved presentation of every list. + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + put: + tags: + - Meta + summary: Change the presentation of one list + description: "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays + as stored. Returns every list resolved." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutPutBody" + responses: + "200": + description: The presentation as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + /api/settings/preview-features: + get: + tags: + - Meta + summary: Get the preview-feature opt-ins + description: Every flag, off unless the caller switched it on. + responses: + "200": + description: The opt-ins. + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Meta + summary: Switch preview features on or off + description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesPatchBody" + responses: + "200": + description: The opt-ins as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/settings/privacy-summary: get: tags: @@ -31354,6 +31429,48 @@ components: required: - confirm description: Typed confirmation for erasing the record. No undo. + ModuleListLayoutPutBody: + type: object + properties: + version: + type: number + const: 1 + list: + type: string + enum: + - labs + - illness + - vorsorge + view: + type: string + enum: + - cards + - list + order: + maxItems: 500 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + sortDir: + type: string + enum: + - recentDesc + - recentAsc + - alphaAsc + - alphaDesc + - manual + required: + - version + - list + additionalProperties: false + PreviewFeaturesPatchBody: + type: object + properties: + topics: + type: boolean + additionalProperties: false ConsentPostBody: type: object properties: @@ -38796,6 +38913,8 @@ components: medicationListLayoutJson: {} moodTagLayoutJson: {} documentsLayoutJson: {} + moduleListLayoutJson: {} + previewFeaturesJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -69269,6 +69388,143 @@ components: - deletedRows - models additionalProperties: false + ModuleListLayoutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ModuleListLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ModuleListLayout: + type: object + properties: + version: + type: number + const: 1 + lists: + type: object + properties: + labs: + $ref: "#/components/schemas/ModuleListPrefs" + illness: + $ref: "#/components/schemas/ModuleListPrefs" + vorsorge: + $ref: "#/components/schemas/ModuleListPrefs" + required: + - labs + - illness + - vorsorge + additionalProperties: false + required: + - version + - lists + additionalProperties: false + description: Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render + a list ignores it. + ModuleListPrefs: + type: object + properties: + view: + type: string + enum: + - cards + - list + description: '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.' + order: + type: array + items: + type: string + description: Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list + follow in the server order. + sortDir: + type: string + enum: + - recentDesc + - recentAsc + - alphaAsc + - alphaDesc + - manual + description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it. + saved: + type: boolean + description: True when the server holds a choice for this list; false means every field is a default. + required: + - view + - order + - sortDir + - saved + additionalProperties: false + ModuleListLayoutSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ModuleListLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PreviewFeaturesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PreviewFeatures" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PreviewFeatures: + type: object + properties: + topics: + type: boolean + description: Topic overview pages above the single-metric Insights pages. Off by default. + required: + - topics + additionalProperties: false + description: Per-user opt-in to preview features, one boolean per flag, every flag off by default. + PreviewFeaturesSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PreviewFeatures" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false PrivacySummaryEnvelope: type: object properties: diff --git a/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql new file mode 100644 index 000000000..6e9beaa6c --- /dev/null +++ b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql @@ -0,0 +1,17 @@ +-- Two per-user preference columns for Settings. +-- +-- module_list_layout_json: the card/list view, manual order and labs sort for +-- the labs, illness and checkups lists. These lived in the browser until now, +-- so they did not follow a person to another device. The client copies a value +-- it still holds in the browser up once, when the server has none for that list. +-- +-- preview_features_json: the per-user opt-in to preview features, one boolean +-- per flag, all off by default. +-- +-- Nullable, no default, no backfill: null reads as the defaults. +-- +-- Reversibility: dropping the columns returns every list to its default +-- presentation and switches every preview feature off. + +ALTER TABLE "users" ADD COLUMN "module_list_layout_json" JSONB; +ALTER TABLE "users" ADD COLUMN "preview_features_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d79d97b1..eb38163bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -364,6 +364,19 @@ model User { // Shape: { "groupOrder": ["", ...], // "placements": { "": ["", ...] } } moodTagLayoutJson Json? @map("mood_tag_layout_json") + // Per-user list presentation for the labs, illness and checkups lists: + // card/list view, manual order, and (labs only) the sort. One blob keyed by + // list, resolved field by field on read (`src/lib/settings/module-list-layout.ts`), + // preserve-when-absent PUT on `/api/settings/module-list-layout`. Null = the + // defaults. Earlier releases kept the same choices in the browser; the client + // copies a stored browser value up once when the server has none for that list. + // Shape: { "version": 1, "labs": { "view": "list", "order": [""], "sortDir": "manual" }, ... } + moduleListLayoutJson Json? @map("module_list_layout_json") + /// Per-user opt-in to preview features, one boolean per flag, all off by + /// default (`src/lib/settings/preview-features.ts`). Read and written by + /// Settings > System through `/api/settings/preview-features`. + /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will. + previewFeaturesJson Json? @map("preview_features_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts new file mode 100644 index 000000000..7bd93d3b8 --- /dev/null +++ b/src/app/api/settings/module-list-layout/route.ts @@ -0,0 +1,94 @@ +/** + * GET / PUT the list presentation for the labs, illness and checkups lists. + * + * GET returns every list resolved, defaults filled in, with `saved` per list + * so a client can tell a stored choice from a default. PUT changes one list + * with preserve-when-absent semantics: a body carrying only `view` keeps the + * stored order and sort, and vice versa, the contract `/api/documents/inbound/layout` + * keeps for its fields. The blob lives on its own `User` column + * (`module_list_layout_json`). + * + * Both halves are the caller's own presentation. Inside somebody else's + * record the ids in a saved order simply do not match and the list keeps the + * server order, so no record gate is needed here. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + MODULE_LIST_KEYS, + resolveModuleListLayout, + storedModuleListLayout, +} from "@/lib/settings/module-list-layout"; +import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs"; + +export const dynamic = "force-dynamic"; + +async function readStored(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + return row?.moduleListLayoutJson; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const layout = resolveModuleListLayout(await readStored(user.id)); + annotate({ + action: { name: "settings.module-list-layout.read" }, + meta: { + saved: MODULE_LIST_KEYS.filter((list) => layout.lists[list].saved), + }, + }); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + // 500 ids of up to 64 characters plus the envelope. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 48 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = moduleListLayoutPutSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "settings.module-list-layout.validation-failed" }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { list, view, order, sortDir } = parsed.data; + // Preserve-when-absent: one stored read fills whichever field the body + // left out, and every other list stays as stored. Only fields a person has + // chosen are written, so a field never picked keeps following the default. + const stored = storedModuleListLayout(await readStored(user.id)); + const nextChoices = { + ...stored[list], + ...(view ? { view } : {}), + ...(order ? { order: [...new Set(order)] } : {}), + ...(sortDir ? { sortDir } : {}), + }; + const nextStored = { ...stored, [list]: nextChoices }; + + await prisma.user.update({ + where: { id: user.id }, + data: { moduleListLayoutJson: toJson({ version: 1, ...nextStored }) }, + }); + + const layout = resolveModuleListLayout(nextStored); + annotate({ + action: { name: "settings.module-list-layout.update" }, + meta: { + list, + view: layout.lists[list].view, + sortDir: layout.lists[list].sortDir, + orderLength: layout.lists[list].order.length, + }, + }); + return apiSuccess(layout); +}); diff --git a/src/app/api/settings/preview-features/route.ts b/src/app/api/settings/preview-features/route.ts new file mode 100644 index 000000000..1b48c75fa --- /dev/null +++ b/src/app/api/settings/preview-features/route.ts @@ -0,0 +1,75 @@ +/** + * GET / PATCH the caller's preview-feature opt-ins. + * + * GET answers every flag, off unless the caller switched it on. PATCH sets + * the flags the body names and keeps the rest; unknown flags are refused + * with the multi-issue 422 so a typo cannot silently do nothing. The blob + * lives on its own `User` column (`preview_features_json`). + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + PREVIEW_FEATURE_KEYS, + resolvePreviewFeatures, + type PreviewFeatures, +} from "@/lib/settings/preview-features"; +import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs"; + +export const dynamic = "force-dynamic"; + +async function readStored(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { previewFeaturesJson: true }, + }); + return row?.previewFeaturesJson; +} + +function enabledFlags(features: PreviewFeatures): string[] { + return PREVIEW_FEATURE_KEYS.filter((key) => features[key]); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const features = resolvePreviewFeatures(await readStored(user.id)); + annotate({ + action: { name: "settings.preview-features.read" }, + meta: { enabled: enabledFlags(features) }, + }); + return apiSuccess(features); +}); + +export const PATCH = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = previewFeaturesPatchSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "settings.preview-features.validation-failed" }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const current = resolvePreviewFeatures(await readStored(user.id)); + const next: PreviewFeatures = { + topics: parsed.data.topics ?? current.topics, + }; + + await prisma.user.update({ + where: { id: user.id }, + data: { previewFeaturesJson: toJson(next) }, + }); + + annotate({ + action: { name: "settings.preview-features.update" }, + meta: { enabled: enabledFlags(next) }, + }); + return apiSuccess(next); +}); diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts index 1c9eb58fe..a156e6303 100644 --- a/src/lib/__tests__/module-list-prefs.test.ts +++ b/src/lib/__tests__/module-list-prefs.test.ts @@ -1,82 +1,224 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; -import { applyOrder, parseModuleListPrefs } from "@/lib/module-list-prefs"; +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("@/components/outcome/outcome-toast", () => ({ + toastWrittenOutcome: vi.fn(), +})); +vi.mock("@/lib/errors/error-toast", () => ({ + showErrorToast: vi.fn(), +})); -describe("module-list-prefs", () => { - describe("parseModuleListPrefs", () => { - it("returns defaults for null / malformed input", () => { - expect(parseModuleListPrefs(null)).toEqual({ - view: "cards", - order: [], - sortDir: "recentDesc", - }); - expect(parseModuleListPrefs("{not json")).toEqual({ - view: "cards", - order: [], - sortDir: "recentDesc", - }); - }); +import { apiPut } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { + applyOrder, + carryOverLegacyModuleListPrefs, + legacyModuleListPatch, + legacyModuleListStorageKey, + parseModuleListPrefs, + resetModuleListCarryOverForTests, + runSetModuleListPrefs, +} from "@/lib/module-list-prefs"; +import { + moduleListDefaults, + resolveModuleListLayout, +} from "@/lib/settings/module-list-layout"; +import { queryKeys } from "@/lib/query-keys"; - it("coerces unknown view / sortDir to defaults but keeps a valid order", () => { - const parsed = parseModuleListPrefs( - JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }), - ); - expect(parsed).toEqual({ - view: "cards", - sortDir: "recentDesc", - order: ["a", "b"], - }); - }); +const store = new Map(); +const fakeStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), +}; - it("round-trips valid values", () => { - const parsed = parseModuleListPrefs( - JSON.stringify({ view: "list", sortDir: "manual", order: ["x"] }), - ); - expect(parsed).toEqual({ - view: "list", - sortDir: "manual", - order: ["x"], - }); - }); +beforeEach(() => { + vi.resetAllMocks(); + store.clear(); + resetModuleListCarryOverForTests(); + vi.stubGlobal("window", { localStorage: fakeStorage }); +}); - it("accepts the alphabetical sort directions (#43)", () => { - for (const sortDir of ["alphaAsc", "alphaDesc"]) { - const parsed = parseModuleListPrefs(JSON.stringify({ sortDir })); - expect(parsed.sortDir).toBe(sortDir); - } - }); +afterEach(() => { + vi.unstubAllGlobals(); +}); - it("applies a per-call default when no blob is stored (Labs list view, #40)", () => { - const labsDefaults = { - view: "list" as const, - order: [], - sortDir: "recentDesc" as const, - }; - expect(parseModuleListPrefs(null, labsDefaults)).toEqual(labsDefaults); - // A stored view still wins over the default. - expect( - parseModuleListPrefs(JSON.stringify({ view: "cards" }), labsDefaults) - .view, - ).toBe("cards"); - }); +describe("parseModuleListPrefs", () => { + const defaults = moduleListDefaults("illness"); + + it("returns defaults for null / malformed input", () => { + expect(parseModuleListPrefs(null, defaults)).toEqual(defaults); + expect(parseModuleListPrefs("{not json", defaults)).toEqual(defaults); }); - describe("applyOrder", () => { - const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; - const idOf = (i: { id: string }) => i.id; + it("coerces unknown view / sortDir to defaults but keeps a valid order", () => { + expect( + parseModuleListPrefs( + JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }), + defaults, + ), + ).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] }); + }); - it("returns the input order when no order is persisted", () => { - expect(applyOrder(items, [], idOf)).toEqual(items); + it("applies the labs default (list view, #40) when nothing is stored", () => { + const labs = moduleListDefaults("labs"); + expect(parseModuleListPrefs(null, labs).view).toBe("list"); + expect( + parseModuleListPrefs(JSON.stringify({ view: "cards" }), labs).view, + ).toBe("cards"); + }); +}); + +describe("legacyModuleListPatch", () => { + it("carries only what differs from the default", () => { + expect( + legacyModuleListPatch( + "labs", + JSON.stringify({ view: "list", order: [], sortDir: "manual" }), + ), + ).toEqual({ sortDir: "manual" }); + expect( + legacyModuleListPatch( + "illness", + JSON.stringify({ view: "list", order: ["e2", "e1"] }), + ), + ).toEqual({ view: "list", order: ["e2", "e1"] }); + expect(legacyModuleListPatch("vorsorge", null)).toEqual({}); + }); +}); + +describe("carryOverLegacyModuleListPrefs", () => { + it("sends a browser-stored order up once and removes the browser copy after the server confirmed it", async () => { + store.set( + legacyModuleListStorageKey("illness"), + JSON.stringify({ view: "cards", order: ["e2", "e1"] }), + ); + const saved = resolveModuleListLayout({ illness: { order: ["e2", "e1"] } }); + vi.mocked(apiPut).mockResolvedValue(saved); + const queryClient = new QueryClient(); + + await carryOverLegacyModuleListPrefs({ + list: "illness", + layout: resolveModuleListLayout(null), + queryClient, + }); + + expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", { + version: 1, + list: "illness", + order: ["e2", "e1"], + }); + expect(store.has(legacyModuleListStorageKey("illness"))).toBe(false); + expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual( + saved, + ); + + // A second mount in the same page load does not send it again. + await carryOverLegacyModuleListPrefs({ + list: "illness", + layout: saved, + queryClient, + }); + expect(apiPut).toHaveBeenCalledTimes(1); + }); + + it("keeps the browser copy when the write fails, so the order is never lost", async () => { + store.set( + legacyModuleListStorageKey("labs"), + JSON.stringify({ order: ["m1"], sortDir: "manual" }), + ); + vi.mocked(apiPut).mockRejectedValue(new Error("offline")); + + await carryOverLegacyModuleListPrefs({ + list: "labs", + layout: resolveModuleListLayout(null), + queryClient: new QueryClient(), }); + + expect(store.has(legacyModuleListStorageKey("labs"))).toBe(true); + }); + + it("leaves a list the server already holds alone and drops the stale browser copy", async () => { + store.set( + legacyModuleListStorageKey("vorsorge"), + JSON.stringify({ order: ["r9"] }), + ); + await carryOverLegacyModuleListPrefs({ + list: "vorsorge", + layout: resolveModuleListLayout({ vorsorge: { order: ["r1"] } }), + queryClient: new QueryClient(), + }); + expect(apiPut).not.toHaveBeenCalled(); + expect(store.has(legacyModuleListStorageKey("vorsorge"))).toBe(false); + }); +}); + +describe("runSetModuleListPrefs", () => { + const t = (key: string) => key; - it("places ordered ids first, then the server-default tail", () => { - const result = applyOrder(items, ["c", "a"], idOf); - expect(result.map(idOf)).toEqual(["c", "a", "b"]); + it("applies the change at once and confirms a view change with a toast", async () => { + const queryClient = new QueryClient(); + const saved = resolveModuleListLayout({ labs: { view: "cards" } }); + vi.mocked(apiPut).mockResolvedValue(saved); + await runSetModuleListPrefs({ + list: "labs", + patch: { view: "cards" }, + queryClient, + t, }); + expect(toastWrittenOutcome).toHaveBeenCalledWith( + "success", + "settings.saved", + ); + expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual( + saved, + ); + }); - it("ignores ids in the order that are absent from the data", () => { - const result = applyOrder(items, ["z", "b"], idOf); - expect(result.map(idOf)).toEqual(["b", "a", "c"]); + it("rolls back and says so when the write fails", async () => { + const queryClient = new QueryClient(); + const before = resolveModuleListLayout(null); + queryClient.setQueryData(queryKeys.moduleListLayout(), before); + vi.mocked(apiPut).mockRejectedValue(new Error("nope")); + await runSetModuleListPrefs({ + list: "illness", + patch: { order: ["x"] }, + queryClient, + t, }); + expect(queryClient.getQueryData(queryKeys.moduleListLayout())).toEqual( + before, + ); + expect(showErrorToast).toHaveBeenCalled(); + }); +}); + +describe("applyOrder", () => { + const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const idOf = (i: { id: string }) => i.id; + + it("keeps the input order with no saved order", () => { + expect(applyOrder(items, [], idOf)).toEqual(items); + }); + + it("puts ordered ids first and the rest after in input order", () => { + expect(applyOrder(items, ["c", "a"], idOf).map(idOf)).toEqual([ + "c", + "a", + "b", + ]); + }); + + it("drops unknown ids", () => { + expect(applyOrder(items, ["z", "b"], idOf).map(idOf)).toEqual([ + "b", + "a", + "c", + ]); }); }); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index f02b0d0e7..601edaefd 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -484,6 +484,7 @@ export const USER_RESET = { medicationListLayoutJson: Prisma.DbNull, moodTagLayoutJson: Prisma.DbNull, documentsLayoutJson: Prisma.DbNull, + moduleListLayoutJson: Prisma.DbNull, reportSelectionJson: Prisma.DbNull, modulePreferencesJson: Prisma.DbNull, healthScoreConfigJson: Prisma.DbNull, @@ -510,6 +511,8 @@ export const USER_KEPT_FIELDS: Readonly> = { oidcIssuer: "a sign-in credential — the external identity binding", oidcSub: "a sign-in credential — the external identity binding", createdAt: "when the account was created; the account is preserved", + previewFeaturesJson: + "which preview features the person opted into — how the interface is shown, not record content", updatedAt: "row bookkeeping maintained by Prisma", managedProfileAt: "whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.", diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 2585a2c11..6fb173682 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -503,6 +503,8 @@ const ACCOUNT_SETTING_CODECS: Readonly< medicationListLayoutJson: json("medicationListLayoutJson"), moodTagLayoutJson: json("moodTagLayoutJson"), documentsLayoutJson: json("documentsLayoutJson"), + moduleListLayoutJson: json("moduleListLayoutJson"), + previewFeaturesJson: json("previewFeaturesJson"), reportSelectionJson: json("reportSelectionJson"), globalExcludedInjectionSites: excludedSites, healthKitConfigJson: json("healthKitConfigJson"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index b3c9ddef3..b2abed39c 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -1010,6 +1010,8 @@ export const USER_COLUMN_BACKUP_CLASS = { medicationListLayoutJson: "SETTING", moodTagLayoutJson: "SETTING", documentsLayoutJson: "SETTING", + moduleListLayoutJson: "SETTING", + previewFeaturesJson: "SETTING", reportSelectionJson: "SETTING", globalExcludedInjectionSites: "SETTING", healthKitConfigJson: "SETTING", diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts index ff5e45e11..bbed3e12f 100644 --- a/src/lib/module-list-prefs.ts +++ b/src/lib/module-list-prefs.ts @@ -1,109 +1,103 @@ "use client"; /** - * v1.18.6 — client-side per-module list preferences for the - * three secondary modules (Vorsorge, Illness, Labs). + * Per-list presentation for the labs, illness and checkups lists: card or + * list view, a manual order, and (labs only) the sort. * - * These modules do not carry a server-side list-layout column the way - * Medications does (`/api/medications/layout`). Rather than mint three new - * Prisma columns + routes — which a parallel backend phase owns — the - * reorder + view choice live in `localStorage`, keyed per module. The choice - * is presentational only: it reorders / reshapes what the page already - * fetched and never feeds a server read, so a missing or stale blob degrades - * to the server's own default order. + * The choices live on the server (`GET/PUT /api/settings/module-list-layout`, + * `User.moduleListLayoutJson`) so they follow a person to every device. Until + * v1.44 they lived in this browser's `localStorage`, one key per list. Those + * values are carried over, never reset: when the server holds nothing for a + * list and this browser still has a value, the value is sent up once and the + * browser copy is removed after the server confirmed it. While the first read + * is in flight the browser copy (or the default) paints, so a person who + * arranged a list does not see it jump back to the default order. * - * Shape per module (one localStorage key each): - * { view: "cards" | "list", order: string[] } - * `order` is an id allow-list; ids absent from the live data are ignored and - * ids present in the data but absent from `order` sort after the ordered - * block (the server default order is preserved for the tail). + * The choice is presentational only: it reorders or reshapes what the page + * already fetched, so a missing or stale value degrades to the server order. */ -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; -export const MODULE_LIST_VIEWS = ["cards", "list"] as const; -export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number]; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + isModuleListView, + isModuleSortDir, + moduleListDefaults, + normaliseModuleListOrder, + resolveModuleListLayout, + type ModuleListKey, + type ModuleListLayout, + type ModuleListPrefs, + type ModuleListView, + type ModuleSortDir, +} from "@/lib/settings/module-list-layout"; -export type ModuleListKey = "vorsorge" | "illness" | "labs"; +export type { + ModuleListKey, + ModuleListPrefs, + ModuleListView, + ModuleSortDir, +} from "@/lib/settings/module-list-layout"; + +const ENDPOINT = "/api/settings/module-list-layout"; + +/** The browser key earlier releases stored a list's choices under. */ +export function legacyModuleListStorageKey(list: ModuleListKey): string { + return `healthlog:module-list-prefs:${list}`; +} /** - * Sort choice for the Labs surface (MOD-04): order biomarker groups by the - * recency of their most-recent reading, alphabetically by analyte name - * (#43), or `manual`, which defers to the persisted `order` array. + * Parse a browser-stored blob from before the server column. Anything + * malformed collapses onto the defaults field by field. */ -export const MODULE_SORT_DIRS = [ - "recentDesc", - "recentAsc", - "alphaAsc", - "alphaDesc", - "manual", -] as const; -export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number]; - -export interface ModuleListPrefs { - view: ModuleListView; - order: string[]; - /** Only consumed by the Labs surface; defaults to most-recent-first. */ - sortDir: ModuleSortDir; -} - -const DEFAULT_PREFS: ModuleListPrefs = { - view: "cards", - order: [], - sortDir: "recentDesc", -}; - -/** - * Per-module default overrides applied when no preference blob is stored yet. - * Labs defaults to the compact list view (#40) so the dense reading table is - * the first thing the user sees; Vorsorge and Illness keep the card grid. - */ -const MODULE_DEFAULTS: Partial< - Record> -> = { - labs: { view: "list" }, -}; - -function defaultsFor(module: ModuleListKey): ModuleListPrefs { - return { ...DEFAULT_PREFS, ...MODULE_DEFAULTS[module] }; -} - -function storageKey(module: ModuleListKey): string { - return `healthlog:module-list-prefs:${module}`; -} - -function isView(value: unknown): value is ModuleListView { - return ( - typeof value === "string" && - (MODULE_LIST_VIEWS as readonly string[]).includes(value) - ); -} - -function isSortDir(value: unknown): value is ModuleSortDir { - return ( - typeof value === "string" && - (MODULE_SORT_DIRS as readonly string[]).includes(value) - ); -} - export function parseModuleListPrefs( raw: string | null, - defaults: ModuleListPrefs = DEFAULT_PREFS, + defaults: ModuleListPrefs, ): ModuleListPrefs { if (!raw) return defaults; try { const blob = JSON.parse(raw) as Record; - const view = isView(blob.view) ? blob.view : defaults.view; - const order = Array.isArray(blob.order) - ? blob.order.filter((v): v is string => typeof v === "string") - : []; - const sortDir = isSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir; - return { view, order, sortDir }; + return { + view: isModuleListView(blob.view) ? blob.view : defaults.view, + order: normaliseModuleListOrder(blob.order) ?? [], + sortDir: isModuleSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir, + }; } catch { return defaults; } } +/** + * The fields of a browser-stored value worth carrying over: the ones that + * differ from the default. The old blob always held all three fields, so a + * field equal to the default cannot be told apart from one never chosen and + * is left to follow the default. + */ +export function legacyModuleListPatch( + list: ModuleListKey, + raw: string | null, +): Partial { + if (!raw) return {}; + const defaults = moduleListDefaults(list); + const legacy = parseModuleListPrefs(raw, defaults); + return { + ...(legacy.view !== defaults.view ? { view: legacy.view } : {}), + ...(legacy.order.length > 0 ? { order: legacy.order } : {}), + ...(legacy.sortDir !== defaults.sortDir ? { sortDir: legacy.sortDir } : {}), + }; +} + /** * Apply a persisted id order to a live list. Items whose id appears in * `order` come first in that order; everything else keeps the input order @@ -127,84 +121,165 @@ export function applyOrder( return [...ordered, ...rest]; } -// A module-scoped event so every mounted hook for the same module repaints -// when one writer flushes a change (the settings page + the module page can -// both be alive across a client navigation without a full reload). -const listeners = new Set<() => void>(); -function emit() { - for (const l of listeners) l(); +function readLegacy(list: ModuleListKey): string | null { + try { + return window.localStorage.getItem(legacyModuleListStorageKey(list)); + } catch { + return null; + } +} + +function removeLegacy(list: ModuleListKey): void { + try { + window.localStorage.removeItem(legacyModuleListStorageKey(list)); + } catch { + // Storage unavailable: nothing to remove. + } +} + +/** Lists already carried over (or checked) in this page load. */ +const carriedOver = new Set(); + +/** + * Carry a browser-stored value up to the server, once, when the server holds + * nothing for the list. A failed write leaves the browser copy in place and + * tries again on the next mount; a list the server already holds keeps the + * server value and drops the stale browser copy. + */ +export async function carryOverLegacyModuleListPrefs(deps: { + list: ModuleListKey; + layout: ModuleListLayout; + queryClient: QueryClient; +}): Promise { + const { list, layout, queryClient } = deps; + if (typeof window === "undefined" || carriedOver.has(list)) return; + carriedOver.add(list); + const raw = readLegacy(list); + if (raw === null) return; + if (layout.lists[list].saved) { + removeLegacy(list); + return; + } + const patch = legacyModuleListPatch(list, raw); + if (Object.keys(patch).length === 0) { + removeLegacy(list); + return; + } + try { + const saved = await apiPut(ENDPOINT, { + version: 1, + list, + ...patch, + }); + if (saved) queryClient.setQueryData(queryKeys.moduleListLayout(), saved); + removeLegacy(list); + } catch { + carriedOver.delete(list); + } +} + +/** Forget which lists were carried over. Tests only. */ +export function resetModuleListCarryOverForTests(): void { + carriedOver.clear(); +} + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** + * Apply one field optimistically (the control has to feel instant), then + * persist it. On failure the cache rolls back and a toast says so; a view or + * sort change confirms with a toast, an order change is its own feedback. + */ +export async function runSetModuleListPrefs(deps: { + list: ModuleListKey; + patch: Partial; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { list, patch, queryClient, t } = deps; + const key = queryKeys.moduleListLayout(); + if (queryClient.isFetching({ queryKey: key }) > 0) { + await queryClient.cancelQueries({ queryKey: key }); + } + const previous = queryClient.getQueryData(key); + const base = previous ?? resolveModuleListLayout(null); + queryClient.setQueryData(key, { + ...base, + lists: { + ...base.lists, + [list]: { ...base.lists[list], ...patch, saved: true }, + }, + }); + try { + const saved = await apiPut(ENDPOINT, { + version: 1, + list, + ...patch, + }); + if (saved) queryClient.setQueryData(key, saved); + if (patch.view !== undefined || patch.sortDir !== undefined) { + toastWrittenOutcome("success", t("settings.saved")); + } + } catch (failure) { + queryClient.setQueryData(key, previous); + showErrorToast(t("settings.savingError"), { cause: failure }); + } } /** - * Read + write the per-module list preferences. SSR-safe via - * `useSyncExternalStore` with a null server snapshot (parsed to defaults). + * Read + write one list's presentation. The server value once it is read; + * before that, the browser copy from an earlier release, or the default. */ -export function useModuleListPrefs(module: ModuleListKey): { +export function useModuleListPrefs(list: ModuleListKey): { prefs: ModuleListPrefs; setView: (view: ModuleListView) => void; setOrder: (order: string[]) => void; setSortDir: (sortDir: ModuleSortDir) => void; } { - const key = storageKey(module); - const defaults = useMemo(() => defaultsFor(module), [module]); + const queryClient = useQueryClient(); + const { t } = useTranslations(); - const subscribe = useCallback( - (onChange: () => void) => { - listeners.add(onChange); - const onStorage = (e: StorageEvent) => { - if (e.key === null || e.key === key) onChange(); - }; - if (typeof window !== "undefined") { - window.addEventListener("storage", onStorage); - } - return () => { - listeners.delete(onChange); - if (typeof window !== "undefined") { - window.removeEventListener("storage", onStorage); - } - }; - }, - [key], - ); + const { data } = useQuery({ + queryKey: queryKeys.moduleListLayout(), + queryFn: () => apiGet(ENDPOINT), + staleTime: 5 * 60 * 1000, + }); - const getSnapshot = useCallback(() => { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(key); - }, [key]); + useEffect(() => { + if (!data) return; + void carryOverLegacyModuleListPrefs({ list, layout: data, queryClient }); + }, [data, list, queryClient]); - const raw = useSyncExternalStore(subscribe, getSnapshot, () => null); - // `useSyncExternalStore` returns a stable `raw` string when storage hasn't - // changed, so keying the parse on it gives downstream `applyOrder` - // memoisation a stable `prefs` reference instead of a fresh object + a - // JSON.parse on every render. - const prefs = useMemo( - () => parseModuleListPrefs(raw, defaults), - [raw, defaults], - ); + // The browser copy is read only after hydration, so the server render and + // the first client paint agree (React #418). + const mounted = useMounted(); + const entry = data?.lists[list]; + const prefs = useMemo(() => { + if (entry) { + return { view: entry.view, order: entry.order, sortDir: entry.sortDir }; + } + const defaults = moduleListDefaults(list); + if (!mounted) return defaults; + return parseModuleListPrefs(readLegacy(list), defaults); + }, [entry, list, mounted]); const write = useCallback( - (next: ModuleListPrefs) => { - if (typeof window === "undefined") return; - window.localStorage.setItem(key, JSON.stringify(next)); - emit(); - }, - [key], + (patch: Partial) => + void runSetModuleListPrefs({ list, patch, queryClient, t }), + [list, queryClient, t], ); const setView = useCallback( - (view: ModuleListView) => - write({ ...parseModuleListPrefs(getSnapshot(), defaults), view }), - [write, getSnapshot, defaults], - ); - const setOrder = useCallback( - (order: string[]) => - write({ ...parseModuleListPrefs(getSnapshot(), defaults), order }), - [write, getSnapshot, defaults], + (view: ModuleListView) => write({ view }), + [write], ); + const setOrder = useCallback((order: string[]) => write({ order }), [write]); const setSortDir = useCallback( - (sortDir: ModuleSortDir) => - write({ ...parseModuleListPrefs(getSnapshot(), defaults), sortDir }), - [write, getSnapshot, defaults], + (sortDir: ModuleSortDir) => write({ sortDir }), + [write], ); return { prefs, setView, setOrder, setSortDir }; diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index a837dbdba..5b1ac7c2b 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -23,6 +23,68 @@ import { stdResponses, } from "./shared"; import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types"; +import { + MODULE_LIST_VIEWS, + MODULE_SORT_DIRS, +} from "@/lib/settings/module-list-layout"; +import { + moduleListLayoutPutSchema, + previewFeaturesPatchSchema, +} from "@/lib/validations/settings-prefs"; + +const moduleListPrefsEntry = z + .object({ + view: z + .enum(MODULE_LIST_VIEWS) + .describe( + '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.', + ), + order: z + .array(z.string()) + .describe( + "Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list follow in the server order.", + ), + sortDir: z + .enum(MODULE_SORT_DIRS) + .describe( + 'Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.', + ), + saved: z + .boolean() + .describe( + "True when the server holds a choice for this list; false means every field is a default.", + ), + }) + .meta({ id: "ModuleListPrefs" }); + +const moduleListLayoutSchema = z + .object({ + version: z.literal(1), + lists: z.object({ + labs: moduleListPrefsEntry, + illness: moduleListPrefsEntry, + vorsorge: moduleListPrefsEntry, + }), + }) + .meta({ + id: "ModuleListLayout", + description: + "Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.", + }); + +const previewFeaturesSchema = z + .object({ + topics: z + .boolean() + .describe( + "Topic overview pages above the single-metric Insights pages. Off by default.", + ), + }) + .meta({ + id: "PreviewFeatures", + description: + "Per-user opt-in to preview features, one boolean per flag, every flag off by default.", + }); // v1.16.11 — the one threshold read every dose-status consumer makes // (cards, table, take-all-due derivation). `lateMinutes` / @@ -856,6 +918,109 @@ export const settingsPaths: NonNullable = { }, }, }, + "/api/settings/module-list-layout": { + get: { + tags: ["Meta"], + summary: "Get the list presentation of labs, illness and checkups", + description: + "Every list resolved, defaults filled in, with `saved` per list. Never writes a row.", + responses: { + "200": { + description: "The resolved presentation of every list.", + content: { + "application/json": { + schema: dataEnvelope( + moduleListLayoutSchema, + "ModuleListLayoutEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Meta"], + summary: "Change the presentation of one list", + description: + "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays as stored. Returns every list resolved.", + requestBody: { + required: true, + content: { + "application/json": { + schema: moduleListLayoutPutSchema.meta({ + id: "ModuleListLayoutPutBody", + }), + }, + }, + }, + responses: { + "200": { + description: "The presentation as stored.", + content: { + "application/json": { + schema: dataEnvelope( + moduleListLayoutSchema, + "ModuleListLayoutSavedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/preview-features": { + get: { + tags: ["Meta"], + summary: "Get the preview-feature opt-ins", + description: "Every flag, off unless the caller switched it on.", + responses: { + "200": { + description: "The opt-ins.", + content: { + "application/json": { + schema: dataEnvelope( + previewFeaturesSchema, + "PreviewFeaturesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Meta"], + summary: "Switch preview features on or off", + description: + "Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.", + requestBody: { + required: true, + content: { + "application/json": { + schema: previewFeaturesPatchSchema.meta({ + id: "PreviewFeaturesPatchBody", + }), + }, + }, + }, + responses: { + "200": { + description: "The opt-ins as stored.", + content: { + "application/json": { + schema: dataEnvelope( + previewFeaturesSchema, + "PreviewFeaturesSavedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/settings/privacy-summary": { get: { tags: ["Meta"], diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 6f9f4e930..95c1a4f13 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -38,6 +38,15 @@ export const settingsKeys = { */ modulesPrefs: () => ["settings", "modules"] as const, + /** + * The labs / illness / checkups list presentation behind + * `GET/PUT /api/settings/module-list-layout` (view, manual order, labs sort). + */ + moduleListLayout: () => ["settings", "module-list-layout"] as const, + + /** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */ + previewFeatures: () => ["settings", "preview-features"] as const, + /** * v1.35.0 — which pillars count toward the Health Score, behind * `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than diff --git a/src/lib/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts new file mode 100644 index 000000000..c431a945b --- /dev/null +++ b/src/lib/settings/__tests__/module-list-layout.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + MODULE_LIST_ORDER_MAX, + normaliseModuleListOrder, + resolveModuleListLayout, + storedModuleListLayout, +} from "../module-list-layout"; + +describe("resolveModuleListLayout", () => { + it("answers the defaults, nothing saved, for an empty column", () => { + const layout = resolveModuleListLayout(null); + expect(layout.lists.labs).toEqual({ + view: "list", + order: [], + sortDir: "recentDesc", + saved: false, + }); + expect(layout.lists.illness.view).toBe("cards"); + expect(layout.lists.vorsorge.view).toBe("cards"); + expect(layout.lists.illness.saved).toBe(false); + }); + + it("fills a partial list field by field and marks it saved", () => { + const layout = resolveModuleListLayout({ + version: 1, + illness: { order: ["b", "a"] }, + }); + expect(layout.lists.illness).toEqual({ + view: "cards", + order: ["b", "a"], + sortDir: "recentDesc", + saved: true, + }); + expect(layout.lists.labs.saved).toBe(false); + }); + + it("drops malformed values instead of failing", () => { + const layout = resolveModuleListLayout({ + labs: { view: "grid", sortDir: 3, order: "x" }, + vorsorge: "nonsense", + }); + expect(layout.lists.labs).toEqual({ + view: "list", + order: [], + sortDir: "recentDesc", + saved: false, + }); + expect(layout.lists.vorsorge.saved).toBe(false); + }); +}); + +describe("normaliseModuleListOrder", () => { + it("keeps strings, drops duplicates, empties and over-long ids", () => { + expect( + normaliseModuleListOrder(["a", "a", "", 4, "b", "x".repeat(65)]), + ).toEqual(["a", "b"]); + }); + + it("caps the length", () => { + const ids = Array.from({ length: MODULE_LIST_ORDER_MAX + 10 }, (_, i) => + String(i), + ); + expect(normaliseModuleListOrder(ids)).toHaveLength(MODULE_LIST_ORDER_MAX); + }); + + it("answers null for a non-array", () => { + expect(normaliseModuleListOrder("a")).toBeNull(); + }); +}); + +describe("storedModuleListLayout", () => { + it("lists only the lists that carry a choice", () => { + expect( + storedModuleListLayout({ labs: { sortDir: "manual" }, illness: {} }), + ).toEqual({ labs: { sortDir: "manual" } }); + }); +}); diff --git a/src/lib/settings/__tests__/preview-features.test.ts b/src/lib/settings/__tests__/preview-features.test.ts new file mode 100644 index 000000000..df5251b6d --- /dev/null +++ b/src/lib/settings/__tests__/preview-features.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_PREVIEW_FEATURES, + resolvePreviewFeatures, +} from "../preview-features"; + +describe("resolvePreviewFeatures", () => { + it("is all off by default", () => { + expect(DEFAULT_PREVIEW_FEATURES).toEqual({ topics: false }); + expect(resolvePreviewFeatures(null)).toEqual({ topics: false }); + }); + + it("reads a flag only on an explicit true", () => { + expect(resolvePreviewFeatures({ topics: true })).toEqual({ topics: true }); + expect(resolvePreviewFeatures({ topics: "true" })).toEqual({ + topics: false, + }); + }); + + it("drops unknown keys", () => { + expect(resolvePreviewFeatures({ topics: true, retired: true })).toEqual({ + topics: true, + }); + }); +}); diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts new file mode 100644 index 000000000..2783db463 --- /dev/null +++ b/src/lib/settings/module-list-layout.ts @@ -0,0 +1,163 @@ +/** + * List presentation for the labs, illness and checkups lists, persisted in + * `User.moduleListLayoutJson`. + * + * Three independent choices per list, all display-only: + * + * - `view`: the card grid or the compact list. + * - `order`: an id allow-list. Ids absent from the live data are ignored; + * ids present in the data but absent from `order` sort after the ordered + * block in the server's own order. + * - `sortDir`: only the labs list reads it (recency, alphabetical, or the + * manual `order`). + * + * Null / missing column = the defaults below. The GET never lazy-writes, and + * the PUT stores only the fields a person has actually chosen, so a default + * that changes later still reaches every list nobody arranged. Mirrors + * `documents-layout.ts`: tolerant resolver on read, preserve-when-absent PUT. + * + * `saved` tells the client whether the server holds a choice for a list. The + * same choices lived in the browser before this column existed; the client + * copies a browser value up once, only while `saved` is false, so an order a + * person built is carried over instead of reset. + */ + +export const MODULE_LIST_KEYS = ["labs", "illness", "vorsorge"] as const; +export type ModuleListKey = (typeof MODULE_LIST_KEYS)[number]; + +export const MODULE_LIST_VIEWS = ["cards", "list"] as const; +export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number]; + +export const MODULE_SORT_DIRS = [ + "recentDesc", + "recentAsc", + "alphaAsc", + "alphaDesc", + "manual", +] as const; +export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number]; + +/** Longest order a list keeps; a longer one is cut, never refused. */ +export const MODULE_LIST_ORDER_MAX = 500; +/** Longest id an order entry may carry (cuid and uuid fit comfortably). */ +export const MODULE_LIST_ID_MAX = 64; + +export interface ModuleListPrefs { + view: ModuleListView; + order: string[]; + sortDir: ModuleSortDir; +} + +export interface ModuleListPrefsEntry extends ModuleListPrefs { + /** True when the server holds at least one choice for this list. */ + saved: boolean; +} + +export interface ModuleListLayout { + version: 1; + lists: Record; +} + +const BASE_DEFAULTS: ModuleListPrefs = { + view: "cards", + order: [], + sortDir: "recentDesc", +}; + +/** Labs opens on the compact list, the dense reading table (#40). */ +const LIST_DEFAULTS: Record = { + labs: { ...BASE_DEFAULTS, view: "list" }, + illness: { ...BASE_DEFAULTS }, + vorsorge: { ...BASE_DEFAULTS }, +}; + +export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs { + const defaults = LIST_DEFAULTS[list]; + return { ...defaults, order: [...defaults.order] }; +} + +export function isModuleListKey(value: unknown): value is ModuleListKey { + return ( + typeof value === "string" && + (MODULE_LIST_KEYS as readonly string[]).includes(value) + ); +} + +export function isModuleListView(value: unknown): value is ModuleListView { + return ( + typeof value === "string" && + (MODULE_LIST_VIEWS as readonly string[]).includes(value) + ); +} + +export function isModuleSortDir(value: unknown): value is ModuleSortDir { + return ( + typeof value === "string" && + (MODULE_SORT_DIRS as readonly string[]).includes(value) + ); +} + +/** Keep string ids of a sane length, first occurrence wins, capped. */ +export function normaliseModuleListOrder(raw: unknown): string[] | null { + if (!Array.isArray(raw)) return null; + const seen = new Set(); + const out: string[] = []; + for (const value of raw) { + if (typeof value !== "string") continue; + if (value.length === 0 || value.length > MODULE_LIST_ID_MAX) continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + if (out.length >= MODULE_LIST_ORDER_MAX) break; + } + return out; +} + +/** + * The fields a stored blob explicitly carries for one list, each validated. + * Unknown or malformed values drop out, so the default fills them on read. + */ +export function storedModuleListChoices( + raw: unknown, +): Partial { + if (raw === null || raw === undefined || typeof raw !== "object") return {}; + const blob = raw as { view?: unknown; order?: unknown; sortDir?: unknown }; + const order = normaliseModuleListOrder(blob.order); + return { + ...(isModuleListView(blob.view) ? { view: blob.view } : {}), + ...(order !== null ? { order } : {}), + ...(isModuleSortDir(blob.sortDir) ? { sortDir: blob.sortDir } : {}), + }; +} + +/** Every list's stored choices, keyed by list. */ +export function storedModuleListLayout( + raw: unknown, +): Partial>> { + if (raw === null || raw === undefined || typeof raw !== "object") return {}; + const blob = raw as Record; + const out: Partial>> = {}; + for (const list of MODULE_LIST_KEYS) { + const choices = storedModuleListChoices(blob[list]); + if (Object.keys(choices).length > 0) out[list] = choices; + } + return out; +} + +/** + * Any malformed, partial or legacy blob collapses onto the defaults field by + * field, so a GET never fails on a stored row. + */ +export function resolveModuleListLayout(raw: unknown): ModuleListLayout { + const stored = storedModuleListLayout(raw); + const lists = {} as Record; + for (const list of MODULE_LIST_KEYS) { + const choices = stored[list]; + lists[list] = { + ...moduleListDefaults(list), + ...choices, + saved: choices !== undefined, + }; + } + return { version: 1, lists }; +} diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts new file mode 100644 index 000000000..cd184520d --- /dev/null +++ b/src/lib/settings/preview-features.ts @@ -0,0 +1,31 @@ +/** + * Per-user opt-in to preview features, persisted in `User.previewFeaturesJson`. + * + * One boolean per flag, every flag off by default. A preview feature is + * something finished enough to try and not yet the default; switching one on + * adds it beside what is there and never replaces an existing page. + * + * Flags: + * - `topics`: topic overview pages above the single-metric Insights pages. + * Stored and shown in Settings > System; no surface reads it yet. + * + * Unknown keys in a stored blob are dropped on read, so a flag retired later + * disappears cleanly and a malformed blob reads as everything off. + */ + +export const PREVIEW_FEATURE_KEYS = ["topics"] as const; +export type PreviewFeatureKey = (typeof PREVIEW_FEATURE_KEYS)[number]; + +export type PreviewFeatures = Record; + +export const DEFAULT_PREVIEW_FEATURES: PreviewFeatures = { topics: false }; + +export function resolvePreviewFeatures(raw: unknown): PreviewFeatures { + const out: PreviewFeatures = { ...DEFAULT_PREVIEW_FEATURES }; + if (raw === null || raw === undefined || typeof raw !== "object") return out; + const blob = raw as Record; + for (const key of PREVIEW_FEATURE_KEYS) { + if (blob[key] === true) out[key] = true; + } + return out; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 957caaf9e..4d3aac9f4 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2154,6 +2154,8 @@ const accountSettingsBackupSchema = z medicationListLayoutJson: z.unknown().optional(), moodTagLayoutJson: z.unknown().optional(), documentsLayoutJson: z.unknown().optional(), + moduleListLayoutJson: z.unknown().optional(), + previewFeaturesJson: z.unknown().optional(), reportSelectionJson: z.unknown().optional(), globalExcludedInjectionSites: z.array(z.string()).optional(), healthKitConfigJson: z.unknown().optional(), diff --git a/src/lib/validations/settings-prefs.ts b/src/lib/validations/settings-prefs.ts new file mode 100644 index 000000000..c3cc82112 --- /dev/null +++ b/src/lib/validations/settings-prefs.ts @@ -0,0 +1,37 @@ +/** + * Request schemas for the per-user Settings preferences that are not part of + * the account payload: the list presentation of the labs, illness and + * checkups lists, and the preview-feature opt-ins. Shared by the routes and + * the OpenAPI registry so the wire contract has one source. + */ +import { z } from "zod/v4"; + +import { + MODULE_LIST_ID_MAX, + MODULE_LIST_KEYS, + MODULE_LIST_ORDER_MAX, + MODULE_LIST_VIEWS, + MODULE_SORT_DIRS, +} from "@/lib/settings/module-list-layout"; + +export const moduleListLayoutPutSchema = z + .object({ + version: z.literal(1), + list: z.enum(MODULE_LIST_KEYS), + view: z.enum(MODULE_LIST_VIEWS).optional(), + order: z + .array(z.string().min(1).max(MODULE_LIST_ID_MAX)) + .max(MODULE_LIST_ORDER_MAX) + .optional(), + sortDir: z.enum(MODULE_SORT_DIRS).optional(), + }) + .strict(); + +export const previewFeaturesPatchSchema = z + .object({ + topics: z.boolean().optional(), + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "Name at least one preview feature.", + }); diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts new file mode 100644 index 000000000..5dcc4d7c5 --- /dev/null +++ b/tests/integration/settings-prefs-routes.test.ts @@ -0,0 +1,216 @@ +/** + * The two per-user Settings preferences that live outside the account + * payload, end to end against Postgres: + * + * - `/api/settings/module-list-layout`: defaults without a write, one list + * changed per PUT with preserve-when-absent fields and untouched neighbours, + * the multi-issue 422 for unknown values, and the column carrying only what + * was chosen. + * - `/api/settings/preview-features`: all off by default, PATCH keeps the + * flags it does not name, an unknown flag or an empty body is refused. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedUser(): Promise<{ userId: string }> { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "settings-prefs-user", + email: "settings-prefs@example.test", + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return { userId: user.id }; +} + +type Handler = (req: Request) => Promise; + +function json(method: string, url: string, body: unknown): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/settings/module-list-layout — integration", () => { + const URL = "http://localhost/api/settings/module-list-layout"; + + it("answers the defaults without writing a row", async () => { + const { userId } = await seedUser(); + const { GET } = await import("@/app/api/settings/module-list-layout/route"); + const res = await (GET as Handler)(new Request(URL)); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { lists: Record }; + }; + expect(body.data.lists.labs).toMatchObject({ view: "list", saved: false }); + expect(body.data.lists.illness).toMatchObject({ + view: "cards", + saved: false, + }); + const row = await getPrismaClient().user.findUnique({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + expect(row?.moduleListLayoutJson).toBeNull(); + }); + + it("changes one list per PUT and keeps the fields and lists it does not name", async () => { + const { userId } = await seedUser(); + const { GET, PUT } = + await import("@/app/api/settings/module-list-layout/route"); + + const first = await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "labs", order: ["m2", "m1"] }), + ); + expect(first.status).toBe(200); + const second = await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "labs", sortDir: "manual" }), + ); + expect(second.status).toBe(200); + await (PUT as Handler)( + json("PUT", URL, { version: 1, list: "illness", view: "list" }), + ); + + const res = await (GET as Handler)(new Request(URL)); + const body = (await res.json()) as { + data: { + lists: Record< + string, + { view: string; order: string[]; sortDir: string; saved: boolean } + >; + }; + }; + expect(body.data.lists.labs).toEqual({ + view: "list", + order: ["m2", "m1"], + sortDir: "manual", + saved: true, + }); + expect(body.data.lists.illness).toMatchObject({ + view: "list", + order: [], + saved: true, + }); + expect(body.data.lists.vorsorge.saved).toBe(false); + + // Only chosen fields are stored, so a default can still move later. + const row = await getPrismaClient().user.findUnique({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + expect(row?.moduleListLayoutJson).toEqual({ + version: 1, + labs: { order: ["m2", "m1"], sortDir: "manual" }, + illness: { view: "list" }, + }); + }); + + it("refuses unknown lists, values and keys with every issue named", async () => { + await seedUser(); + const { PUT } = await import("@/app/api/settings/module-list-layout/route"); + const res = await (PUT as Handler)( + json("PUT", URL, { + version: 1, + list: "weight", + view: "grid", + extra: true, + }), + ); + expect(res.status).toBe(422); + }); + + it("refuses a caller without a session", async () => { + const { GET } = await import("@/app/api/settings/module-list-layout/route"); + const res = await (GET as Handler)(new Request(URL)); + expect(res.status).toBe(401); + }); +}); + +describe("/api/settings/preview-features — integration", () => { + const URL = "http://localhost/api/settings/preview-features"; + + it("is all off by default and keeps a flag once switched on", async () => { + const { userId } = await seedUser(); + const { GET, PATCH } = + await import("@/app/api/settings/preview-features/route"); + + const before = await (GET as Handler)(new Request(URL)); + expect(((await before.json()) as { data: unknown }).data).toEqual({ + topics: false, + }); + + const on = await (PATCH as Handler)(json("PATCH", URL, { topics: true })); + expect(on.status).toBe(200); + expect(((await on.json()) as { data: unknown }).data).toEqual({ + topics: true, + }); + + const after = await (GET as Handler)(new Request(URL)); + expect(((await after.json()) as { data: unknown }).data).toEqual({ + topics: true, + }); + const row = await getPrismaClient().user.findUnique({ + where: { id: userId }, + select: { previewFeaturesJson: true }, + }); + expect(row?.previewFeaturesJson).toEqual({ topics: true }); + }); + + it("refuses an unknown flag and an empty body", async () => { + await seedUser(); + const { PATCH } = await import("@/app/api/settings/preview-features/route"); + const unknown = await (PATCH as Handler)( + json("PATCH", URL, { topics: true, darkMagic: true }), + ); + expect(unknown.status).toBe(422); + const empty = await (PATCH as Handler)(json("PATCH", URL, {})); + expect(empty.status).toBe(422); + }); +}); From 23f6fe4c6eef913b871341988e9ca8a00970ff5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:16 +0200 Subject: [PATCH 02/98] feat(integrations): add one token lifecycle for every OAuth provider A provider describes where its grant lives and how its token endpoint is called; the kit reads the grant, refreshes it under the per-user advisory lock, re-reads under the lock so a peer's refresh or a reconnect wins, and writes back only over the refresh token it spent. Retry-After is read in one place, both forms, with a cap. --- src/lib/google-health/client.ts | 9 +- .../oauth-kit/__tests__/retry-after.test.ts | 41 +++ .../oauth-kit/__tests__/token.test.ts | 223 ++++++++++++++++ src/lib/integrations/oauth-kit/retry-after.ts | 42 +++ src/lib/integrations/oauth-kit/token.ts | 246 ++++++++++++++++++ src/lib/integrations/oauth-refresh.ts | 9 +- src/lib/strava/client.ts | 10 +- 7 files changed, 570 insertions(+), 10 deletions(-) create mode 100644 src/lib/integrations/oauth-kit/__tests__/retry-after.test.ts create mode 100644 src/lib/integrations/oauth-kit/__tests__/token.test.ts create mode 100644 src/lib/integrations/oauth-kit/retry-after.ts create mode 100644 src/lib/integrations/oauth-kit/token.ts diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index a0e3a7aa4..dbdd331b3 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -56,6 +56,7 @@ import { noteGoogleHealthWalkPage, } from "./backfill-bounds"; import { walkPages } from "@/lib/integrations/page-walk"; +import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; export * from "./mappers"; @@ -1026,12 +1027,8 @@ export function googleHealthRetryDelayMs( json: unknown, now: number = Date.now(), ): number | undefined { - const header = res.headers.get("retry-after")?.trim(); - if (header) { - if (/^\d+$/.test(header)) return Number.parseInt(header, 10) * 1000; - const at = Date.parse(header); - if (Number.isFinite(at)) return Math.max(0, at - now); - } + const fromHeader = retryAfterMsOf(res, now); + if (fromHeader !== undefined) return fromHeader; const details = (json as { error?: { details?: unknown } } | null)?.error ?.details; if (Array.isArray(details)) { diff --git a/src/lib/integrations/oauth-kit/__tests__/retry-after.test.ts b/src/lib/integrations/oauth-kit/__tests__/retry-after.test.ts new file mode 100644 index 000000000..3466fa14b --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/retry-after.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + parseRetryAfterMs, + RETRY_AFTER_CAP_MS, + retryAfterMsOf, +} from "../retry-after"; + +const NOW = Date.parse("2026-10-21T12:00:00.000Z"); + +describe("parseRetryAfterMs", () => { + it("reads delay seconds", () => { + expect(parseRetryAfterMs("30", NOW)).toBe(30_000); + expect(parseRetryAfterMs(" 1.5 ", NOW)).toBe(1_500); + }); + + it("reads an HTTP date, a past one as zero", () => { + expect(parseRetryAfterMs("Wed, 21 Oct 2026 12:01:00 GMT", NOW)).toBe( + 60_000, + ); + expect(parseRetryAfterMs("Wed, 21 Oct 2026 11:00:00 GMT", NOW)).toBe(0); + }); + + it("caps an absurd wait", () => { + expect(parseRetryAfterMs("999999999", NOW)).toBe(RETRY_AFTER_CAP_MS); + }); + + it("ignores what it cannot read", () => { + expect(parseRetryAfterMs(null, NOW)).toBeUndefined(); + expect(parseRetryAfterMs("", NOW)).toBeUndefined(); + expect(parseRetryAfterMs("soon", NOW)).toBeUndefined(); + expect(parseRetryAfterMs("-5", NOW)).toBeUndefined(); + }); + + it("reads it off a response", () => { + const res = new Response(null, { + status: 429, + headers: { "Retry-After": "7" }, + }); + expect(retryAfterMsOf(res, NOW)).toBe(7_000); + }); +}); diff --git a/src/lib/integrations/oauth-kit/__tests__/token.test.ts b/src/lib/integrations/oauth-kit/__tests__/token.test.ts new file mode 100644 index 000000000..18eb3bd35 --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/token.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { lock } = vi.hoisted(() => ({ lock: vi.fn(async () => undefined) })); + +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: async (fn: (tx: unknown) => unknown) => fn({ tx: true }), + }, +})); +vi.mock("@/lib/crypto", () => ({ + encrypt: (v: string) => `enc(${v})`, + decrypt: (v: string) => v.replace(/^enc\((.*)\)$/, "$1"), +})); +vi.mock("@/lib/integrations/oauth-refresh", () => ({ + acquireProviderTokenRefreshLock: lock, + PROVIDER_REFRESH_TRANSACTION_OPTIONS: {}, +})); + +import { + obtainAccessToken, + type NextGrant, + type OAuthTokenDescriptor, + type StoredGrant, +} from "../token"; + +const NOW = Date.parse("2026-10-21T12:00:00.000Z"); + +function harness(opts: { + grant: StoredGrant | null; + tracksExpiry?: boolean; + refresh?: OAuthTokenDescriptor["refresh"]; + creds?: string | null; +}) { + let stored = opts.grant; + const writes: NextGrant[] = []; + const reads: Array = []; + const descriptor: OAuthTokenDescriptor = { + provider: "withings", + tracksExpiry: opts.tracksExpiry ?? true, + expiryBufferMs: 5 * 60 * 1000, + readGrant: async () => { + reads.push(stored); + return stored; + }, + writeGrant: async (_db, _user, spent, next) => { + if (!stored || stored.refreshCiphertext !== spent) return 0; + writes.push(next); + stored = next; + return 1; + }, + loadCredentials: async () => + opts.creds === undefined ? "creds" : opts.creds, + refresh: + opts.refresh === undefined + ? vi.fn(async () => ({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 3600, + })) + : opts.refresh, + }; + return { + descriptor, + writes, + reads, + replace(g: StoredGrant | null) { + stored = g; + }, + }; +} + +const grant = (over: Partial = {}): StoredGrant => ({ + accessCiphertext: "enc(old-access)", + refreshCiphertext: "enc(old-refresh)", + expiresAt: new Date(NOW + 60 * 60 * 1000), + ...over, +}); + +beforeEach(() => lock.mockClear()); + +describe("obtainAccessToken", () => { + it("answers none without a stored grant", async () => { + const h = harness({ grant: null }); + await expect( + obtainAccessToken(h.descriptor, "u", { now: () => NOW }), + ).resolves.toEqual({ kind: "none" }); + }); + + it("returns a fresh token without taking the lock", async () => { + const h = harness({ grant: grant() }); + const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW }); + expect(res).toMatchObject({ kind: "token", accessToken: "old-access" }); + expect(lock).not.toHaveBeenCalled(); + expect(h.descriptor.refresh).not.toHaveBeenCalled(); + }); + + it("refreshes a stale token under the lock and writes both tokens", async () => { + const h = harness({ grant: grant({ expiresAt: new Date(NOW + 1000) }) }); + const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW }); + expect(lock).toHaveBeenCalledWith({ tx: true }, "withings", "u"); + expect(res).toMatchObject({ + kind: "token", + accessToken: "new-access", + refreshed: true, + }); + expect(h.writes).toEqual([ + { + accessCiphertext: "enc(new-access)", + refreshCiphertext: "enc(new-refresh)", + expiresAt: new Date(NOW + 3600 * 1000), + }, + ]); + }); + + it("keeps the stored refresh token when the response carries none", async () => { + const h = harness({ + grant: grant({ expiresAt: new Date(NOW - 1) }), + refresh: vi.fn(async () => ({ access_token: "a2", expires_in: 60 })), + }); + await obtainAccessToken(h.descriptor, "u", { now: () => NOW }); + expect(h.writes[0]?.refreshCiphertext).toBe("enc(old-refresh)"); + }); + + it("uses a peer's refresh it finds under the lock instead of spending the token again", async () => { + const stale = grant({ expiresAt: new Date(NOW - 1) }); + const h = harness({ grant: stale }); + // The peer commits between the first read and the read under the lock. + let first = true; + const read = h.descriptor.readGrant; + h.descriptor.readGrant = async (db, user) => { + const g = await read(db, user); + if (first) { + first = false; + h.replace( + grant({ + accessCiphertext: "enc(peer-access)", + refreshCiphertext: "enc(peer-refresh)", + }), + ); + } + return g; + }; + const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW }); + expect(res).toMatchObject({ accessToken: "peer-access", refreshed: false }); + expect(h.descriptor.refresh).not.toHaveBeenCalled(); + }); + + it("after a refusal refreshes once, and a later refusal of the same token reuses the result", async () => { + const h = harness({ + grant: grant({ expiresAt: null }), + tracksExpiry: false, + }); + const a = await obtainAccessToken(h.descriptor, "u", { + afterRefusalOf: "enc(old-refresh)", + now: () => NOW, + }); + const b = await obtainAccessToken(h.descriptor, "u", { + afterRefusalOf: "enc(old-refresh)", + now: () => NOW, + }); + expect(a).toMatchObject({ accessToken: "new-access", refreshed: true }); + expect(b).toMatchObject({ accessToken: "new-access", refreshed: false }); + expect(h.descriptor.refresh).toHaveBeenCalledTimes(1); + expect(h.writes[0]?.expiresAt).toBeNull(); + }); + + it("lets a reconnect that lands during the refresh win", async () => { + const h = harness({ grant: grant({ expiresAt: new Date(NOW - 1) }) }); + h.descriptor = { + ...h.descriptor, + refresh: vi.fn(async () => { + h.replace( + grant({ + accessCiphertext: "enc(reconnected)", + refreshCiphertext: "enc(reconnected-refresh)", + }), + ); + return { access_token: "late", refresh_token: "late-r", expires_in: 1 }; + }), + }; + const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW }); + expect(res).toMatchObject({ accessToken: "reconnected", refreshed: false }); + expect(h.writes).toEqual([]); + }); + + it("answers no_credentials when the client is gone", async () => { + const h = harness({ + grant: grant({ expiresAt: new Date(NOW - 1) }), + creds: null, + }); + await expect( + obtainAccessToken(h.descriptor, "u", { now: () => NOW }), + ).resolves.toEqual({ kind: "no_credentials" }); + }); + + it("never refreshes for a provider without refresh tokens", async () => { + const h = harness({ + grant: grant({ refreshCiphertext: null, expiresAt: null }), + tracksExpiry: false, + refresh: null, + }); + const res = await obtainAccessToken(h.descriptor, "u", { + force: true, + now: () => NOW, + }); + expect(res).toMatchObject({ kind: "token", accessToken: "old-access" }); + expect(lock).not.toHaveBeenCalled(); + }); + + it("rethrows the provider's refusal untouched", async () => { + const boom = new Error("invalid_grant"); + const h = harness({ + grant: grant({ expiresAt: new Date(NOW - 1) }), + refresh: vi.fn(async () => { + throw boom; + }), + }); + await expect( + obtainAccessToken(h.descriptor, "u", { now: () => NOW }), + ).rejects.toBe(boom); + expect(h.writes).toEqual([]); + }); +}); diff --git a/src/lib/integrations/oauth-kit/retry-after.ts b/src/lib/integrations/oauth-kit/retry-after.ts new file mode 100644 index 000000000..cf6a42f58 --- /dev/null +++ b/src/lib/integrations/oauth-kit/retry-after.ts @@ -0,0 +1,42 @@ +/** + * How long a provider asked to be left alone after a 429 or 503. + * + * `Retry-After` comes in two forms (RFC 9110 10.2.3): a number of seconds, or + * an HTTP date. Every provider client reads it through here, so a date form is + * not honoured by one provider and ignored by the next. + */ + +/** Upper bound on any wait a provider can ask for, so a bad header cannot park a job for days. */ +export const RETRY_AFTER_CAP_MS = 24 * 60 * 60 * 1000; + +/** + * The delay a `Retry-After` value asks for, in ms, or undefined when the value + * is absent or unreadable. A date in the past reads as 0. + */ +export function parseRetryAfterMs( + value: string | null | undefined, + now: number = Date.now(), +): number | undefined { + const header = value?.trim(); + if (!header) return undefined; + if (/^\d+(?:\.\d+)?$/.test(header)) { + return Math.min( + RETRY_AFTER_CAP_MS, + Math.round(Number.parseFloat(header) * 1000), + ); + } + // An HTTP date always names its weekday and month; a bare number that is not + // a delay (a negative one) is not a date either, whatever Date.parse thinks. + if (!/[a-z]/i.test(header)) return undefined; + const at = Date.parse(header); + if (!Number.isFinite(at)) return undefined; + return Math.min(RETRY_AFTER_CAP_MS, Math.max(0, at - now)); +} + +/** The `Retry-After` delay of a response, in ms. */ +export function retryAfterMsOf( + res: Pick, + now: number = Date.now(), +): number | undefined { + return parseRetryAfterMs(res.headers.get("retry-after"), now); +} diff --git a/src/lib/integrations/oauth-kit/token.ts b/src/lib/integrations/oauth-kit/token.ts new file mode 100644 index 000000000..d526c4f9e --- /dev/null +++ b/src/lib/integrations/oauth-kit/token.ts @@ -0,0 +1,246 @@ +/** + * The one token lifecycle every OAuth provider integration goes through. + * + * Six providers (Withings, WHOOP, Oura, Strava, Google Health, Polar) used to + * carry their own copy of "read the stored grant, decide whether it needs a + * refresh, refresh it, write it back". The copies drifted: two refreshed under + * the advisory lock, one only compare-and-swapped, one did neither, and the + * Withings status route refreshed with no guard at all. A provider now + * describes where its grant lives and how its token endpoint is called + * (`OAuthTokenDescriptor`), and this module owns the rest: + * + * - A fresh token is returned straight from the row, without a transaction. + * - A refresh happens under the per-provider, per-user advisory lock + * (`acquireProviderTokenRefreshLock`). Under the lock the row is read again: + * a peer that refreshed while this caller waited, or a reconnect that + * stored a new grant, wins, and its token is used instead of refreshing a + * second time. Spending a one-time refresh token twice is what an + * authorization server reads as theft. + * - The write is conditional on the refresh ciphertext the refresh spent, so + * a reconnect that lands while the provider answers (it takes no lock) is + * never overwritten by the older grant. + * - A provider that does not rotate refresh tokens keeps the stored one when + * the response carries none. + * + * Storage is unchanged by design: every descriptor reads and writes the + * columns the provider always used, so no connection has to reconnect. + * + * What stays with the provider: classifying a refused refresh, recording it on + * the integration ledger, and the provider's own reauth flag. The kit throws + * the provider's own error untouched, so those branches keep working. + */ +import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { + acquireProviderTokenRefreshLock, + PROVIDER_REFRESH_TRANSACTION_OPTIONS, +} from "@/lib/integrations/oauth-refresh"; + +/** The OAuth integrations whose tokens the kit manages. */ +export type OAuthKitProvider = + "withings" | "whoop" | "oura" | "strava" | "google-health" | "polar"; + +/** Read and write access the descriptor gets: the client or a transaction. */ +export type OAuthKitDb = Prisma.TransactionClient; + +/** A grant as stored, still sealed. */ +export interface StoredGrant { + /** Ciphertext of the access token. */ + readonly accessCiphertext: string; + /** Ciphertext of the refresh token; null for a provider without one. */ + readonly refreshCiphertext: string | null; + /** When the access token stops working; null when the provider gives none. */ + readonly expiresAt: Date | null; +} + +/** The sealed grant a refresh writes back. */ +export interface NextGrant { + readonly accessCiphertext: string; + readonly refreshCiphertext: string; + readonly expiresAt: Date | null; +} + +/** What a provider's token endpoint answered on a refresh. */ +export interface RefreshedGrant { + readonly access_token: string; + readonly refresh_token?: string | null; + readonly expires_in?: number | null; +} + +export interface OAuthTokenDescriptor { + readonly provider: OAuthKitProvider; + /** + * True when the provider stores an expiry and the token is refreshed ahead + * of it. False for the providers that refresh only after a refusal (Oura, + * Strava) or never (Polar). + */ + readonly tracksExpiry: boolean; + /** How long before the expiry a token counts as stale. */ + readonly expiryBufferMs: number; + /** Read the stored grant, or null when the user has no connection. */ + readGrant(db: OAuthKitDb, userId: string): Promise; + /** + * Write a refreshed grant, only while the stored refresh ciphertext still + * equals `spentRefreshCiphertext`. Returns the number of rows written. + */ + writeGrant( + db: OAuthKitDb, + userId: string, + spentRefreshCiphertext: string, + next: NextGrant, + ): Promise; + /** The OAuth client the refresh authenticates as, or null when none is set. */ + loadCredentials(userId: string): Promise; + /** Call the token endpoint. Null for a provider without refresh tokens. */ + readonly refresh: + | (( + refreshToken: string, + credentials: Credentials, + ) => Promise) + | null; +} + +export type AccessTokenResult = + | { readonly kind: "none" } + | { readonly kind: "no_credentials" } + | { + readonly kind: "token"; + readonly accessToken: string; + readonly expiresAt: Date | null; + /** The refresh ciphertext stored beside the token, for a later refusal. */ + readonly refreshCiphertext: string | null; + /** True when this call refreshed the token itself. */ + readonly refreshed: boolean; + }; + +export interface ObtainAccessTokenOptions { + /** + * The token was just refused. Refresh unless a peer already did: pass the + * refresh ciphertext the refused token was read with. A stored ciphertext + * that differs from it means the grant moved on since, and its token is + * used as it is. + */ + readonly afterRefusalOf?: string; + /** Refresh even when the stored expiry says the token is still good. */ + readonly force?: boolean; + /** Clock override for tests. */ + readonly now?: () => number; +} + +function isFresh( + descriptor: Pick< + OAuthTokenDescriptor, + "tracksExpiry" | "expiryBufferMs" + >, + grant: StoredGrant, + now: number, +): boolean { + if (!descriptor.tracksExpiry || grant.expiresAt === null) return true; + return grant.expiresAt.getTime() - descriptor.expiryBufferMs >= now; +} + +function tokenOf(grant: StoredGrant, refreshed: boolean): AccessTokenResult { + return { + kind: "token", + accessToken: decrypt(grant.accessCiphertext), + expiresAt: grant.expiresAt, + refreshCiphertext: grant.refreshCiphertext, + refreshed, + }; +} + +/** + * A usable access token for the user's connection, refreshing it when it is + * stale, forced, or was just refused. Throws whatever the provider's token + * endpoint threw, for the provider to classify and record. + */ +export async function obtainAccessToken( + descriptor: OAuthTokenDescriptor, + userId: string, + opts: ObtainAccessTokenOptions = {}, +): Promise { + const now = opts.now ?? Date.now; + const observed = await descriptor.readGrant(prisma, userId); + if (!observed) return { kind: "none" }; + + const wantsRefresh = + opts.force === true || + opts.afterRefusalOf !== undefined || + !isFresh(descriptor, observed, now()); + if ( + !wantsRefresh || + descriptor.refresh === null || + observed.refreshCiphertext === null + ) { + return tokenOf(observed, false); + } + + const credentials = await descriptor.loadCredentials(userId); + if (!credentials) return { kind: "no_credentials" }; + + const refresh = descriptor.refresh; + // What the caller's view of the grant was. A refusal names the ciphertext + // its token was read with; otherwise it is what was just read above. + const spentView = opts.afterRefusalOf ?? observed.refreshCiphertext; + + return prisma.$transaction(async (tx) => { + await acquireProviderTokenRefreshLock(tx, descriptor.provider, userId); + const current = await descriptor.readGrant(tx, userId); + if (!current || current.refreshCiphertext === null) { + return { kind: "none" } as const; + } + // The grant moved on while this caller waited for the lock or used its + // token: a peer refreshed it, or a reconnect replaced it. Use that one. + // A non-rotating provider keeps its refresh ciphertext, so its access + // ciphertext tells the same story. + const movedOn = + current.refreshCiphertext !== spentView || + (opts.afterRefusalOf === undefined && + current.accessCiphertext !== observed.accessCiphertext); + if (movedOn && isFresh(descriptor, current, now())) { + return tokenOf(current, false); + } + if ( + !movedOn && + opts.force !== true && + opts.afterRefusalOf === undefined && + isFresh(descriptor, current, now()) + ) { + return tokenOf(current, false); + } + + const rotated = await refresh( + decrypt(current.refreshCiphertext), + credentials, + ); + const expiresAt = descriptor.tracksExpiry + ? new Date(now() + (rotated.expires_in ?? 3600) * 1000) + : null; + const next: NextGrant = { + accessCiphertext: encrypt(rotated.access_token), + refreshCiphertext: rotated.refresh_token + ? encrypt(rotated.refresh_token) + : current.refreshCiphertext, + expiresAt, + }; + const written = await descriptor.writeGrant( + tx, + userId, + current.refreshCiphertext, + next, + ); + if (written > 0) { + return { + kind: "token", + accessToken: rotated.access_token, + expiresAt, + refreshCiphertext: next.refreshCiphertext, + refreshed: true, + } as const; + } + // A reconnect stored a new grant while the provider answered; it wins. + const replaced = await descriptor.readGrant(tx, userId); + return replaced ? tokenOf(replaced, false) : ({ kind: "none" } as const); + }, PROVIDER_REFRESH_TRANSACTION_OPTIONS); +} diff --git a/src/lib/integrations/oauth-refresh.ts b/src/lib/integrations/oauth-refresh.ts index 452caec8f..8acef990a 100644 --- a/src/lib/integrations/oauth-refresh.ts +++ b/src/lib/integrations/oauth-refresh.ts @@ -62,7 +62,14 @@ export async function persistRotatedToken( } export type SerializedRefreshProvider = - "whoop" | "withings" | "oura" | "codex" | "admin-codex"; + | "whoop" + | "withings" + | "oura" + | "strava" + | "google-health" + | "polar" + | "codex" + | "admin-codex"; /** * Provider refreshes hold a database transaction open around one bounded diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 3bc4d1f5d..8ac54fd1a 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -23,6 +23,7 @@ */ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; +import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after"; import type { Prisma } from "@/generated/prisma/client"; import { StravaApiError, classifyStravaResponse } from "./response-classifier"; import { mapStravaSportType } from "./sport-map"; @@ -282,9 +283,12 @@ export function stravaRateLimitRetryAfterSeconds( headers: Headers, now: Date = new Date(), ): number { - const retryAfter = Number(headers.get("retry-after")); - if (Number.isFinite(retryAfter) && retryAfter > 0) { - return Math.ceil(retryAfter); + const retryAfterMs = parseRetryAfterMs( + headers.get("retry-after"), + now.getTime(), + ); + if (retryAfterMs !== undefined && retryAfterMs > 0) { + return Math.ceil(retryAfterMs / 1000); } const pair = (name: string): [number, number] | null => { const parts = (headers.get(name) ?? "").split(",").map(Number); From 2331faa36c3268a2a47695ccd60056ec1b8668ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:33 +0200 Subject: [PATCH 03/98] refactor(oura): refresh tokens through the shared OAuth lifecycle --- src/lib/oura/__tests__/sync.test.ts | 13 ++- src/lib/oura/credentials.ts | 82 +------------------ src/lib/oura/oauth-descriptor.ts | 66 +++++++++++++++ src/lib/oura/sync.ts | 22 +---- .../oura-refresh-serialization.test.ts | 7 +- 5 files changed, 79 insertions(+), 111 deletions(-) create mode 100644 src/lib/oura/oauth-descriptor.ts diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 451084b43..63616a85d 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -70,11 +70,12 @@ const { // these cases pin. vi.mock("../credentials", () => ({ getOuraConnection: getConnMock, - refreshOuraAccessToken: async ( - userId: string, - spentCiphertext: string, - creds: unknown, - ) => { + syncOuraScopeDenied: scopeDeniedMock, +})); +vi.mock("../oauth-descriptor", () => ({ + refreshOuraAfterRefusal: async (userId: string, spentCiphertext: string) => { + const creds = await getCredsMock(userId); + if (!creds) return null; const rotated = await refreshMock("ref", creds); return storeTokensMock( userId, @@ -83,8 +84,6 @@ vi.mock("../credentials", () => ({ spentCiphertext, ); }, - getOuraClientCredentials: getCredsMock, - syncOuraScopeDenied: scopeDeniedMock, })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index 4804cfeb7..95b06898e 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -13,15 +13,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { decrypt, encrypt } from "@/lib/crypto"; -import { - acquireProviderTokenRefreshLock, - PROVIDER_REFRESH_TRANSACTION_OPTIONS, -} from "@/lib/integrations/oauth-refresh"; -import { - getOuraCredentials, - refreshAccessToken, - type OuraCredentials, -} from "./client"; +import { getOuraCredentials, type OuraCredentials } from "./client"; /** * Resolve the user's Oura OAuth client id/secret, DB-first then env. @@ -87,7 +79,7 @@ export interface OuraConnection { accessToken: string; /** * The exact stored refresh-token ciphertext (NOT the decrypted value). - * `refreshOuraAccessToken` compares against it under the refresh lock to + * `refreshOuraAfterRefusal` compares against it under the refresh lock to * tell whether a peer already rotated the token this sync started with. */ refreshTokenCiphertext: string; @@ -116,76 +108,6 @@ export async function getOuraConnection( }; } -/** - * Refresh the user's Oura access token once, however many syncs ask at the - * same moment. - * - * Oura's refresh tokens are single-use, and spending one twice is what an - * authorization server treats as theft: the second request is refused and the - * grant behind it may be revoked, parking a healthy connection at reconnect. - * The refresh is reactive on a 401, so an hourly tick, a manual sync and the - * first sync after a reconnect can all reach it together. A per-user advisory - * lock lets one of them refresh; each later one re-reads the stored refresh - * ciphertext under the lock and, finding it changed from the one its own sync - * started with, takes the access token stored beside it instead of refreshing - * again. The write is still conditional on the ciphertext the refresh spent, - * because a reconnect stores a new grant without taking the lock: a refresh of - * the old grant finishing after it must not put the old grant back. - * - * Returns the access token to retry with, or null when the connection is gone. - * A refused refresh throws for the caller to classify. - */ -export async function refreshOuraAccessToken( - userId: string, - spentRefreshCiphertext: string, - creds: OuraCredentials, -): Promise { - return prisma.$transaction(async (tx) => { - await acquireProviderTokenRefreshLock(tx, "oura", userId); - const current = await tx.user.findUnique({ - where: { id: userId }, - select: { - ouraAccessTokenEncrypted: true, - ouraRefreshTokenEncrypted: true, - }, - }); - if ( - !current?.ouraAccessTokenEncrypted || - !current.ouraRefreshTokenEncrypted - ) { - return null; - } - if (current.ouraRefreshTokenEncrypted !== spentRefreshCiphertext) { - // Rotated by a peer, or replaced by a reconnect, while this sync ran. - return decrypt(current.ouraAccessTokenEncrypted); - } - - const rotated = await refreshAccessToken( - decrypt(current.ouraRefreshTokenEncrypted), - creds, - ); - const { count } = await tx.user.updateMany({ - where: { - id: userId, - ouraRefreshTokenEncrypted: spentRefreshCiphertext, - }, - data: { - ouraAccessTokenEncrypted: encrypt(rotated.access_token), - ouraRefreshTokenEncrypted: encrypt(rotated.refresh_token), - }, - }); - if (count > 0) return rotated.access_token; - // A reconnect stored a new grant while Oura answered; that one wins. - const fresh = await tx.user.findUnique({ - where: { id: userId }, - select: { ouraAccessTokenEncrypted: true }, - }); - return fresh?.ouraAccessTokenEncrypted - ? decrypt(fresh.ouraAccessTokenEncrypted) - : null; - }, PROVIDER_REFRESH_TRANSACTION_OPTIONS); -} - /** * Collections the current Oura grant does not cover, by collection name. Read * by the settings envelope; an absent or malformed value reads as none. diff --git a/src/lib/oura/oauth-descriptor.ts b/src/lib/oura/oauth-descriptor.ts new file mode 100644 index 000000000..cfc43a51b --- /dev/null +++ b/src/lib/oura/oauth-descriptor.ts @@ -0,0 +1,66 @@ +/** + * Where an Oura grant lives and how its token endpoint is called, for the + * shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`). + * + * Oura's tokens sit on `User`, with no expiry column: the sync refreshes after + * a refused read, and Oura rotates the refresh token on every use. + */ +import { + obtainAccessToken, + type OAuthTokenDescriptor, +} from "@/lib/integrations/oauth-kit/token"; +import { refreshAccessToken, type OuraCredentials } from "./client"; +import { getOuraClientCredentials } from "./credentials"; + +export const ouraTokenDescriptor: OAuthTokenDescriptor = { + provider: "oura", + tracksExpiry: false, + expiryBufferMs: 0, + async readGrant(db, userId) { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { + ouraAccessTokenEncrypted: true, + ouraRefreshTokenEncrypted: true, + }, + }); + if (!row?.ouraAccessTokenEncrypted || !row.ouraRefreshTokenEncrypted) { + return null; + } + return { + accessCiphertext: row.ouraAccessTokenEncrypted, + refreshCiphertext: row.ouraRefreshTokenEncrypted, + expiresAt: null, + }; + }, + async writeGrant(db, userId, spent, next) { + const { count } = await db.user.updateMany({ + where: { id: userId, ouraRefreshTokenEncrypted: spent }, + data: { + ouraAccessTokenEncrypted: next.accessCiphertext, + ouraRefreshTokenEncrypted: next.refreshCiphertext, + }, + }); + return count; + }, + loadCredentials: (userId) => getOuraClientCredentials(userId), + refresh: (refreshToken, credentials) => + refreshAccessToken(refreshToken, credentials), +}; + +/** + * Refresh after Oura refused the token read with `spentRefreshCiphertext`, + * once however many syncs ask at the same moment. Returns the access token to + * retry with, or null when there is nothing to retry with (no connection, or + * no OAuth client to refresh as). A refused refresh throws for the caller to + * classify. + */ +export async function refreshOuraAfterRefusal( + userId: string, + spentRefreshCiphertext: string, +): Promise { + const result = await obtainAccessToken(ouraTokenDescriptor, userId, { + afterRefusalOf: spentRefreshCiphertext, + }); + return result.kind === "token" ? result.accessToken : null; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index b6e6e035c..39aee9733 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -86,12 +86,8 @@ import { mapVo2Max, type MappedMeasurement, } from "./client"; -import { - getOuraClientCredentials, - getOuraConnection, - refreshOuraAccessToken, - syncOuraScopeDenied, -} from "./credentials"; +import { getOuraConnection, syncOuraScopeDenied } from "./credentials"; +import { refreshOuraAfterRefusal } from "./oauth-descriptor"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; import { isOuraScopeGatedCollection } from "./scope"; @@ -524,21 +520,9 @@ async function fetchAll( // same one-time refresh token twice, and retry the collections that failed // for any reason but a named scope. A failed refresh throws so the caller // parks reauth_required. - const creds = await getOuraClientCredentials(userId); - if (!creds) - return { - readings: first.readings, - // The refresh never happened, so no sibling's success can be trusted - // over it; a refusal that names its scope still says what it says. - ...splitScopeDenied(first.failures, false), - sleepSweeps, - sleepRetirements, - }; - - const usableToken = await refreshOuraAccessToken( + const usableToken = await refreshOuraAfterRefusal( userId, refreshTokenCiphertext, - creds, ); if (!usableToken) return { diff --git a/tests/integration/oura-refresh-serialization.test.ts b/tests/integration/oura-refresh-serialization.test.ts index 488ae9a1b..d42f145fe 100644 --- a/tests/integration/oura-refresh-serialization.test.ts +++ b/tests/integration/oura-refresh-serialization.test.ts @@ -41,7 +41,7 @@ vi.mock("@/lib/oura/client", async (importOriginal) => { import { decrypt, encrypt } from "@/lib/crypto"; import { OuraApiError } from "@/lib/oura/response-classifier"; -import { refreshOuraAccessToken } from "@/lib/oura/credentials"; +import { refreshOuraAfterRefusal } from "@/lib/oura/oauth-descriptor"; import { syncUserOura } from "@/lib/oura/sync"; const USER = "user-oura-refresh"; @@ -142,10 +142,7 @@ describe("Oura token refresh (real Postgres)", () => { }; }); - const token = await refreshOuraAccessToken(USER, spent, { - clientId: "oura-client", - clientSecret: "oura-secret", - }); + const token = await refreshOuraAfterRefusal(USER, spent); expect(token).toBe("reconnect-access"); expect(await storedTokens()).toEqual({ From 0592dfc0746c4c302b64a62626d56167781a92ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:34 +0200 Subject: [PATCH 04/98] refactor(whoop): refresh tokens through the shared OAuth lifecycle The rotated pair is now written only over the refresh token it spent, so a reconnect that lands during a refresh keeps its new grant. --- .../whoop/__tests__/sync-dead-token.test.ts | 4 + src/lib/whoop/__tests__/sync.test.ts | 19 ++- src/lib/whoop/oauth-descriptor.ts | 51 +++++++ src/lib/whoop/sync-core.ts | 129 +++++------------- 4 files changed, 102 insertions(+), 101 deletions(-) create mode 100644 src/lib/whoop/oauth-descriptor.ts diff --git a/src/lib/whoop/__tests__/sync-dead-token.test.ts b/src/lib/whoop/__tests__/sync-dead-token.test.ts index c2e9f4c52..a5d51027e 100644 --- a/src/lib/whoop/__tests__/sync-dead-token.test.ts +++ b/src/lib/whoop/__tests__/sync-dead-token.test.ts @@ -32,6 +32,7 @@ const { whoopConnection: { findUnique: vi.fn(), update: vi.fn(async () => ({})), + updateMany: vi.fn(async () => ({ count: 1 })), }, $transaction: vi.fn(), }, @@ -131,6 +132,9 @@ beforeEach(() => { isReauthRequiredMock.mockResolvedValue(false); prismaMock.whoopConnection.findUnique.mockReset(); prismaMock.whoopConnection.update.mockReset().mockResolvedValue({}); + prismaMock.whoopConnection.updateMany + .mockReset() + .mockResolvedValue({ count: 1 }); getUserWhoopCredentialsMock.mockReset().mockResolvedValue(null); refreshAccessTokenMock.mockReset(); // The advisory-lock transaction runs its callback against the same mock. diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index 969bc0ee3..85462399e 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -169,7 +169,7 @@ describe("getValidToken — rotating refresh", () => { refresh_token: "new-refresh", expires_in: 3600, }); - prismaMock.whoopConnection.update.mockResolvedValue({}); + prismaMock.whoopConnection.updateMany.mockResolvedValue({ count: 1 }); const result = await getValidToken("user1"); @@ -178,15 +178,28 @@ describe("getValidToken — rotating refresh", () => { clientId: "cid", clientSecret: "csecret", }); - const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0]; - expect(updateArg.where.id).toBe("conn1"); + // Written only while the stored refresh token is still the one spent. + const updateArg = prismaMock.whoopConnection.updateMany.mock.calls[0]![0]; + expect(updateArg.where).toEqual({ + userId: "user1", + refreshToken: "enc(old-refresh)", + }); expect(updateArg.data.accessToken).toBe("enc(new-access)"); expect(updateArg.data.refreshToken).toBe("enc(new-refresh)"); expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1); }); it("re-reads token state under an advisory lock and reuses a peer winner", async () => { + const stale = { + id: "conn1", + whoopUserId: "42", + accessToken: "enc(old-access)", + refreshToken: "enc(old-refresh)", + tokenExpiresAt: new Date(Date.now() - 1000), + }; prismaMock.whoopConnection.findUnique + // The connection lookup, then the token read before the lock. + .mockResolvedValueOnce(stale) .mockResolvedValueOnce({ id: "conn1", whoopUserId: "42", diff --git a/src/lib/whoop/oauth-descriptor.ts b/src/lib/whoop/oauth-descriptor.ts new file mode 100644 index 000000000..8a86ac2a4 --- /dev/null +++ b/src/lib/whoop/oauth-descriptor.ts @@ -0,0 +1,51 @@ +/** + * Where a WHOOP grant lives and how its token endpoint is called, for the + * shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`). + * + * WHOOP keeps an expiry on `WhoopConnection`, so the token is refreshed ahead + * of it, and rotates the refresh token on every use. A connection without a + * WHOOP user id is a connect still in flight and has no usable grant yet. + */ +import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token"; +import { refreshAccessToken, type WhoopCredentials } from "./client"; +import { getUserWhoopCredentials } from "./credentials"; + +/** Refresh this long before the stored expiry. */ +export const WHOOP_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; + +export const whoopTokenDescriptor: OAuthTokenDescriptor = { + provider: "whoop", + tracksExpiry: true, + expiryBufferMs: WHOOP_TOKEN_REFRESH_BUFFER_MS, + async readGrant(db, userId) { + const row = await db.whoopConnection.findUnique({ + where: { userId }, + select: { + whoopUserId: true, + accessToken: true, + refreshToken: true, + tokenExpiresAt: true, + }, + }); + if (!row?.whoopUserId) return null; + return { + accessCiphertext: row.accessToken, + refreshCiphertext: row.refreshToken, + expiresAt: row.tokenExpiresAt, + }; + }, + async writeGrant(db, userId, spent, next) { + const { count } = await db.whoopConnection.updateMany({ + where: { userId, refreshToken: spent }, + data: { + accessToken: next.accessCiphertext, + refreshToken: next.refreshCiphertext, + ...(next.expiresAt ? { tokenExpiresAt: next.expiresAt } : {}), + }, + }); + return count; + }, + loadCredentials: (userId) => getUserWhoopCredentials(userId), + refresh: (refreshToken, credentials) => + refreshAccessToken(refreshToken, credentials), +}; diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts index 4ba22baeb..48f88adcb 100644 --- a/src/lib/whoop/sync-core.ts +++ b/src/lib/whoop/sync-core.ts @@ -3,12 +3,11 @@ * Measurement upsert/rollup-fold helpers, cursors, and sync status handling. * * Mirrors `src/lib/withings/sync.ts`: - * - `getValidToken` decrypts the stored pair, refreshes at - * `tokenExpiresAt - 5 min`, and persists BOTH rotated tokens (WHOOP - * invalidates the prior access AND refresh token on every refresh — the - * same discipline Withings uses for its rotating refresh token). A - * provider/user advisory lock serializes refresh, and token state is - * re-read inside the transaction before the one-time token is spent. + * - `getValidToken` resolves the token through the shared OAuth token + * lifecycle (`src/lib/integrations/oauth-kit/token.ts`, descriptor in + * `./oauth-descriptor`): refreshed at `tokenExpiresAt - 5 min` under the + * provider/user advisory lock, BOTH rotated tokens persisted (WHOOP + * invalidates the prior access AND refresh token on every refresh). * - Each per-resource sync (`sync-recovery` / `sync-sleep` / `sync-cycle` / * `sync-workout`) upserts into `Measurement` / `Workout` keyed on * `(userId, type, source = WHOOP, externalId)` so a re-post (WHOOP @@ -27,7 +26,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; -import { encrypt, decrypt } from "@/lib/crypto"; import { getEvent } from "@/lib/logging/context"; import { markSyncFailureRecorded, @@ -44,16 +42,12 @@ import { emitInsertedMeasurementArrivals, type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; -import { - acquireProviderTokenRefreshLock, - PROVIDER_REFRESH_TRANSACTION_OPTIONS, -} from "@/lib/integrations/oauth-refresh"; +import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; import { MeasurementReconciliationError, reconcileExternalMeasurement, } from "@/lib/measurements/reconcile-external-measurement"; -import { refreshAccessToken } from "./client"; -import { getUserWhoopCredentials } from "./credentials"; +import { whoopTokenDescriptor } from "./oauth-descriptor"; import { WhoopApiError, classifyWhoopError, @@ -164,9 +158,6 @@ export async function handleCollectionFetchError( throw markSyncFailureRecorded(err); } -/** Refresh the access token this many ms before `tokenExpiresAt`. */ -const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; - /** * Overlap window for the incremental sync, in ms. WHOOP re-scores recovery * and sleep after the fact, and a night can reach the WHOOP cloud DAYS late @@ -259,94 +250,36 @@ export async function getValidToken( ): Promise { const connection = await prisma.whoopConnection.findUnique({ where: { userId }, + select: { id: true, whoopUserId: true }, }); if (!connection?.whoopUserId) return null; - - if ( - connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS >= - Date.now() - ) { - return { - accessToken: decrypt(connection.accessToken), - connection: { - id: connection.id, - whoopUserId: connection.whoopUserId, - }, - }; - } - - const creds = await getUserWhoopCredentials(userId); - if (!creds) { - getEvent()?.addWarning( - `No WHOOP credentials found for user ${userId} during token refresh`, - ); - await recordSyncFailure({ - userId, - integration: "whoop", - kind: "reauth_required", - message: "WHOOP credentials missing — token refresh skipped", - errorCode: "credentials_missing", - }); - // A dead token means every resource "succeeds" with 0 rows — without a - // ledger entry the cycle reads as clean and `recordSyncSuccess` un-parks - // `error_reauth` while the hourly cohort hammers the dead refresh token. - // Fail the cycle's verdict so the park holds. - noteHardFailure(WHOOP_TOKEN_HARD_FAIL); - return null; - } + const meta = { id: connection.id, whoopUserId: connection.whoopUserId }; try { - return await prisma.$transaction(async (tx) => { - await acquireProviderTokenRefreshLock(tx, "whoop", userId); - - const current = await tx.whoopConnection.findUnique({ - where: { userId }, - }); - if (!current?.whoopUserId) { - // The connection vanished on the re-read inside the advisory lock - // (disconnect/webhook race). Register on the ledger so the cycle does - // not stamp success over a connection that no longer exists — the same - // invariant as the refresh branch below. - noteHardFailure(WHOOP_TOKEN_HARD_FAIL); - return null; - } - - if ( - current.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS >= - Date.now() - ) { - return { - accessToken: decrypt(current.accessToken), - connection: { - id: current.id, - whoopUserId: current.whoopUserId, - }, - }; - } - - const newTokens = await refreshAccessToken( - decrypt(current.refreshToken), - creds, + const result = await obtainAccessToken(whoopTokenDescriptor, userId); + if (result.kind === "token") { + return { accessToken: result.accessToken, connection: meta }; + } + if (result.kind === "no_credentials") { + getEvent()?.addWarning( + `No WHOOP credentials found for user ${userId} during token refresh`, ); - const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); - - await tx.whoopConnection.update({ - where: { id: current.id }, - data: { - accessToken: encrypt(newTokens.access_token), - refreshToken: encrypt(newTokens.refresh_token), - tokenExpiresAt: expiresAt, - }, + await recordSyncFailure({ + userId, + integration: "whoop", + kind: "reauth_required", + message: "WHOOP credentials missing — token refresh skipped", + errorCode: "credentials_missing", }); - - return { - accessToken: newTokens.access_token, - connection: { - id: current.id, - whoopUserId: current.whoopUserId, - }, - }; - }, PROVIDER_REFRESH_TRANSACTION_OPTIONS); + } + // No credentials, or the connection vanished between the read above and + // the token read (disconnect/webhook race). A dead token means every + // resource "succeeds" with 0 rows — without a ledger entry the cycle reads + // as clean and `recordSyncSuccess` un-parks `error_reauth` while the hourly + // cohort hammers the dead refresh token. Fail the cycle's verdict so the + // park holds. + noteHardFailure(WHOOP_TOKEN_HARD_FAIL); + return null; } catch (err) { getEvent()?.addWarning( `WHOOP token refresh failed for user ${userId}: ${err}`, From 6319a1be328514f8292f13137647940689f0e411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:34 +0200 Subject: [PATCH 05/98] fix(withings): refresh tokens through the shared OAuth lifecycle The status route refreshed an expiring token on its own, without the lock the sync takes, so a status read and a sync could spend the same one-time refresh token and park a healthy connection at reconnect. Both now go through the kit. --- src/app/api/withings/status/route.ts | 30 ++---- .../__tests__/sync-measure-watermark.test.ts | 17 ++-- src/lib/withings/oauth-descriptor.ts | 46 +++++++++ src/lib/withings/sync.ts | 94 +++++-------------- 4 files changed, 87 insertions(+), 100 deletions(-) create mode 100644 src/lib/withings/oauth-descriptor.ts diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 79fe1c729..3af9d631f 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -3,9 +3,9 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; -import { decrypt, encrypt } from "@/lib/crypto"; -import { getUserWithingsCredentials } from "@/lib/withings/credentials"; -import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client"; +import { hasActivityScope } from "@/lib/withings/client"; +import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; +import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor"; import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync"; import { markReauthRequired } from "@/lib/integrations/status"; @@ -33,8 +33,6 @@ export const GET = apiHandler(async () => { where: { userId: user.id }, select: { withingsUserId: true, - accessToken: true, - refreshToken: true, lastSyncedAt: true, tokenExpiresAt: true, createdAt: true, @@ -56,22 +54,14 @@ export const GET = apiHandler(async () => { const shouldRefresh = tokenExpiresAt.getTime() - 60_000 <= now; if (shouldRefresh) { try { - const creds = await getUserWithingsCredentials(user.id); - if (creds) { - const refreshToken = decrypt(connection.refreshToken); - const refreshed = await refreshAccessToken(refreshToken, creds); - tokenExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000); + // The shared token lifecycle refreshes under the same lock the sync + // takes, so a status read and a sync never spend the one-time refresh + // token twice. + const result = await obtainAccessToken(withingsTokenDescriptor, user.id); + if (result.kind === "token" && result.expiresAt) { + tokenExpiresAt = result.expiresAt; tokenExpired = tokenExpiresAt.getTime() <= Date.now(); - - await prisma.withingsConnection.update({ - where: { userId: user.id }, - data: { - accessToken: encrypt(refreshed.access_token), - refreshToken: encrypt(refreshed.refresh_token), - tokenExpiresAt, - }, - }); - } else if (tokenExpired) { + } else if (result.kind !== "token" && tokenExpired) { tokenRefreshFailed = true; } } catch (error) { diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index a242e89c0..4408c931d 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -126,14 +126,17 @@ beforeEach(() => { describe("getValidToken — serialized rotating refresh", () => { it("re-reads token state under an advisory lock and reuses a peer winner", async () => { + const stale = { + id: "conn-1", + withingsUserId: "wu-1", + accessToken: "old-access", + refreshToken: "old-refresh", + tokenExpiresAt: new Date(Date.now() - 1000), + }; vi.mocked(prisma.withingsConnection.findUnique) - .mockResolvedValueOnce({ - id: "conn-1", - withingsUserId: "wu-1", - accessToken: "old-access", - refreshToken: "old-refresh", - tokenExpiresAt: new Date(Date.now() - 1000), - } as never) + // The connection lookup, then the token read before the lock. + .mockResolvedValueOnce(stale as never) + .mockResolvedValueOnce(stale as never) .mockResolvedValueOnce({ id: "conn-1", withingsUserId: "wu-1", diff --git a/src/lib/withings/oauth-descriptor.ts b/src/lib/withings/oauth-descriptor.ts new file mode 100644 index 000000000..fa99d0f5b --- /dev/null +++ b/src/lib/withings/oauth-descriptor.ts @@ -0,0 +1,46 @@ +/** + * Where a Withings grant lives and how its token endpoint is called, for the + * shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`). + * + * Withings keeps an expiry on `WithingsConnection`, so the token is refreshed + * ahead of it, and rotates the refresh token on every use. + */ +import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token"; +import { refreshAccessToken, type WithingsCredentials } from "./client"; +import { getUserWithingsCredentials } from "./credentials"; + +/** Refresh this long before the stored expiry. */ +export const WITHINGS_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; + +export const withingsTokenDescriptor: OAuthTokenDescriptor = + { + provider: "withings", + tracksExpiry: true, + expiryBufferMs: WITHINGS_TOKEN_REFRESH_BUFFER_MS, + async readGrant(db, userId) { + const row = await db.withingsConnection.findUnique({ + where: { userId }, + select: { accessToken: true, refreshToken: true, tokenExpiresAt: true }, + }); + if (!row) return null; + return { + accessCiphertext: row.accessToken, + refreshCiphertext: row.refreshToken, + expiresAt: row.tokenExpiresAt, + }; + }, + async writeGrant(db, userId, spent, next) { + const { count } = await db.withingsConnection.updateMany({ + where: { userId, refreshToken: spent }, + data: { + accessToken: next.accessCiphertext, + refreshToken: next.refreshCiphertext, + ...(next.expiresAt ? { tokenExpiresAt: next.expiresAt } : {}), + }, + }); + return count; + }, + loadCredentials: (userId) => getUserWithingsCredentials(userId), + refresh: (refreshToken, credentials) => + refreshAccessToken(refreshToken, credentials), + }; diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 6bf90b248..86d780388 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -4,11 +4,9 @@ */ import { prisma } from "@/lib/db"; import type { MeasurementType, Prisma } from "@/generated/prisma/client"; -import { encrypt, decrypt } from "@/lib/crypto"; import { getUnitForType } from "@/lib/validations/measurement"; import { fetchMeasurements, - refreshAccessToken, subscribeWebhook, WITHINGS_MEASTYPES, type WithingsMeasure, @@ -27,7 +25,6 @@ import { WITHINGS_PLAN_GATED_STATUS, type WithinPlanResult, } from "./api-plan"; -import { getUserWithingsCredentials } from "./credentials"; import { annotate, getEvent } from "@/lib/logging/context"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; @@ -52,10 +49,8 @@ import { invalidateUserMeasurements, } from "@/lib/cache/invalidate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; -import { - acquireProviderTokenRefreshLock, - PROVIDER_REFRESH_TRANSACTION_OPTIONS, -} from "@/lib/integrations/oauth-refresh"; +import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; +import { withingsTokenDescriptor } from "./oauth-descriptor"; /** * Build the callback URL handed to Withings at `Notify.subscribe` time. @@ -95,7 +90,9 @@ export interface GetValidWithingsTokenOptions { } /** - * Get valid access token for a user, refreshing if expired. + * Get a valid access token for a user, refreshing it through the shared OAuth + * token lifecycle (`src/lib/integrations/oauth-kit/token.ts`) when it is + * within five minutes of its expiry. */ export async function getValidToken( userId: string, @@ -103,77 +100,28 @@ export async function getValidToken( ): Promise { const connection = await prisma.withingsConnection.findUnique({ where: { userId }, + select: { id: true, withingsUserId: true }, }); - if (!connection) return null; - if (connection.tokenExpiresAt.getTime() - 5 * 60 * 1000 >= Date.now()) { - return { - accessToken: decrypt(connection.accessToken), - connection: { - id: connection.id, - withingsUserId: connection.withingsUserId, - }, - }; - } - - const creds = await getUserWithingsCredentials(userId); - if (!creds) { - getEvent()?.addWarning( - `No credentials found for user ${userId} during token refresh`, - ); - await recordSyncFailure({ - userId, - integration: "withings", - kind: "reauth_required", - message: "Withings credentials missing — token refresh skipped", - errorCode: "credentials_missing", - }); - return null; - } - try { - return await prisma.$transaction(async (tx) => { - await acquireProviderTokenRefreshLock(tx, "withings", userId); - - const current = await tx.withingsConnection.findUnique({ - where: { userId }, - }); - if (!current) return null; - - if (current.tokenExpiresAt.getTime() - 5 * 60 * 1000 >= Date.now()) { - return { - accessToken: decrypt(current.accessToken), - connection: { - id: current.id, - withingsUserId: current.withingsUserId, - }, - }; - } - - const newTokens = await refreshAccessToken( - decrypt(current.refreshToken), - creds, + const result = await obtainAccessToken(withingsTokenDescriptor, userId); + if (result.kind === "token") { + return { accessToken: result.accessToken, connection }; + } + if (result.kind === "no_credentials") { + getEvent()?.addWarning( + `No credentials found for user ${userId} during token refresh`, ); - const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); - - await tx.withingsConnection.update({ - where: { id: current.id }, - data: { - accessToken: encrypt(newTokens.access_token), - refreshToken: encrypt(newTokens.refresh_token), - tokenExpiresAt: expiresAt, - }, + await recordSyncFailure({ + userId, + integration: "withings", + kind: "reauth_required", + message: "Withings credentials missing — token refresh skipped", + errorCode: "credentials_missing", }); - - return { - accessToken: newTokens.access_token, - connection: { - id: current.id, - withingsUserId: current.withingsUserId, - }, - }; - }, PROVIDER_REFRESH_TRANSACTION_OPTIONS); + } + return null; } catch (err) { getEvent()?.addWarning(`Token refresh failed for user ${userId}`); await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES); From b710cb732bcf98c485689e1b32069ddfcb6f6df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:35 +0200 Subject: [PATCH 06/98] fix(google-health): refresh tokens under the shared refresh lock Two walks that both met an expired token refreshed side by side; the kit lets the second take the first one's token. --- .../__tests__/sync-failsoft.test.ts | 5 + src/lib/google-health/oauth-descriptor.ts | 47 +++++ src/lib/google-health/sync-core.ts | 164 +++++++----------- 3 files changed, 118 insertions(+), 98 deletions(-) create mode 100644 src/lib/google-health/oauth-descriptor.ts diff --git a/src/lib/google-health/__tests__/sync-failsoft.test.ts b/src/lib/google-health/__tests__/sync-failsoft.test.ts index 1e587d398..64c09bee9 100644 --- a/src/lib/google-health/__tests__/sync-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-failsoft.test.ts @@ -34,6 +34,11 @@ const { update: vi.fn(async () => ({})), updateMany: vi.fn(async () => ({ count: 1 })), }, + // The token refresh runs in a transaction under an advisory lock. + $queryRaw: vi.fn(async () => []), + $transaction: vi.fn(async (run: (tx: unknown) => unknown) => + run(prismaMock), + ), }, getUserGoogleHealthCredentialsMock: vi.fn(async (): Promise => null), refreshAccessTokenMock: vi.fn(), diff --git a/src/lib/google-health/oauth-descriptor.ts b/src/lib/google-health/oauth-descriptor.ts new file mode 100644 index 000000000..48d210e0e --- /dev/null +++ b/src/lib/google-health/oauth-descriptor.ts @@ -0,0 +1,47 @@ +/** + * Where a Google Health grant lives and how its token endpoint is called, for + * the shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`). + * + * Google keeps an expiry on `GoogleHealthConnection`, so the token is refreshed + * ahead of it. Google does not rotate refresh tokens: a refresh usually comes + * back without one, and the kit then keeps the stored refresh token. + */ +import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token"; +import { refreshAccessToken, type GoogleHealthCredentials } from "./client"; +import { getUserGoogleHealthCredentials } from "./credentials"; + +/** Refresh this long before the stored expiry. */ +export const GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; + +export const googleHealthTokenDescriptor: OAuthTokenDescriptor = + { + provider: "google-health", + tracksExpiry: true, + expiryBufferMs: GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS, + async readGrant(db, userId) { + const row = await db.googleHealthConnection.findUnique({ + where: { userId }, + select: { accessToken: true, refreshToken: true, tokenExpiresAt: true }, + }); + if (!row) return null; + return { + accessCiphertext: row.accessToken, + refreshCiphertext: row.refreshToken, + expiresAt: row.tokenExpiresAt, + }; + }, + async writeGrant(db, userId, spent, next) { + const { count } = await db.googleHealthConnection.updateMany({ + where: { userId, refreshToken: spent }, + data: { + accessToken: next.accessCiphertext, + refreshToken: next.refreshCiphertext, + ...(next.expiresAt ? { tokenExpiresAt: next.expiresAt } : {}), + }, + }); + return count; + }, + loadCredentials: (userId) => getUserGoogleHealthCredentials(userId), + refresh: (refreshToken, credentials) => + refreshAccessToken(refreshToken, credentials), + }; diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 4741df368..282a3a112 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -18,7 +18,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; -import { encrypt, decrypt } from "@/lib/crypto"; import { annotate, getEvent } from "@/lib/logging/context"; import type { GoogleHealthCollectionKey } from "./collection-keys"; import { @@ -52,11 +51,14 @@ import { noteGoogleHealthMapped, noteGoogleHealthOutcomeFailure, noteGoogleHealthWritten, - refreshAccessToken, type GoogleHealthResumePoint, type GoogleHealthTokenSource, } from "./client"; -import { getUserGoogleHealthCredentials } from "./credentials"; +import { + GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS, + googleHealthTokenDescriptor, +} from "./oauth-descriptor"; +import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; import { GoogleHealthApiError, classifyGoogleHealthError, @@ -70,9 +72,6 @@ import { */ export const GOOGLE_HEALTH_INTEGRATION_KEY = "google-health"; -/** Refresh the access token this many ms before `tokenExpiresAt`. */ -const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; - /** * Overlap window for the incremental sync, in ms. Google re-rolls daily * summaries after the fact (a daily SpO2 / HRV / RHR can finalise hours after @@ -142,104 +141,72 @@ export async function getValidToken( ): Promise { const connection = await prisma.googleHealthConnection.findUnique({ where: { userId }, + select: { id: true, googleUserId: true }, }); if (!connection) return null; - const accessToken = decrypt(connection.accessToken); - const refreshToken = decrypt(connection.refreshToken); - - if ( - opts.forceRefresh === true || - connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS < Date.now() - ) { - try { - const creds = await getUserGoogleHealthCredentials(userId); - if (!creds) { - getEvent()?.addWarning( - `No Google Health credentials found for user ${userId} during token refresh`, - ); - await recordSyncFailure({ - userId, - integration: GOOGLE_HEALTH_INTEGRATION_KEY, - kind: "reauth_required", - message: "Google Health credentials missing — token refresh skipped", - errorCode: "credentials_missing", - }); - // A dead token means every resource "succeeds" with 0 rows — without a - // ledger entry the cycle would read as clean, stamp `markSynced`, and - // `recordSyncSuccess` would flip a parked connection back to connected - // (an hourly invalid-refresh hammer with an advancing watermark). Fail - // the cycle's verdict so the park holds. - noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL); - return null; - } - - const newTokens = await refreshAccessToken(refreshToken, creds); - const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); - - // Google does NOT rotate refresh tokens: a routine refresh returns a new - // access token but usually omits `refresh_token`. Persist the new access - // token + expiry and only overwrite the stored refresh token when the - // response actually carries a fresh one — otherwise keep the existing one. - await prisma.googleHealthConnection.update({ - where: { id: connection.id }, - data: { - accessToken: encrypt(newTokens.access_token), - tokenExpiresAt: expiresAt, - ...(newTokens.refresh_token - ? { refreshToken: encrypt(newTokens.refresh_token) } - : {}), - }, - }); - + try { + const result = await obtainAccessToken( + googleHealthTokenDescriptor, + userId, + { force: opts.forceRefresh === true }, + ); + if (result.kind === "token") { return { - accessToken: newTokens.access_token, - expiresAt, - connection: { - id: connection.id, - googleUserId: connection.googleUserId, - }, + accessToken: result.accessToken, + // Google always stores an expiry; the fallback only satisfies the type. + expiresAt: result.expiresAt ?? new Date(), + connection, }; - } catch (err) { - getEvent()?.addWarning( - `Google Health token refresh failed for user ${userId}: ${err}`, - ); - // A revoked / expired refresh token (`invalid_grant` / 401 — the 7-day - // Testing-mode refresh expiry, or a user-revoked grant) is a SOFT - // disconnect: flag `needsReauth` on the connection so the card surfaces a - // "reconnect" CTA rather than a hard error. The callback clears it on a - // fresh consent. A transient refresh failure (network blip, 5xx) leaves - // the flag untouched so we don't nag the user to reconnect needlessly. - if (isGoogleHealthReauthRequired(err)) { - await prisma.googleHealthConnection - .update({ - where: { id: connection.id }, - data: { needsReauth: true }, - }) - .catch((e) => - getEvent()?.addWarning( - `google-health: needsReauth flag write failed for ${userId}: ${e}`, - ), - ); - } - await recordGoogleHealthSyncFailure(userId, err); - // Same verdict rule as the credentials-missing branch above: a refresh - // failure must fail the cycle, or the all-zeros run stamps success, - // un-parks `error_reauth`, and the hourly cohort retries the dead - // refresh token forever while `lastSyncedAt` advances past real data. - noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL); - return null; } + if (result.kind === "none") return null; + getEvent()?.addWarning( + `No Google Health credentials found for user ${userId} during token refresh`, + ); + await recordSyncFailure({ + userId, + integration: GOOGLE_HEALTH_INTEGRATION_KEY, + kind: "reauth_required", + message: "Google Health credentials missing — token refresh skipped", + errorCode: "credentials_missing", + }); + // A dead token means every resource "succeeds" with 0 rows — without a + // ledger entry the cycle would read as clean, stamp `markSynced`, and + // `recordSyncSuccess` would flip a parked connection back to connected + // (an hourly invalid-refresh hammer with an advancing watermark). Fail + // the cycle's verdict so the park holds. + noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL); + return null; + } catch (err) { + getEvent()?.addWarning( + `Google Health token refresh failed for user ${userId}: ${err}`, + ); + // A revoked / expired refresh token (`invalid_grant` / 401 — the 7-day + // Testing-mode refresh expiry, or a user-revoked grant) is a SOFT + // disconnect: flag `needsReauth` on the connection so the card surfaces a + // "reconnect" CTA rather than a hard error. The callback clears it on a + // fresh consent. A transient refresh failure (network blip, 5xx) leaves + // the flag untouched so we don't nag the user to reconnect needlessly. + if (isGoogleHealthReauthRequired(err)) { + await prisma.googleHealthConnection + .update({ + where: { id: connection.id }, + data: { needsReauth: true }, + }) + .catch((e) => + getEvent()?.addWarning( + `google-health: needsReauth flag write failed for ${userId}: ${e}`, + ), + ); + } + await recordGoogleHealthSyncFailure(userId, err); + // Same verdict rule as the credentials-missing branch above: a refresh + // failure must fail the cycle, or the all-zeros run stamps success, + // un-parks `error_reauth`, and the hourly cohort retries the dead + // refresh token forever while `lastSyncedAt` advances past real data. + noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL); + return null; } - - return { - accessToken, - expiresAt: connection.tokenExpiresAt, - connection: { - id: connection.id, - googleUserId: connection.googleUserId, - }, - }; } /** @@ -277,7 +244,8 @@ export function googleHealthTokenSource( return async ({ forceRefresh }) => { if ( forceRefresh || - current.expiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS < Date.now() + current.expiresAt.getTime() - GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS < + Date.now() ) { const next = await getValidToken(userId, { forceRefresh }); if (!next) throw new GoogleHealthTokenUnavailableError(); From a12174bcaa63a388504dcfa06395be87086568bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:35 +0200 Subject: [PATCH 07/98] fix(strava): serialise the token refresh like the other providers Strava rotates its refresh token on every use. The sync relied on a compare-and-swap alone, so two overlapping runs could both spend the same token; the refresh now runs under the per-user lock. --- src/lib/strava/__tests__/credentials.test.ts | 57 +---------------- src/lib/strava/credentials.ts | 48 +-------------- src/lib/strava/oauth-descriptor.ts | 64 ++++++++++++++++++++ src/lib/strava/sync.ts | 15 +---- 4 files changed, 70 insertions(+), 114 deletions(-) create mode 100644 src/lib/strava/oauth-descriptor.ts diff --git a/src/lib/strava/__tests__/credentials.test.ts b/src/lib/strava/__tests__/credentials.test.ts index 138e8a187..708f56d22 100644 --- a/src/lib/strava/__tests__/credentials.test.ts +++ b/src/lib/strava/__tests__/credentials.test.ts @@ -22,7 +22,7 @@ vi.mock("../client", () => ({ getStravaCredentials: () => getStravaCredentials(), })); -import { getStravaClientCredentials, storeStravaTokens } from "../credentials"; +import { getStravaClientCredentials } from "../credentials"; describe("getStravaClientCredentials — DB-first then env", () => { beforeEach(() => { @@ -57,58 +57,3 @@ describe("getStravaClientCredentials — DB-first then env", () => { expect(getStravaCredentials).toHaveBeenCalledOnce(); }); }); - -describe("storeStravaTokens — rotating-refresh compare-and-swap", () => { - beforeEach(() => { - findUnique.mockReset(); - updateMany.mockReset(); - }); - - it("persists and returns the fresh access token when the CAS wins", async () => { - updateMany.mockResolvedValue({ count: 1 }); - const token = await storeStravaTokens( - "user-1", - "fresh-access", - "fresh-refresh", - "stored-refresh-ciphertext", - ); - expect(token).toBe("fresh-access"); - // The guard keys on the exact stored ciphertext, not a re-encryption. - expect(updateMany).toHaveBeenCalledWith({ - where: { - id: "user-1", - stravaRefreshTokenEncrypted: "stored-refresh-ciphertext", - }, - data: { - stravaAccessTokenEncrypted: "enc(fresh-access)", - stravaRefreshTokenEncrypted: "enc(fresh-refresh)", - }, - }); - }); - - it("returns the peer's rotated access token when the CAS loses the race", async () => { - updateMany.mockResolvedValue({ count: 0 }); - findUnique.mockResolvedValue({ - stravaAccessTokenEncrypted: "peer-access-cipher", - }); - const token = await storeStravaTokens( - "user-1", - "fresh-access", - "fresh-refresh", - "stale-ciphertext", - ); - expect(token).toBe("dec(peer-access-cipher)"); - }); - - it("returns null when the connection vanished mid-race", async () => { - updateMany.mockResolvedValue({ count: 0 }); - findUnique.mockResolvedValue({ stravaAccessTokenEncrypted: null }); - const token = await storeStravaTokens( - "user-1", - "fresh-access", - "fresh-refresh", - "stale-ciphertext", - ); - expect(token).toBeNull(); - }); -}); diff --git a/src/lib/strava/credentials.ts b/src/lib/strava/credentials.ts index 768911c8e..4410aac16 100644 --- a/src/lib/strava/credentials.ts +++ b/src/lib/strava/credentials.ts @@ -12,7 +12,6 @@ */ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; -import { persistRotatedToken } from "@/lib/integrations/oauth-refresh"; import { getStravaCredentials, type StravaCredentials } from "./client"; /** @@ -91,8 +90,8 @@ export interface StravaConnection { refreshToken: string; /** * The exact stored refresh-token ciphertext (NOT the decrypted value). The - * rotating-refresh compare-and-swap in `storeStravaTokens` matches on this so - * a concurrent sync that already rotated the token is not clobbered. + * refresh after a refusal (`refreshStravaAfterRefusal`) compares against it + * so a concurrent sync that already rotated the token is not clobbered. */ refreshTokenCiphertext: string; } @@ -116,46 +115,3 @@ export async function getStravaConnection( refreshTokenCiphertext: user.stravaRefreshTokenEncrypted, }; } - -/** - * Persist a rotated Strava token pair with compare-and-swap on the stored - * refresh ciphertext. Strava's tokens live on `User` and the refresh is - * REACTIVE on a 401, so two overlapping syncs can race the same one-time-use - * refresh token. The CAS guard writes only when the stored ciphertext still - * equals the one this caller spent; on a lost race (0 rows) it re-reads and - * returns the peer's freshly rotated access token. - * - * Returns the access token the caller should use for the current sync (its own - * on a win, the peer's on a loss), or null if the connection vanished. - */ -export async function storeStravaTokens( - userId: string, - accessToken: string, - refreshToken: string, - expectedRefreshCiphertext: string, -): Promise { - return persistRotatedToken(accessToken, { - conditionalUpdate: async () => { - const { count } = await prisma.user.updateMany({ - where: { - id: userId, - stravaRefreshTokenEncrypted: expectedRefreshCiphertext, - }, - data: { - stravaAccessTokenEncrypted: encrypt(accessToken), - stravaRefreshTokenEncrypted: encrypt(refreshToken), - }, - }); - return count; - }, - readPeerAccessToken: async () => { - const fresh = await prisma.user.findUnique({ - where: { id: userId }, - select: { stravaAccessTokenEncrypted: true }, - }); - return fresh?.stravaAccessTokenEncrypted - ? decrypt(fresh.stravaAccessTokenEncrypted) - : null; - }, - }); -} diff --git a/src/lib/strava/oauth-descriptor.ts b/src/lib/strava/oauth-descriptor.ts new file mode 100644 index 000000000..52e9c1aa9 --- /dev/null +++ b/src/lib/strava/oauth-descriptor.ts @@ -0,0 +1,64 @@ +/** + * Where a Strava grant lives and how its token endpoint is called, for the + * shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`). + * + * Strava's tokens sit on `User`, with no expiry column: the sync refreshes + * after a refused read, and Strava rotates the refresh token on every use. + */ +import { + obtainAccessToken, + type OAuthTokenDescriptor, +} from "@/lib/integrations/oauth-kit/token"; +import { refreshAccessToken, type StravaCredentials } from "./client"; +import { getStravaClientCredentials } from "./credentials"; + +export const stravaTokenDescriptor: OAuthTokenDescriptor = { + provider: "strava", + tracksExpiry: false, + expiryBufferMs: 0, + async readGrant(db, userId) { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { + stravaAccessTokenEncrypted: true, + stravaRefreshTokenEncrypted: true, + }, + }); + if (!row?.stravaAccessTokenEncrypted || !row.stravaRefreshTokenEncrypted) { + return null; + } + return { + accessCiphertext: row.stravaAccessTokenEncrypted, + refreshCiphertext: row.stravaRefreshTokenEncrypted, + expiresAt: null, + }; + }, + async writeGrant(db, userId, spent, next) { + const { count } = await db.user.updateMany({ + where: { id: userId, stravaRefreshTokenEncrypted: spent }, + data: { + stravaAccessTokenEncrypted: next.accessCiphertext, + stravaRefreshTokenEncrypted: next.refreshCiphertext, + }, + }); + return count; + }, + loadCredentials: (userId) => getStravaClientCredentials(userId), + refresh: (refreshToken, credentials) => + refreshAccessToken(refreshToken, credentials), +}; + +/** + * Refresh after Strava refused the token read with `spentRefreshCiphertext`, + * once however many syncs ask together. Returns the access token to retry + * with, or null when there is nothing to retry with. A refused refresh throws. + */ +export async function refreshStravaAfterRefusal( + userId: string, + spentRefreshCiphertext: string, +): Promise { + const result = await obtainAccessToken(stravaTokenDescriptor, userId, { + afterRefusalOf: spentRefreshCiphertext, + }); + return result.kind === "token" ? result.accessToken : null; +} diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 35752d1b3..739e7a1b1 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -44,16 +44,12 @@ import { fetchActivities, fetchActivityById, mapActivity, - refreshAccessToken, summaryHasHeartRate, type StravaDetailedActivity, type StravaWorkoutRow, } from "./client"; -import { - getStravaClientCredentials, - getStravaConnection, - storeStravaTokens, -} from "./credentials"; +import { getStravaConnection } from "./credentials"; +import { refreshStravaAfterRefusal } from "./oauth-descriptor"; import { StravaApiError, classifyStravaError, @@ -173,13 +169,8 @@ async function runStravaSync( async function refreshOnce(): Promise { if (refreshDone) return false; - const creds = await getStravaClientCredentials(userId); - if (!creds) return false; - const rotated = await refreshAccessToken(conn!.refreshToken, creds); - const usable = await storeStravaTokens( + const usable = await refreshStravaAfterRefusal( userId, - rotated.access_token, - rotated.refresh_token, conn!.refreshTokenCiphertext, ); if (!usable) return false; From f9ed07551f079e6d3c42e70aa67bb84de2ed4474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:36 +0200 Subject: [PATCH 08/98] refactor(polar): read the token through the shared OAuth lifecycle --- src/lib/polar/credentials.ts | 18 +++++++++--------- src/lib/polar/oauth-descriptor.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 src/lib/polar/oauth-descriptor.ts diff --git a/src/lib/polar/credentials.ts b/src/lib/polar/credentials.ts index 50d866bc8..5151a1889 100644 --- a/src/lib/polar/credentials.ts +++ b/src/lib/polar/credentials.ts @@ -11,7 +11,9 @@ */ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; +import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; import { getPolarCredentials, type PolarCredentials } from "./client"; +import { polarTokenDescriptor } from "./oauth-descriptor"; /** * Resolve the user's Polar OAuth client id/secret, DB-first then env. @@ -80,24 +82,22 @@ export interface PolarConnection { } /** - * Fetch + decrypt the user's stored Polar token. Returns null when the user has + * Fetch + decrypt the user's stored Polar token, read through the shared OAuth + * token lifecycle like every other provider's. Returns null when the user has * not connected Polar (no access token) or the member id is missing. */ export async function getPolarConnection( userId: string, ): Promise { + const grant = await obtainAccessToken(polarTokenDescriptor, userId); + if (grant.kind !== "token") return null; const user = await prisma.user.findUnique({ where: { id: userId }, - select: { - polarAccessTokenEncrypted: true, - polarUserIdEncrypted: true, - }, + select: { polarUserIdEncrypted: true }, }); - if (!user?.polarAccessTokenEncrypted || !user?.polarUserIdEncrypted) { - return null; - } + if (!user?.polarUserIdEncrypted) return null; return { - accessToken: decrypt(user.polarAccessTokenEncrypted), + accessToken: grant.accessToken, polarUserId: decrypt(user.polarUserIdEncrypted), }; } diff --git a/src/lib/polar/oauth-descriptor.ts b/src/lib/polar/oauth-descriptor.ts new file mode 100644 index 000000000..6ce074fdb --- /dev/null +++ b/src/lib/polar/oauth-descriptor.ts @@ -0,0 +1,31 @@ +/** + * Where a Polar grant lives, for the shared token lifecycle + * (`src/lib/integrations/oauth-kit/token.ts`). + * + * Polar AccessLink tokens do not expire and come without a refresh token, so + * the kit only reads them: there is nothing to refresh and nothing to write + * back. A revoked grant surfaces as a refused read, and the person reconnects. + */ +import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token"; + +export const polarTokenDescriptor: OAuthTokenDescriptor = { + provider: "polar", + tracksExpiry: false, + expiryBufferMs: 0, + async readGrant(db, userId) { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { polarAccessTokenEncrypted: true }, + }); + if (!row?.polarAccessTokenEncrypted) return null; + return { + accessCiphertext: row.polarAccessTokenEncrypted, + refreshCiphertext: null, + expiresAt: null, + }; + }, + // Never reached: a grant without a refresh token is never refreshed. + writeGrant: async () => 0, + loadCredentials: async () => null, + refresh: null, +}; From b60fa0123b170f88738569bca7ac2dec2cbb4832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:37 +0200 Subject: [PATCH 09/98] test(integrations): freeze that every OAuth provider refreshes through the kit --- src/__tests__/oauth-kit-guard.test.ts | 114 ++++++++++++++ .../oauth-kit/__tests__/descriptors.test.ts | 148 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/__tests__/oauth-kit-guard.test.ts create mode 100644 src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts diff --git a/src/__tests__/oauth-kit-guard.test.ts b/src/__tests__/oauth-kit-guard.test.ts new file mode 100644 index 000000000..a5e2ca106 --- /dev/null +++ b/src/__tests__/oauth-kit-guard.test.ts @@ -0,0 +1,114 @@ +/** + * Every OAuth provider integration goes through the one token lifecycle. + * + * `src/lib/integrations/oauth-kit/token.ts` owns reading a grant, deciding it + * needs a refresh, refreshing under the advisory lock, and writing the result + * back conditionally. It replaced seven copies of that sequence, which had + * drifted into four different safety levels, one of them a status route that + * refreshed a one-time token with no guard at all. This file is what stops an + * eighth copy: + * + * A. Each kit provider has `src/lib//oauth-descriptor.ts`, and the + * descriptor names that provider. + * B. A provider client's `refreshAccessToken` is imported by that + * provider's descriptor and by nothing else outside tests. + * C. The refresh lock is taken by the kit and by the AI provider store + * (`src/lib/ai/provider.ts`, which is not an integration) only. + * D. A token is sealed into a connection row only by a callback route + * (the connect-time write). The kit writes ciphertext the descriptor is + * handed and never encrypts on its own. + * + * Fitbit is the one listed exception: the classic Fitbit Web API is retired + * on 2026-10-30 and its integration rides out on its own refresh until then, + * rather than being migrated onto a lifecycle it will never use. + * + * Every leg asserts a non-empty match set before it compares, so a matcher + * that stops matching fails instead of passing. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** The providers whose tokens the kit manages. */ +const KIT_PROVIDERS = [ + "withings", + "whoop", + "oura", + "strava", + "google-health", + "polar", +] as const; + +/** Retired on 2026-10-30; keeps its own refresh until it is removed. */ +const RETIRED = new Set(["src/lib/fitbit/sync-core.ts"]); + +function productionFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((f) => !f.startsWith("generated/")) + .filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f)) + .map((f) => `src/${f}`); +} + +function read(rel: string): string { + return readFileSync(join(process.cwd(), rel), "utf8"); +} + +describe("OAuth token lifecycle guard", () => { + const files = productionFiles(); + + it("A: every kit provider has a descriptor naming it", () => { + for (const provider of KIT_PROVIDERS) { + const rel = `src/lib/${provider}/oauth-descriptor.ts`; + expect(existsSync(join(process.cwd(), rel)), rel).toBe(true); + expect(read(rel)).toMatch(new RegExp(`provider:\\s*"${provider}"`)); + } + }); + + it("B: a provider's refreshAccessToken is imported only by its descriptor", () => { + const importers = files.filter((f) => { + const src = read(f); + return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test( + src, + ); + }); + expect(importers.length).toBeGreaterThan(0); + const allowed = new Set([ + ...KIT_PROVIDERS.filter((p) => p !== "polar").map( + (p) => `src/lib/${p}/oauth-descriptor.ts`, + ), + ...RETIRED, + ]); + expect(importers.filter((f) => !allowed.has(f))).toEqual([]); + }); + + it("C: only the kit and the AI provider store take the refresh lock", () => { + const callers = files.filter((f) => + /\bacquireProviderTokenRefreshLock\s*\(/.test(read(f)), + ); + expect(callers.length).toBeGreaterThan(0); + expect(callers.sort()).toEqual([ + "src/lib/ai/provider.ts", + "src/lib/integrations/oauth-kit/token.ts", + "src/lib/integrations/oauth-refresh.ts", + ]); + }); + + it("D: tokens are sealed into a connection only by a callback", () => { + const sealing = files.filter((f) => + /\b(?:accessToken|refreshToken|[a-z]+AccessTokenEncrypted|[a-z]+RefreshTokenEncrypted):\s*encrypt\(/.test( + read(f), + ), + ); + expect(sealing.length).toBeGreaterThan(0); + const offenders = sealing.filter( + (f) => + !RETIRED.has(f) && + !/^src\/app\/api\/[a-z-]+\/callback\/route\.ts$/.test(f), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts b/src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts new file mode 100644 index 000000000..4148ecfcc --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts @@ -0,0 +1,148 @@ +/** + * Pins what each provider's descriptor reads and writes. The kit's own + * behaviour is covered in `token.test.ts`; the claim here is that every + * descriptor reads the columns its provider always used (so no connection has + * to reconnect) and writes only while the stored refresh token is the one the + * refresh spent. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/withings/credentials", () => ({ + getUserWithingsCredentials: vi.fn(), +})); +vi.mock("@/lib/whoop/credentials", () => ({ + getUserWhoopCredentials: vi.fn(), +})); +vi.mock("@/lib/google-health/credentials", () => ({ + getUserGoogleHealthCredentials: vi.fn(), +})); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: vi.fn(), +})); +vi.mock("@/lib/strava/credentials", () => ({ + getStravaClientCredentials: vi.fn(), +})); + +import type { OAuthKitDb, OAuthTokenDescriptor } from "../token"; +import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor"; +import { whoopTokenDescriptor } from "@/lib/whoop/oauth-descriptor"; +import { googleHealthTokenDescriptor } from "@/lib/google-health/oauth-descriptor"; +import { ouraTokenDescriptor } from "@/lib/oura/oauth-descriptor"; +import { stravaTokenDescriptor } from "@/lib/strava/oauth-descriptor"; +import { polarTokenDescriptor } from "@/lib/polar/oauth-descriptor"; + +function fakeDb(row: Record | null) { + const model = { + findUnique: vi.fn(async () => row), + updateMany: vi.fn(async () => ({ count: 1 })), + }; + const db = { + user: model, + withingsConnection: model, + whoopConnection: model, + googleHealthConnection: model, + } as unknown as OAuthKitDb; + return { db, model }; +} + +const EXP = new Date("2026-10-21T13:00:00.000Z"); +const NEXT = { + accessCiphertext: "a2", + refreshCiphertext: "r2", + expiresAt: EXP, +}; + +type AnyDescriptor = OAuthTokenDescriptor; + +const CONNECTION_TABLES: Array<[string, AnyDescriptor]> = [ + ["withings", withingsTokenDescriptor as unknown as AnyDescriptor], + ["whoop", whoopTokenDescriptor as unknown as AnyDescriptor], + ["google-health", googleHealthTokenDescriptor as unknown as AnyDescriptor], +]; + +describe.each(CONNECTION_TABLES)("%s descriptor", (_name, d) => { + it("reads the connection row's columns", async () => { + const { db } = fakeDb({ + whoopUserId: "w", + accessToken: "a1", + refreshToken: "r1", + tokenExpiresAt: EXP, + }); + await expect(d.readGrant(db, "u")).resolves.toEqual({ + accessCiphertext: "a1", + refreshCiphertext: "r1", + expiresAt: EXP, + }); + }); + + it("writes only over the refresh token it spent", async () => { + const { db, model } = fakeDb(null); + await d.writeGrant(db, "u", "r1", NEXT); + expect(model.updateMany).toHaveBeenCalledWith({ + where: { userId: "u", refreshToken: "r1" }, + data: { accessToken: "a2", refreshToken: "r2", tokenExpiresAt: EXP }, + }); + }); +}); + +describe("whoop descriptor", () => { + it("treats a connection without a WHOOP user id as no grant", async () => { + const { db } = fakeDb({ + whoopUserId: null, + accessToken: "a1", + refreshToken: "r1", + tokenExpiresAt: EXP, + }); + await expect(whoopTokenDescriptor.readGrant(db, "u")).resolves.toBeNull(); + }); +}); + +const USER_COLUMNS: Array<[string, AnyDescriptor, string]> = [ + ["oura", ouraTokenDescriptor as unknown as AnyDescriptor, "oura"], + ["strava", stravaTokenDescriptor as unknown as AnyDescriptor, "strava"], +]; + +describe.each(USER_COLUMNS)("%s descriptor", (_name, d, prefix) => { + it("reads the User columns, with no expiry", async () => { + const { db } = fakeDb({ + [`${prefix}AccessTokenEncrypted`]: "a1", + [`${prefix}RefreshTokenEncrypted`]: "r1", + }); + await expect(d.readGrant(db, "u")).resolves.toEqual({ + accessCiphertext: "a1", + refreshCiphertext: "r1", + expiresAt: null, + }); + expect(d.tracksExpiry).toBe(false); + }); + + it("reads no grant when either token is missing", async () => { + const { db } = fakeDb({ [`${prefix}AccessTokenEncrypted`]: "a1" }); + await expect(d.readGrant(db, "u")).resolves.toBeNull(); + }); + + it("writes only over the refresh token it spent", async () => { + const { db, model } = fakeDb(null); + await d.writeGrant(db, "u", "r1", NEXT); + expect(model.updateMany).toHaveBeenCalledWith({ + where: { id: "u", [`${prefix}RefreshTokenEncrypted`]: "r1" }, + data: { + [`${prefix}AccessTokenEncrypted`]: "a2", + [`${prefix}RefreshTokenEncrypted`]: "r2", + }, + }); + }); +}); + +describe("polar descriptor", () => { + it("reads a token with nothing to refresh", async () => { + const { db } = fakeDb({ polarAccessTokenEncrypted: "a1" }); + await expect(polarTokenDescriptor.readGrant(db, "u")).resolves.toEqual({ + accessCiphertext: "a1", + refreshCiphertext: null, + expiresAt: null, + }); + expect(polarTokenDescriptor.refresh).toBeNull(); + }); +}); From 9a3523a498d507055b23bc025f051e8990bf97f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:23:34 +0200 Subject: [PATCH 10/98] fix(oura): stop importing the equivalent walking distance as distance Oura's equivalent_walking_distance is the distance a walk of the day's effort would have covered, not distance travelled. Written as WALKING_RUNNING_DISTANCE it counted a bike ride or a yoga hour as kilometres walked. The daily activity mapper now writes steps and active energy only. --- src/lib/oura/__tests__/client.test.ts | 14 ++++++------ src/lib/oura/client.ts | 33 +++++++++++++-------------- src/lib/oura/sync.ts | 2 +- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 13535d03c..7636ffd05 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -471,7 +471,7 @@ describe("mapSleep", () => { }); describe("mapDailyActivity", () => { - it("maps steps, active energy, and equivalent walking distance (m)", () => { + it("maps steps and active energy, and never the equivalent walking distance", () => { const a: OuraDailyActivity = { id: "1", day: "2026-06-10", @@ -484,12 +484,12 @@ describe("mapDailyActivity", () => { expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe( 412, ); - const dist = mapped.find((m) => m.type === "WALKING_RUNNING_DISTANCE"); - expect(dist).toMatchObject({ - value: 6543, - unit: "m", - fieldTag: "distance", - }); + // An activity equivalent, not distance travelled: no distance row at all. + expect(mapped.map((m) => m.type).sort()).toEqual([ + "ACTIVE_ENERGY_BURNED", + "ACTIVITY_STEPS", + ]); + expect(mapped.some((m) => m.fieldTag === "distance")).toBe(false); }); }); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index ed99a0505..c7a40fb83 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -449,7 +449,11 @@ export interface OuraDailyActivity { steps?: number | null; active_calories?: number | null; total_calories?: number | null; - /** Equivalent walking distance for the day, metres. */ + /** + * Oura's activity-equivalent distance, metres: what a walk of the day's + * effort would have covered. Not distance travelled; never imported (see + * `mapDailyActivity`). + */ equivalent_walking_distance?: number | null; } @@ -972,8 +976,17 @@ export function mapDailySpo2( ]; } -/** Map one Oura daily-activity record: steps, active energy, and the - * equivalent walking distance (B5 — the OURA distance ladder slot was dead). */ +/** + * Map one Oura daily-activity record: steps and active energy. + * + * `equivalent_walking_distance` is deliberately not mapped. Oura derives it + * from the day's whole activity (the distance a walk of the same effort would + * have covered), so an hour of cycling or yoga adds kilometres nobody walked. + * Stored as `WALKING_RUNNING_DISTANCE` it read as real distance, and on a day + * no other source covered it became the day's distance. The rows earlier + * syncs wrote under `activity::distance` are retired on their own + * (`./retire-distance`). + */ export function mapDailyActivity( a: OuraDailyActivity, tz?: string, @@ -999,20 +1012,6 @@ export function mapDailyActivity( fieldTag: "active_energy", }); } - if ( - typeof a.equivalent_walking_distance === "number" && - a.equivalent_walking_distance >= 0 - ) { - out.push({ - type: "WALKING_RUNNING_DISTANCE", - // Oura reports the equivalent walking distance in metres — the canonical - // WALKING_RUNNING_DISTANCE unit (no conversion). - value: importValue(a.equivalent_walking_distance), - unit: "m", - measuredAt, - fieldTag: "distance", - }); - } return out; } diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 39aee9733..a22b87561 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -4,7 +4,7 @@ * Pulls daily readiness (→ RECOVERY_SCORE + BODY_TEMPERATURE_DEVIATION), sleep * (real per-segment hypnogram timeline when present, else per-stage totals; * efficiency, HRV, RHR, respiratory rate), daily activity (steps, active - * energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE), + * energy), the daily Sleep Score (→ SLEEP_SCORE), * daily SpO2 (→ OXYGEN_SATURATION), the dedicated vO2_max collection (→ * VO2_MAX), and the daily cardiovascular-age estimate (→ VASCULAR_AGE) for one * connected user, mapping each into `Measurement` rows tagged `source = OURA`. From 9941db15497c1f10ef8d36d5d339cb9d66db5aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:24:21 +0200 Subject: [PATCH 11/98] refactor(insights): drop the readable cached-text column Every write has gone to insights_cached_text_encrypted since 0411, and the boot-time free-text backfill sealed the rows written before it. Migration 0430 drops insights_cached_text and the backfill index that served it. A host that skipped the intervening releases loses only a cache of model output; the next warm writes a fresh, sealed payload. The cipher module now reads and writes the sealed column only, the backfill job loses its insights step, and consent withdrawal, the wipe plan, the backup plan and the session projection follow the schema. Backups never carried the column, and restore ignores unknown account settings; a round-trip test now proves an old backup naming the field still restores cleanly. The access guard pins exact counts for where the sealed name may appear. Fixtures, the demo seed and the demo migration script seed the sealed column instead of plaintext. --- e2e/setup/ai-optional-fixture.ts | 10 +- e2e/setup/global-setup.ts | 2 +- .../migration.sql | 15 ++ prisma/schema.prisma | 9 +- scripts/migrate-demo-edge01.sh | 4 +- scripts/seed-demo.ts | 4 +- .../insights-cached-text-access-guard.test.ts | 64 +++---- .../session-user-projection-guard.test.ts | 2 +- .../__tests__/route-module-gate.test.ts | 1 - src/app/api/daily/digest/route.ts | 2 +- src/app/api/dashboard/snapshot/route.ts | 2 +- .../health-record/__tests__/route.test.ts | 2 +- .../insights/generate/__tests__/route.test.ts | 159 +++++++++++------- src/app/api/insights/generate/route.ts | 2 +- .../insights/use-insights-advisor.ts | 4 +- src/lib/auth/session-user.ts | 4 +- src/lib/consent/withdrawal.ts | 2 +- src/lib/daily/digest.ts | 2 +- src/lib/daily/load-digest.ts | 2 +- .../snapshot-read-briefing-capability.test.ts | 12 +- .../snapshot-read-briefing-today.test.ts | 16 +- src/lib/dashboard/snapshot.ts | 4 +- src/lib/data-wipe/wipe-plan.ts | 1 - .../restore-backup-round-trip.test.ts | 37 +++- src/lib/export/backup-plan.ts | 1 - .../__tests__/cached-text-cipher.test.ts | 38 +---- ...ensive-briefing-grounding-disposal.test.ts | 9 +- .../comprehensive-briefing-reroll.test.ts | 11 +- .../comprehensive-budget-refusal.test.ts | 2 +- .../comprehensive-generate-force.test.ts | 49 ++++-- .../comprehensive-json-retry.test.ts | 2 +- .../comprehensive-outbound-repair.test.ts | 2 +- .../comprehensive-plan-progress.test.ts | 2 +- .../comprehensive-privacy-flip.test.ts | 23 ++- .../comprehensive-timeout-honored.test.ts | 4 +- .../comprehensive-warm-timeout.test.ts | 2 +- src/lib/insights/briefing-failure-marker.ts | 2 +- src/lib/insights/cached-text-cipher.ts | 75 +++------ src/lib/insights/comprehensive-generate.ts | 2 +- .../free-text-encryption-backfill.test.ts | 99 ----------- src/lib/jobs/free-text-encryption-backfill.ts | 63 +------ .../profile/__tests__/health-facts.test.ts | 4 - tests/integration/ai-optional-actions.test.ts | 9 +- .../ai-optional-mixed-routes.test.ts | 11 +- .../integration/briefing-cache-locale.test.ts | 11 +- .../consent-withdrawal-purge.test.ts | 9 +- .../free-text-encryption-backfill.test.ts | 79 +++------ .../insights-provider-chain.test.ts | 2 +- tests/integration/sharing-front-door.test.ts | 5 +- 49 files changed, 381 insertions(+), 497 deletions(-) create mode 100644 prisma/migrations/0430_drop_insights_cached_text/migration.sql diff --git a/e2e/setup/ai-optional-fixture.ts b/e2e/setup/ai-optional-fixture.ts index 4a417d3e1..3b930fc38 100644 --- a/e2e/setup/ai-optional-fixture.ts +++ b/e2e/setup/ai-optional-fixture.ts @@ -54,7 +54,7 @@ export async function resetAiOptionalAccount(): Promise { `UPDATE users SET ai_provider = NULL, ai_base_url = NULL, - insights_cached_text = NULL, + insights_cached_text_encrypted = NULL, insights_cached_at = NULL, insights_cached_locale = NULL, disable_coach = false @@ -80,16 +80,20 @@ export async function seedStoredBriefing(marker: string): Promise { // Generated now: only text written today is served as today's read. briefingGeneratedAt: new Date().toISOString(), }; + // Sealed the way the app writes it; there is no readable column to seed. + const { sealInsightsCachedText } = + await import("@/lib/insights/cached-text-cipher"); + const sealed = Buffer.from(sealInsightsCachedText(JSON.stringify(insights))); await withAccount(async (pool, userId) => { await pool.query( `UPDATE users SET ai_provider = 'LOCAL', ai_base_url = 'http://127.0.0.1:9/v1', - insights_cached_text = $2, + insights_cached_text_encrypted = $2, insights_cached_at = now(), insights_cached_locale = 'en' WHERE id = $1`, - [userId, JSON.stringify(insights)], + [userId, sealed], ); await pool.query( `INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at) diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 3d4b74cf9..faf10ceb1 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -802,7 +802,7 @@ export default async function globalSetup(config: FullConfig): Promise { codex_connected_at = NULL, codex_connection_status = 'disconnected', insights_cached_at = NULL, - insights_cached_text = NULL, + insights_cached_text_encrypted = NULL, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, module_preferences_json = EXCLUDED.module_preferences_json, diff --git a/prisma/migrations/0430_drop_insights_cached_text/migration.sql b/prisma/migrations/0430_drop_insights_cached_text/migration.sql new file mode 100644 index 000000000..39724eb38 --- /dev/null +++ b/prisma/migrations/0430_drop_insights_cached_text/migration.sql @@ -0,0 +1,15 @@ +-- Drop the readable copy of the cached AI insights payload. +-- +-- Since 0411 every write goes to `insights_cached_text_encrypted` and leaves +-- `insights_cached_text` NULL, and the boot-time free-text encryption backfill +-- sealed the rows written before. Nothing reads the readable column any more. +-- +-- A host that skipped the releases between 0411 and this one may still hold a +-- readable payload the backfill never reached. It is a cache of model output, +-- not a record: the next nightly or on-demand warm writes a fresh, sealed one. +-- Dropping it is the safe direction; keeping it would leave health text in the +-- clear on exactly the hosts that upgraded the longest way round. + +DROP INDEX IF EXISTS "users_insights_cached_text_backfill_idx"; + +ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_cached_text"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d79d97b1..61e9e8270 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,13 +136,6 @@ model User { useCentralCodex Boolean @default(false) @map("use_central_codex") insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" insightsCachedAt DateTime? @map("insights_cached_at") - /// @internal: legacy plaintext cache of the AI insights payload, read only - /// as a fallback for rows the free-text encryption backfill has not reached - /// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted` - /// and leaves this NULL; the backfill seals and nulls it on the old rows. - /// DROP this column in a later release, once the backfill reports zero - /// remaining rows on every instance. - insightsCachedText String? @map("insights_cached_text") /// v1.43.7 — the cached AI insights payload (briefing, per-domain readings, /// recommendations; JSON), AES-256-GCM binary layout under /// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through @@ -6701,7 +6694,7 @@ model HostMetric { // (no prompt-tuning flow yet — that ratchet is v1.4.17). // // Privacy: server-side fills providerType + promptVersion from the -// user's most recent cached payload (User.insightsCachedText) so the +// user's most recent cached payload (User.insightsCachedTextEncrypted) so the // client can't tamper with the attribution. // // `(userId, recommendationId, recommendationText)` is the dedup key. diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index 228e94714..d623229f0 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -327,7 +327,7 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat height_cm, date_of_birth, gender, timezone, codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at, codex_connected_at, codex_connection_status, - insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted, + insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted, insights_exclude_metrics, global_excluded_injection_sites, telegram_bot_token, telegram_chat_id, telegram_enabled, @@ -360,7 +360,7 @@ readonly USERS_SELECT="id, username, email, password_hash, NULL::timestamptz AS codex_token_expires_at, NULL::timestamptz AS codex_connected_at, 'disconnected' AS codex_connection_status, - insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted, + insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted, insights_exclude_metrics, global_excluded_injection_sites, NULL::text AS telegram_bot_token, diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index aad6f50f2..2479d4cf3 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -3555,15 +3555,13 @@ async function seed() { await client.query( `UPDATE users SET insights_cached_text_encrypted = $2, - insights_cached_text = NULL, insights_cached_at = NOW(), insights_snapshot_hash = $3, insights_briefing_reroll_date = $4 WHERE id = $1`, [ userId, - // Sealed like every write since v1.43.7; the app never reads the - // readable column while a ciphertext is there. + // Sealed like every write since v1.43.7. Buffer.from( sealInsightsCachedText(JSON.stringify(comprehensiveInsight)), ), diff --git a/src/__tests__/insights-cached-text-access-guard.test.ts b/src/__tests__/insights-cached-text-access-guard.test.ts index f5cff3900..f3bd38ee4 100644 --- a/src/__tests__/insights-cached-text-access-guard.test.ts +++ b/src/__tests__/insights-cached-text-access-guard.test.ts @@ -1,18 +1,16 @@ /** * The cached AI insights payload is read and written in one place. * - * Since v1.43.7 the payload is sealed in `User.insightsCachedTextEncrypted` - * and the readable `insightsCachedText` survives only as a fallback for rows - * the backfill has not reached. Two columns that must move together are a - * standing invitation to move one: a clear written as - * `{ insightsCachedText: null }` — the shape every clear had until this - * release — would now leave the whole sealed briefing behind, after a consent - * withdrawal of all places. So the names are spelled in - * `src/lib/insights/cached-text-cipher.ts`, and every other file goes through - * its select, write, clear and open helpers. + * The payload is sealed in `User.insightsCachedTextEncrypted`; the readable + * `insightsCachedText` column it replaced was dropped in v1.44.0 (migration + * 0430). Every reader and writer goes through + * `src/lib/insights/cached-text-cipher.ts` (its select, write, clear and open + * helpers), so a clear cannot forget the column and a read cannot skip the + * decryption. The column name is spelled there and in the tables that have + * to name every column of the row. * - * The allowlist below is the set of files with a reason to spell the legacy - * name anyway, each stated. A file that joins it has to say why here. + * The readable name survives only as the snapshot builder's input field, which + * carries the OPENED text, never the column. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -23,42 +21,46 @@ import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); -const ALLOWED: Readonly> = { - "lib/insights/cached-text-cipher.ts": "the one module that owns the columns", - "lib/jobs/free-text-encryption-backfill.ts": - "the backfill that seals the legacy column", +const SEALED_ALLOWED: Readonly> = { + "lib/insights/cached-text-cipher.ts": "the one module that owns the column", + "lib/crypto/encrypted-columns.ts": + "the registry of every ciphertext-bearing column", "lib/export/backup-plan.ts": "the backup classification names every User column", - "lib/data-wipe/wipe-plan.ts": - "the wipe reset names every User column it resets, and resets both", + "lib/data-wipe/wipe-plan.ts": "the wipe reset names every User column", +}; + +const OPENED_TEXT_ALLOWED: Readonly> = { "lib/dashboard/snapshot.ts": - "the snapshot builder's input field carries the OPENED text, not the column", + "the snapshot builder's input field carries the opened text", "lib/dashboard/snapshot-read.ts": "fills that input field from openInsightsCachedText", }; -/** The legacy name, not its sealed sibling, outside comments. */ -const LEGACY_NAME = /\binsightsCachedText\b(?!Encrypted)/; +const SEALED_NAME = /\binsightsCachedTextEncrypted\b/; +/** The dropped readable name, not its sealed sibling. */ +const READABLE_NAME = /\binsightsCachedText\b(?!Encrypted)/; -function filesSpellingTheLegacyName(): string[] { +function sourceFilesMatching(pattern: RegExp): string[] { return walkSourceFiles(SRC, { floor: 2000 }) .filter((f) => !f.startsWith("generated/")) .filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f)) .filter((f) => - LEGACY_NAME.test(stripComments(readFileSync(join(SRC, f), "utf8"))), - ); + pattern.test(stripComments(readFileSync(join(SRC, f), "utf8"))), + ) + .sort(); } describe("insights cache column access", () => { - const found = filesSpellingTheLegacyName(); - - it("is confined to the cipher module and the stated exceptions", () => { - const strays = found.filter((f) => !(f in ALLOWED)); - expect(strays).toEqual([]); + it("spells the sealed column only in the cipher module and the stated tables", () => { + const found = sourceFilesMatching(SEALED_NAME); + expect(found.length).toBe(4); + expect(found).toEqual(Object.keys(SEALED_ALLOWED).sort()); }); - it("still finds every stated exception, so the matcher is live", () => { - expect(found.length).toBeGreaterThan(5); - expect([...found].sort()).toEqual(Object.keys(ALLOWED).sort()); + it("uses the readable name only for the opened text the snapshot builder takes", () => { + const found = sourceFilesMatching(READABLE_NAME); + expect(found.length).toBe(2); + expect(found).toEqual(Object.keys(OPENED_TEXT_ALLOWED).sort()); }); }); diff --git a/src/__tests__/session-user-projection-guard.test.ts b/src/__tests__/session-user-projection-guard.test.ts index 36a64c618..ad62e0f7d 100644 --- a/src/__tests__/session-user-projection-guard.test.ts +++ b/src/__tests__/session-user-projection-guard.test.ts @@ -127,7 +127,7 @@ describe("SESSION_USER_SELECT leaves out bytes and secrets", () => { it("selects no credential, secret or large cached text", () => { const secretShaped = - /Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^insightsCachedText$|^environmentAqHistoryJson$/; + /Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^environmentAqHistoryJson$/; const secrets = columns .map((c) => c.name) .filter((name) => secretShaped.test(name)); diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts index 94bb26256..647741d4b 100644 --- a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -226,7 +226,6 @@ describe("about-me module gate", () => { where: { id: "u1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, diff --git a/src/app/api/daily/digest/route.ts b/src/app/api/daily/digest/route.ts index db05d9ea8..f9b6dbfa3 100644 --- a/src/app/api/daily/digest/route.ts +++ b/src/app/api/daily/digest/route.ts @@ -4,7 +4,7 @@ * The read seam for the unified daily-value system (P3). Returns the * `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget * all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the - * nightly briefing lifted read-only from `User.insightsCachedText`, the + * nightly briefing lifted read-only from `User.insightsCachedTextEncrypted`, the * dashboard-snapshot health score / meds-today / sleep freshness) plus two * light deterministic reads (broken integrations, overdue Vorsorge). No * provider call is reachable from this path, and nothing warms on mount. diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index 8ad23d819..3079fb2e8 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -26,7 +26,7 @@ * so a regression is attributable without re-instrumenting. * * No LLM is reachable from the builder — the daily briefing is lifted - * read-only from `User.insightsCachedText`. The nightly + * read-only from `User.insightsCachedTextEncrypted`. The nightly * `insight-pregenerate` cron keeps that cache warm. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 3b61abb58..0a768cb86 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -206,7 +206,7 @@ beforeEach(() => { fullName: null, insurerName: null, insuranceNumberEncrypted: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, } as never); }); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 8972bd996..bab35438f 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -44,7 +44,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(async () => ({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, locale: "en", })), updateMany: vi.fn(async () => ({ count: 1 })), @@ -175,7 +175,10 @@ import { import { annotate } from "@/lib/logging/context"; import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -472,7 +475,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { const user = { insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, locale: "en", unitPreference: "imperial", glucoseUnit: "mmol/L", @@ -527,7 +530,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: ["sleep"], locale: "en", dateOfBirth: null, @@ -564,7 +567,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], locale: "en", } as never); @@ -596,11 +599,13 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - changed: "still fresh", - dailyBriefing: { paragraph: "ok", keyFindings: [] }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + changed: "still fresh", + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), locale: "en", } as never); @@ -636,11 +641,13 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - changed: "still fresh", - dailyBriefing: { paragraph: "ok", keyFindings: [] }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + changed: "still fresh", + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), locale: "en", } as never); @@ -673,10 +680,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ - changed: "fresh but no briefing", - dailyBriefing: null, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + changed: "fresh but no briefing", + dailyBriefing: null, + }), + ), insightsExcludeMetrics: [], locale: "en", } as never); @@ -686,10 +695,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "today", keyFindings: [] }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "today", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), locale: "en", } as never); @@ -770,7 +781,7 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () // wide shape; the retry asks for the aggregated shape. insightsPrivacyMode: "raw", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], locale: "en", } as never); @@ -817,7 +828,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), locale: "en", } as never); @@ -842,7 +855,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const stale = new Date(Date.now() - 48 * 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: stale, - insightsCachedText: JSON.stringify({ dailyBriefing: null }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: null }), + ), locale: "en", } as never); @@ -867,7 +882,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cached = { dailyBriefing: { paragraph: "Deutsch", keyFindings: [] } }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), insightsCachedLocale: "de", locale: "en", } as never); @@ -896,7 +913,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), insightsCachedLocale: "en", locale: "en", } as never); @@ -918,10 +937,12 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(Date.now() - 60_000), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] }, - briefingGeneratedAt: yesterday.toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] }, + briefingGeneratedAt: yesterday.toISOString(), + }), + ), insightsCachedLocale: "en", locale: "en", timezone: "UTC", @@ -944,29 +965,31 @@ describe("GET /api/insights/generate — read-only advisor read", () => { it("serves today's signals with deltas at display precision", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - dailyBriefing: { - paragraph: "ok", - keyFindings: [], - signalsOfDay: [ - { - sourceMetric: "sleep", - tone: "info", - headline: "Shorter night", - nudge: "An earlier night.", - delta: null, - }, - { - sourceMetric: "pulse", - tone: "watch", - headline: "Pulse up", - nudge: "Rest.", - delta: "+33.72 bpm vs your 30-day average", - }, - ], - }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { + paragraph: "ok", + keyFindings: [], + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night.", + delta: null, + }, + { + sourceMetric: "pulse", + tone: "watch", + headline: "Pulse up", + nudge: "Rest.", + delta: "+33.72 bpm vs your 30-day average", + }, + ], + }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), insightsCachedLocale: "en", locale: "en", timezone: "UTC", @@ -998,7 +1021,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => { vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, locale: "en", } as never); @@ -1027,7 +1050,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cached = { dailyBriefing: { paragraph: "old", keyFindings: [] } }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: stale, - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), locale: "en", } as never); @@ -1054,7 +1079,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cachedAt = new Date(Date.now() - 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: cachedAt, - insightsCachedText: JSON.stringify({ dailyBriefing: null }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: null }), + ), locale: "en", } as never); // Marker newer than the last successful generation (written right @@ -1086,9 +1113,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cachedAt = new Date(Date.now() - 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: cachedAt, - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "held", keyFindings: [] }, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "held", keyFindings: [] }, + }), + ), locale: "en", } as never); vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ @@ -1109,9 +1138,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => { it("reports hasProvider: true on a normal cached read", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "ok", keyFindings: [] }, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + }), + ), locale: "en", } as never); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index f2e3c3ff9..9827cab62 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -153,7 +153,7 @@ export function resolveInsightsRateLimit(): number { * to mount against the POST handler, which generates inline on a cache miss * and blocks the page-load path on the full provider chain (up to the * client's 8 s abort). This read-only GET serves the cached payload from - * `User.insightsCachedText` immediately — NEVER calling the provider — and, + * `User.insightsCachedTextEncrypted` immediately — NEVER calling the provider — and, * when the cache is stale / missing AND a provider is configured, enqueues * an out-of-band warm (the same `insight-pregenerate` queue the nightly * cron and the "prepare assessments" button use). The next read reflects diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index e19700589..9633a652e 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -26,7 +26,7 @@ import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marke * * Why a POST under a `useQuery` rather than a dedicated GET endpoint: * the route already cache-returns the most recent generation (24h TTL on - * `User.insightsCachedAt` / `User.insightsCachedText`) without burning a + * `User.insightsCachedAt` / `User.insightsCachedTextEncrypted`) without burning a * rate-limit token, so a single POST-without-`force` is functionally a * GET-or-generate. Adding a separate GET would duplicate the cache-read * branch and split the audit-log surface; the reconcile report deferred @@ -540,7 +540,7 @@ export function useInsightsAdvisorQuery( // re-fetches. queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); // v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing - // headline from the SNAPSHOT (server-lifted `User.insightsCachedText`), + // headline from the SNAPSHOT (server-lifted `User.insightsCachedTextEncrypted`), // and the Today hero's lead line is the SAME briefing lifted into the // digest — neither is this advisor cache. A regenerate rewrites that // server cache, so both the snapshot and the digest must refetch. diff --git a/src/lib/auth/session-user.ts b/src/lib/auth/session-user.ts index d54cfeccd..b665542c7 100644 --- a/src/lib/auth/session-user.ts +++ b/src/lib/auth/session-user.ts @@ -17,8 +17,8 @@ import type { Prisma } from "@/generated/prisma/client"; * flags, the small preference documents). Anything else is read by the one * route that needs it, with its own `select`, at the moment it needs it: * - * - bytes and large text: `avatarBytes`, `insightsCachedText`, - * `insightsCachedTextEncrypted`, `homeLocationEncrypted`, + * - bytes and large text: `avatarBytes`, `insightsCachedTextEncrypted`, + * `homeLocationEncrypted`, * `environmentAqHistoryJson`, `dawarichPrefsEncrypted`; * - credentials and secrets: `passwordHash`, `totpSecretEncrypted`, * `totpLastStep`, `telegramBotToken`, every `*Encrypted` integration and diff --git a/src/lib/consent/withdrawal.ts b/src/lib/consent/withdrawal.ts index 9ec245d6c..2c35c261a 100644 --- a/src/lib/consent/withdrawal.ts +++ b/src/lib/consent/withdrawal.ts @@ -66,7 +66,7 @@ export async function purgeRegenerableAiText( where: { id: userId, OR: [ - ...INSIGHTS_CACHE_PRESENT_WHERE.OR, + INSIGHTS_CACHE_PRESENT_WHERE, { insightsCachedAt: { not: null } }, { insightsSnapshotHash: { not: null } }, ], diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 5d837ba28..e6995062f 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -3,7 +3,7 @@ * * A pure, deterministic composer that assembles the day's read from data that * ALREADY EXISTS — the nightly `insight-pregenerate` output lifted read-only - * from `User.insightsCachedText`, the dashboard-snapshot ingredients (health + * from `User.insightsCachedTextEncrypted`, the dashboard-snapshot ingredients (health * score, meds-today, sleep last-seen), plus two light deterministic reads * (integration status, due Vorsorge reminders). It computes no analytics and * NEVER triggers an AI/provider call — the warm-on-mount ban extends here. The diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index a79699426..1b15616a9 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -6,7 +6,7 @@ * briefing lift) rides `readDashboardSnapshotCached` — the SAME SWR cell the * dashboard already serves, so the digest never re-runs the snapshot builder * within its TTL and never reaches an AI provider (the briefing is lifted - * read-only from `User.insightsCachedText`). Two light deterministic reads add + * read-only from `User.insightsCachedTextEncrypted`). Two light deterministic reads add * the rail's non-snapshot inputs: broken integrations and overdue Vorsorge * reminders. Module gating is inherited from the snapshot (already * module-gated at the build layer) plus the resolved module map the item diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts index ebe57a1d8..329dd66d9 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts @@ -16,6 +16,10 @@ import { AI_AVAILABLE, aiUnavailable, } from "@/__tests__/helpers/ai-capability-fixtures"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; + +// The cached payload is sealed at rest, so seeding it needs a key. +vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); const briefingBody = { tiles: {}, @@ -35,9 +39,11 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: async () => ({ - insightsCachedText: JSON.stringify({ - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + briefingGeneratedAt: new Date().toISOString(), + }), + ), }), }, }, diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts index 7cf124e4a..3efaf82c4 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -10,6 +10,10 @@ import { AI_AVAILABLE, aiUnavailable, } from "@/__tests__/helpers/ai-capability-fixtures"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; + +// The cached payload is sealed at rest, so seeding it needs a key. +vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); const BRIEFING = { paragraph: "Your pulse is well above its usual level today.", @@ -49,7 +53,7 @@ const buildDashboardSnapshot = vi.fn(async () => current); // The cached briefing text is read off its own row, not the session user. const cacheRow = vi.hoisted(() => ({ - current: null as { insightsCachedText: string | null } | null, + current: null as { insightsCachedTextEncrypted: Uint8Array | null } | null, })); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: async () => cacheRow.current } }, @@ -80,10 +84,12 @@ const NOW = new Date("2026-10-05T19:00:00.000Z"); function user(generatedAt: string | null): SessionUser { cacheRow.current = { - insightsCachedText: JSON.stringify({ - dailyBriefing: BRIEFING, - ...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: BRIEFING, + ...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}), + }), + ), }; return { id: "user-briefing-today", diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 4293375c7..29541fa39 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -27,7 +27,7 @@ * inside the same fast window. * * No LLM is reachable from this builder. The briefing is lifted - * read-only from `User.insightsCachedText`; a cache miss / stale row + * read-only from `User.insightsCachedTextEncrypted`; a cache miss / stale row * yields `briefingState: "preparing"` (with the last good briefing * delivered under `briefingStale: true` when one exists) and the * `insight-pregenerate` cron refills it — or `"no-provider"` when no @@ -943,7 +943,7 @@ export function applyBriefingCapability( } /** - * Read-only briefing lift. Parses `User.insightsCachedText`, validates + * Read-only briefing lift. Parses `User.insightsCachedTextEncrypted`, validates * the `dailyBriefing` block, and reports a four-state. NEVER calls the * provider chain — `hasProvider` is a credential-PRESENCE thunk (two * narrow reads, no decrypt, no network) evaluated only on the diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index f02b0d0e7..93347dfcc 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -400,7 +400,6 @@ export const USER_RESET = { // Derived AI output cached on the row insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, insightsSnapshotHash: null, diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index fc61a2cbe..b647ccbf4 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -536,6 +536,38 @@ describe("backup round trip — export, wire schema, restore", () => { expect(res.status).toBe(200); }); + // Files written before v1.44.0 can name the readable insights cache the + // release dropped (migration 0430), at the top level or among the account + // settings. The column is gone, so the restore must neither fail on the name + // nor try to write it. + it("restores a file that still names the dropped readable insights cache", async () => { + const { payload } = await buildFullBackupPayload( + sourceClient() as never, + OWNER, + { purpose: "disaster-recovery" }, + ); + const legacy: Record = { + ...payload, + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + accountSettings: { + ...((payload.accountSettings as Record | null) ?? {}), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + }, + }; + + const { res, written } = await roundTrip({}, legacy); + + expect(res.status).toBe(200); + for (const w of written) { + expect( + Object.keys(w.data), + `${w.model}.${w.op} must not write the dropped column`, + ).not.toContain("insightsCachedText"); + } + // The rest of the file still lands. + expect(written.some((w) => w.model === "userHealthProfile")).toBe(true); + }); + it("writes the durable self-context back with its ciphertext intact", async () => { const { written } = await roundTrip(); @@ -785,7 +817,7 @@ describe("backup round trip — export, wire schema, restore", () => { (w) => w.model === "user" && w.op === "update" && - "insightsCachedText" in w.data, + "insightsCachedTextEncrypted" in w.data, ); expect( userUpdate, @@ -794,7 +826,6 @@ describe("backup round trip — export, wire schema, restore", () => { // The language tag is cleared with the text: an empty slot carries // no language, and the next write tags it afresh. expect(userUpdate!.data).toEqual({ - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedAt: null, insightsCachedLocale: null, @@ -831,7 +862,7 @@ describe("backup round trip — export, wire schema, restore", () => { (w) => w.model === "user" && w.op === "update" && - "insightsCachedText" in w.data, + "insightsCachedTextEncrypted" in w.data, ); expect( userUpdate, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index b3c9ddef3..1d517711b 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -948,7 +948,6 @@ export const USER_COLUMN_BACKUP_CLASS = { // ── Operational ─────────────────────────────────────────────────────────── insightsCachedAt: "OPERATIONAL", - insightsCachedText: "OPERATIONAL", insightsCachedTextEncrypted: "OPERATIONAL", insightsCachedLocale: "OPERATIONAL", insightsSnapshotHash: "OPERATIONAL", diff --git a/src/lib/insights/__tests__/cached-text-cipher.test.ts b/src/lib/insights/__tests__/cached-text-cipher.test.ts index 8fcd71142..4aa64f6c0 100644 --- a/src/lib/insights/__tests__/cached-text-cipher.test.ts +++ b/src/lib/insights/__tests__/cached-text-cipher.test.ts @@ -23,50 +23,27 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()); describe("cached insights payload at rest", () => { - it("writes ciphertext and nulls the readable column in the same write", () => { + it("writes ciphertext only", () => { const data = insightsCacheWrite(PAYLOAD); - expect(data.insightsCachedText).toBeNull(); + expect(Object.keys(data)).toEqual(["insightsCachedTextEncrypted"]); expect( Buffer.from(data.insightsCachedTextEncrypted).toString("utf8"), ).not.toContain("Sleep held"); expect(openInsightsCachedText(data)).toBe(PAYLOAD); }); - it("falls back to the readable column for a row the backfill has not reached", () => { + it("reads a sealed value", () => { expect( openInsightsCachedText({ - insightsCachedText: PAYLOAD, - insightsCachedTextEncrypted: null, - }), - ).toBe(PAYLOAD); - }); - - // This used to prefer the readable column, the one sealed column read - // that way. Every sealed column now reads the ciphertext first. - it("reads a sealed value before a readable one beside it", () => { - expect( - openInsightsCachedText({ - insightsCachedText: "readable", insightsCachedTextEncrypted: sealInsightsCachedText("sealed"), }), ).toBe("sealed"); }); - it("never serves the readable value in place of a sealed one that does not open", () => { - const foreign = encryptBytes(Buffer.from(PAYLOAD, "utf8"), "other/v1"); - expect( - openInsightsCachedText({ - insightsCachedText: "readable", - insightsCachedTextEncrypted: new Uint8Array(foreign), - }), - ).toBeNull(); - }); - it("does not open a value sealed for another purpose", () => { const foreign = encryptBytes(Buffer.from(PAYLOAD, "utf8"), "other/v1"); expect( openInsightsCachedText({ - insightsCachedText: null, insightsCachedTextEncrypted: new Uint8Array(foreign), }), ).toBeNull(); @@ -75,6 +52,9 @@ describe("cached insights payload at rest", () => { it("reads an empty row as no cache", () => { expect(openInsightsCachedText(null)).toBeNull(); expect(openInsightsCachedText({ ...INSIGHTS_CACHE_CLEARED })).toBeNull(); + expect( + openInsightsCachedText({ insightsCachedTextEncrypted: new Uint8Array() }), + ).toBeNull(); }); it("is fail-closed on write: no key, no payload", () => { @@ -82,13 +62,11 @@ describe("cached insights payload at rest", () => { expect(() => insightsCacheWrite(PAYLOAD)).toThrow(); }); - it("clears and selects both columns", () => { + it("clears and selects the sealed column", () => { expect(INSIGHTS_CACHE_CLEARED).toEqual({ - insightsCachedText: null, insightsCachedTextEncrypted: null, }); - expect(Object.keys(INSIGHTS_CACHE_SELECT).sort()).toEqual([ - "insightsCachedText", + expect(Object.keys(INSIGHTS_CACHE_SELECT)).toEqual([ "insightsCachedTextEncrypted", ]); }); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 898355e11..17c106731 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -92,7 +92,10 @@ vi.mock("@/lib/cache/invalidate", () => ({ })); import { generateComprehensiveInsight } from "../comprehensive-generate"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -148,7 +151,7 @@ function cachedUser(over: Record = {}) { return { insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], // Never equal to the freshly computed hash, so a full generation runs. insightsSnapshotHash: null, @@ -189,7 +192,7 @@ describe("briefing grounding — disposal", () => { findUnique.mockResolvedValue( cachedUser({ insightsCachedAt: new Date("2026-07-15T03:00:00.000Z"), - insightsCachedText: PREVIOUS_PAYLOAD, + insightsCachedTextEncrypted: sealInsightsCachedText(PREVIOUS_PAYLOAD), }), ); // Both passes carry the same fabricated figure: a content failure. diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 417cc94f1..00ce1b374 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -98,7 +98,10 @@ import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { featuresInReaderUnits } from "../features-units"; import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -143,7 +146,7 @@ describe("daily-briefing re-roll on unchanged data", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, insightsBriefingRerollDate: null, // never re-rolled today @@ -198,7 +201,7 @@ describe("daily-briefing re-roll on unchanged data", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, insightsBriefingRerollDate: todayKey, // already re-rolled today @@ -214,7 +217,7 @@ describe("daily-briefing re-roll on unchanged data", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 1ab2e37d8..e2b135eb3 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -150,7 +150,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 5519de2cd..3878245df 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -96,7 +96,10 @@ import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { featuresInReaderUnits } from "../features-units"; import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -143,7 +146,7 @@ describe("generateComprehensiveInsight — the briefing capability at the wire", const STALE_USER = { insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, }; @@ -181,7 +184,7 @@ describe("generateComprehensiveInsight — the briefing capability at the wire", findUnique.mockResolvedValue({ ...STALE_USER, insightsCachedAt: new Date(Date.now() - 60 * 60 * 1000), - insightsCachedText: "{}", + insightsCachedTextEncrypted: sealInsightsCachedText("{}"), }); aiCapabilityForRecord.mockResolvedValue({ available: false, @@ -201,7 +204,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), - insightsCachedText: "{}", + insightsCachedTextEncrypted: sealInsightsCachedText("{}"), insightsExcludeMetrics: [], insightsSnapshotHash: null, }); @@ -220,7 +223,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { insightsPrivacyMode: "aggregated", // 21h old — inside the 24h TTL, so the un-forced path would cache. insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), - insightsCachedText: "{}", + insightsCachedTextEncrypted: sealInsightsCachedText("{}"), insightsExcludeMetrics: [], insightsSnapshotHash: null, }); @@ -251,7 +254,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, }); @@ -280,7 +285,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], // Stored fingerprint from an older data state. insightsSnapshotHash: "0".repeat(64), @@ -329,7 +336,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, unitPreference: "imperial", @@ -383,7 +392,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 1 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "english" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "english" } }), + ), insightsCachedLocale: "en", insightsExcludeMetrics: [], insightsSnapshotHash: null, @@ -403,7 +414,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: null, }); @@ -436,10 +449,12 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ - dailyBriefing: null, - recommendations: [], - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: null, + recommendations: [], + }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, }); @@ -475,7 +490,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, }); @@ -522,7 +537,9 @@ describe("generateComprehensiveInsight — usage level", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, aiUsageLevel, diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index dca1bcfe2..3a6f0e78f 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -110,7 +110,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts b/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts index 4d6025b50..97adb76f7 100644 --- a/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts +++ b/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts @@ -116,7 +116,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts b/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts index 98c1501e7..eaf83631b 100644 --- a/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts +++ b/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts @@ -114,7 +114,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 12bca2b0b..87fbb55b1 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -97,6 +97,7 @@ vi.mock("@/lib/logging/context", async (importOriginal) => { import { generateComprehensiveInsight } from "../comprehensive-generate"; import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -151,7 +152,9 @@ describe("generateComprehensiveInsight — consent withdrawn in flight", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: "0".repeat(64), }); @@ -191,7 +194,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: "0".repeat(64), // changed → full generation }); @@ -236,7 +241,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: ["sleep"], insightsSnapshotHash: "0".repeat(64), gender: "FEMALE", @@ -280,7 +287,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: ["sleep"], insightsSnapshotHash: "0".repeat(64), gender: "FEMALE", @@ -322,7 +331,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: "0".repeat(64), }); @@ -361,7 +372,7 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED_WITH_BRIEFING, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED_WITH_BRIEFING), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, // unchanged → reroll path insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index fb77da31c..39307a17c 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -108,7 +108,7 @@ function mockUser(overrides: Record = {}) { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, @@ -165,7 +165,7 @@ describe("comprehensive timeout honouring", () => { const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); expect(outcome.status).toBe("failed"); - // No cache row written → the prior `insightsCachedText` (last good) survives. + // No cache row written → the prior `insightsCachedTextEncrypted` (last good) survives. expect(userUpdate).not.toHaveBeenCalled(); // A dated failure marker is recorded so the read path can surface a hint. expect(recordBriefingFailure).toHaveBeenCalledWith( diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index b1f089d00..ef7cd05ad 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -129,7 +129,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts index 025a3abe4..b3a62c25d 100644 --- a/src/lib/insights/briefing-failure-marker.ts +++ b/src/lib/insights/briefing-failure-marker.ts @@ -4,7 +4,7 @@ import { prisma } from "@/lib/db"; * Dated failure marker for the daily-briefing generation. * * The briefing keeps its last good payload on a failed refresh: the generator - * writes NO `User.insightsCachedText` row when a provider times out or errors, + * writes NO `User.insightsCachedTextEncrypted` row when a provider times out or errors, * so the previous briefing stays intact and the surface never blanks. What was * missing is a signal the READ path can use to tell the difference between "no * refresh was due" and "the last refresh attempt failed" — without that, a diff --git a/src/lib/insights/cached-text-cipher.ts b/src/lib/insights/cached-text-cipher.ts index c74cbfcb0..90d154f18 100644 --- a/src/lib/insights/cached-text-cipher.ts +++ b/src/lib/insights/cached-text-cipher.ts @@ -1,35 +1,30 @@ /** * The cached AI insights payload, sealed at rest. * - * `User.insightsCachedText` held the model's last full reading of the account - * in the clear: the daily briefing, the per-domain status text and the - * recommendations, every one of them a sentence about the person's health. - * It was the last readable column of that kind on the user row. Since v1.43.7 - * the payload lives in `insightsCachedTextEncrypted` (AES-256-GCM, binary - * layout, purpose-bound by {@link INSIGHTS_CACHED_TEXT_AAD}), and the free-text - * encryption backfill seals the rows written before. + * The model's last full reading of the account (the daily briefing, the + * per-domain status text and the recommendations, every one of them a + * sentence about the person's health) lives in `insightsCachedTextEncrypted` + * (AES-256-GCM, binary layout, purpose-bound by + * {@link INSIGHTS_CACHED_TEXT_AAD}). The readable column it replaced in + * v1.43.7 was dropped in v1.44.0 (migration 0430). * - * Every reader and writer goes through this module, which is what keeps the - * two columns in step. A clear that nulls one column and not the other would - * leave the briefing behind after a consent withdrawal, so the clearing value - * is exported here and nowhere else spells the column names + * Every reader and writer goes through this module, so the column name is + * spelled here and in the classification tables only * (`insights-cached-text-access-guard.test.ts`). */ import { Buffer } from "node:buffer"; import { decryptBytes, encryptBytes } from "@/lib/crypto"; import { INSIGHTS_CACHED_TEXT_AAD } from "@/lib/crypto/encrypted-columns"; -import { readSealedOrLegacy, warnAndDrop } from "@/lib/crypto/sealed-or-legacy"; +import { warnAndDrop } from "@/lib/crypto/sealed-or-legacy"; /** The columns a reader selects to open the cache. */ export const INSIGHTS_CACHE_SELECT = { - insightsCachedText: true, insightsCachedTextEncrypted: true, } as const; /** The row shape {@link openInsightsCachedText} reads. */ export interface InsightsCacheColumns { - insightsCachedText?: string | null; insightsCachedTextEncrypted?: Uint8Array | null; } @@ -44,47 +39,28 @@ export function sealInsightsCachedText(text: string): Uint8Array { return out; } -/** - * The `data` fields that store a payload: the ciphertext, and the legacy - * readable column nulled in the same write so a row never holds both a new - * sealed payload and an older readable one. - */ +/** The `data` fields that store a payload. */ export function insightsCacheWrite(text: string): { insightsCachedTextEncrypted: Uint8Array; - insightsCachedText: null; } { - return { - insightsCachedTextEncrypted: sealInsightsCachedText(text), - insightsCachedText: null, - }; + return { insightsCachedTextEncrypted: sealInsightsCachedText(text) }; } -/** The `data` fields that empty the cache. Both columns, always. */ +/** The `data` fields that empty the cache. */ export const INSIGHTS_CACHE_CLEARED = { - insightsCachedText: null, insightsCachedTextEncrypted: null, } as const; -/** A `where` that finds rows holding a cached payload in either column. */ +/** A `where` that finds rows holding a cached payload. */ export const INSIGHTS_CACHE_PRESENT_WHERE = { - OR: [ - { insightsCachedText: { not: null } }, - { insightsCachedTextEncrypted: { not: null } }, - ], + insightsCachedTextEncrypted: { not: null }, } as const; +const dropUndecryptable = warnAndDrop("insights cache did not open"); + /** * The cached payload as text, or null when there is none. * - * The ciphertext is read first, as every sealed column is read - * (`readSealedOrLegacy`); the readable column only when the row holds no - * ciphertext. A row holding both can come from a writer of the previous - * release still running after the upgrade; its readable value may be the - * newer one, and the free-text backfill seals it over the old ciphertext on - * its next pass. Until then the reader serves the sealed one: an older - * briefing for a while, never a readable value preferred over a protected - * one. - * * A sealed value that does not open reads as an empty cache with a warning on * the event. It is a cache: the next warm overwrites it under the active key, * whereas failing the read would take the dashboard down with it. @@ -92,14 +68,13 @@ export const INSIGHTS_CACHE_PRESENT_WHERE = { export function openInsightsCachedText( row: InsightsCacheColumns | null | undefined, ): string | null { - if (!row) return null; - return readSealedOrLegacy({ - sealed: row.insightsCachedTextEncrypted, - legacy: row.insightsCachedText, - open: (sealed) => - decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString( - "utf8", - ), - onUndecryptable: warnAndDrop("insights cache did not open"), - }); + const sealed = row?.insightsCachedTextEncrypted; + if (!sealed || sealed.byteLength === 0) return null; + try { + return decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString( + "utf8", + ); + } catch (error) { + return dropUndecryptable(error); + } } diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 3b1036474..67a612a62 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -1279,7 +1279,7 @@ export async function generateComprehensiveInsight( }); // v1.25 — record a dated failure marker so the read path can surface a // discreet "couldn't refresh" hint alongside the preserved last-good - // briefing. No cache row is written here, so `insightsCachedText` (the + // briefing. No cache row is written here, so `insightsCachedTextEncrypted` (the // last good payload) stays intact and the surface never blanks. void recordBriefingFailure({ userId, diff --git a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts index 06057a5c8..5bc6abd92 100644 --- a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts @@ -5,10 +5,6 @@ import { decryptRouteGeometry, encryptRouteGeometry, } from "@/lib/workouts/route-geometry-cipher"; -import { - openInsightsCachedText, - sealInsightsCachedText, -} from "@/lib/insights/cached-text-cipher"; // ── in-memory store backing a minimal prisma mock ────────────────────────── interface ConversationRow { @@ -33,14 +29,6 @@ interface PractitionerRow { locationEncrypted: Uint8Array | null; updatedAt?: Date; } -interface UserRow { - id: string; - /** The row is its own owner; mirrors the other delegates' owner field. */ - userId: string; - insightsCachedText: string | null; - insightsCachedTextEncrypted: Uint8Array | null; - updatedAt?: Date; -} interface RouteRow { id: string; /** The owning workout's user; the real table reaches it through the join. */ @@ -54,7 +42,6 @@ const store = vi.hoisted(() => ({ entries: [] as EntryRow[], practitioners: [] as PractitionerRow[], routes: [] as RouteRow[], - users: [] as UserRow[], })); vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); @@ -131,33 +118,6 @@ vi.mock("@/lib/db", () => { customMetricEntry: delegate(() => store.entries, ["note"]), practitioner: delegate(() => store.practitioners, ["phone", "location"]), workoutRoute: delegate(() => store.routes, ["geometry"]), - user: (() => { - const base = delegate(() => store.users, ["insightsCachedText"]); - return { - ...base, - // The insights cache move also guards on the readable value it read, - // so a warm that sealed a newer payload meanwhile is not overwritten. - updateMany: async (args: { - where: { id: string; updatedAt?: Date; insightsCachedText?: string }; - data: Partial; - }) => { - const row = store.users.find((x) => x.id === args.where.id); - if ( - row && - args.where.insightsCachedText !== undefined && - row.insightsCachedText !== args.where.insightsCachedText - ) { - return { count: 0 }; - } - // Prisma stamps `@updatedAt` on every write that does not set it. - const data = - "updatedAt" in args.data - ? args.data - : { ...args.data, updatedAt: new Date() }; - return base.updateMany({ ...args, data }); - }, - }; - })(), }; return { prisma: { @@ -178,9 +138,6 @@ import { } from "@/lib/jobs/free-text-encryption-backfill"; const KEY = "a".repeat(64); -const CACHE = JSON.stringify({ - dailyBriefing: { paragraph: "Your pressure eased after the new tablets." }, -}); const TRACK = { type: "LineString", coordinates: [ @@ -268,21 +225,6 @@ beforeEach(() => { }, { id: "r3", userId: "u2", geometry: TRACK, geometryEncrypted: null }, ]; - store.users = [ - { - id: "u1", - userId: "u1", - insightsCachedText: CACHE, - insightsCachedTextEncrypted: null, - updatedAt: new Date("2026-03-01T07:00:00.000Z"), - }, - { - id: "u2", - userId: "u2", - insightsCachedText: "other user", - insightsCachedTextEncrypted: null, - }, - ]; }); afterEach(() => { @@ -300,7 +242,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 1, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 1, }); const c1 = store.conversations.find((r) => r.id === "c1")!; @@ -404,52 +345,12 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe( sealed, ); }); - it("seals the cached insights payload, keeps updatedAt, and leaves another account's alone", async () => { - await runFreeTextEncryptionBackfillForUser("u1"); - const u1 = store.users.find((r) => r.id === "u1")!; - expect(u1.insightsCachedText).toBeNull(); - expect(u1.insightsCachedTextEncrypted).not.toBeNull(); - // Sealed, not merely encoded: the bytes do not carry the prose. - expect( - Buffer.from(u1.insightsCachedTextEncrypted!).toString("utf8"), - ).not.toContain("pressure"); - expect(openInsightsCachedText(u1)).toBe(CACHE); - // Profile forms use updatedAt as their concurrency token; a storage move - // must not invalidate an open form. - expect(u1.updatedAt).toEqual(new Date("2026-03-01T07:00:00.000Z")); - expect(store.users.find((r) => r.id === "u2")).toMatchObject({ - insightsCachedText: "other user", - insightsCachedTextEncrypted: null, - }); - }); - - it("does not overwrite a payload a warm sealed while the move was reading", async () => { - const newer = sealInsightsCachedText("newer"); - const u1 = store.users.find((r) => r.id === "u1")!; - // The warm lands between the backfill's read and its write: the read - // returns the old readable value, the row then holds the newer sealed one. - const { prisma } = await import("@/lib/db"); - const snapshot = { ...u1 }; - const spy = vi.spyOn(prisma.user, "findUnique").mockImplementationOnce((( - _args: unknown, - ) => { - u1.insightsCachedText = null; - u1.insightsCachedTextEncrypted = newer; - return Promise.resolve(snapshot); - }) as never); - await runFreeTextEncryptionBackfillForUser("u1"); - spy.mockRestore(); - expect(u1.insightsCachedTextEncrypted).toBe(newer); - expect(openInsightsCachedText(u1)).toBe("newer"); - }); - it("is fail-closed: without a key every readable row stays intact", async () => { vi.stubEnv("ENCRYPTION_KEY", ""); vi.stubEnv("ENCRYPTION_KEYS", ""); diff --git a/src/lib/jobs/free-text-encryption-backfill.ts b/src/lib/jobs/free-text-encryption-backfill.ts index 024861185..f7e7317d3 100644 --- a/src/lib/jobs/free-text-encryption-backfill.ts +++ b/src/lib/jobs/free-text-encryption-backfill.ts @@ -22,9 +22,9 @@ * column holds a JSON null rather than SQL NULL has nothing to seal; it is set * to SQL NULL so discovery stops finding it. * - * v1.43.7 adds the cached AI insights payload on the user row: - * `User.insightsCachedText` -> `insightsCachedTextEncrypted` (the model's - * briefing, per-domain readings and recommendations about the account). + * v1.43.7 sealed the cached AI insights payload on the user row the same + * way; v1.44.0 dropped its readable column (migration 0430), so that part of + * the pass is gone too. * * v1.42 (#615) added the environment module's coarse locations (home, dated * location periods, stored days). v1.43 dropped their readable columns @@ -71,7 +71,6 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; -import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE = "free-text-encryption-backfill"; @@ -99,8 +98,6 @@ export interface FreeTextEncryptionBackfillSummary { appointmentAddressesCleared: number; /** v1.39.4 — edit audit rows whose old phone number / address was removed. */ contactAuditRowsScrubbed: number; - /** v1.43.7 — cached AI insights payloads sealed (0 or 1 per account). */ - insightsCachesMigrated: number; } /** The audit action whose older rows carried contact values. */ @@ -298,42 +295,6 @@ async function migrateRouteGeometry(id: string): Promise { }); } -/** - * Seal one account's cached AI insights payload into - * `insightsCachedTextEncrypted` and null the readable column, atomically and - * idempotently. The update is guarded on the readable value it read: a warm - * that lands meanwhile writes the ciphertext and nulls the readable column, so - * the guard misses and the newer payload is left as it is. - */ -async function migrateInsightsCache(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.user.findUnique({ - where: { id }, - select: { insightsCachedText: true, updatedAt: true }, - }); - if (!fresh || fresh.insightsCachedText === null) return false; - // FAIL-CLOSED: a key error throws here and rolls the tx back. A cache - // move is storage, not activity, so `updatedAt` is carried: the profile - // forms use it as their optimistic-concurrency token. Matching on it too - // means an edit that lands meanwhile wins; the row is retried next pass. - const { count } = await tx.user.updateMany({ - where: { - id, - updatedAt: fresh.updatedAt, - insightsCachedText: fresh.insightsCachedText, - }, - data: { - insightsCachedTextEncrypted: sealInsightsCachedText( - fresh.insightsCachedText, - ), - insightsCachedText: null, - updatedAt: fresh.updatedAt, - }, - }); - return count === 1; - }); -} - /** * Walk one kind of row page by page, migrating each. `page` returns the ids * still holding a readable value; each migration clears it, so a migrated row @@ -428,18 +389,6 @@ export async function runFreeTextEncryptionBackfillForUser( once, ); - // One row at most: the cache is a single slot on the user row. - const insightsCachesMigrated = await drain( - () => - prisma.user.findMany({ - where: { id: userId, insightsCachedText: { not: null } }, - select: { id: true }, - take: 1, - }), - migrateInsightsCache, - once, - ); - // An appointment reminder's address is resolved from the practitioner when // it fires; the readable copy earlier releases wrote here goes. const appointmentAddressesCleared = ( @@ -463,7 +412,6 @@ export async function runFreeTextEncryptionBackfillForUser( route_geometries_migrated: routeGeometriesMigrated, appointment_addresses_cleared: appointmentAddressesCleared, contact_audit_rows_scrubbed: contactAuditRowsScrubbed, - insights_caches_migrated: insightsCachesMigrated, }, }, }); @@ -475,7 +423,6 @@ export async function runFreeTextEncryptionBackfillForUser( routeGeometriesMigrated, appointmentAddressesCleared, contactAuditRowsScrubbed, - insightsCachesMigrated, }; } @@ -530,10 +477,6 @@ export async function enqueueBootTimeFreeTextEncryptionBackfill( WHERE action = ${CONTACT_AUDIT_ACTION} AND user_id IS NOT NULL AND (details LIKE '%"location":%' OR details LIKE '%"phone":%')`, - // The 0411 partial index matches this predicate. - prisma.$queryRaw<{ user_id: string }[]>` - SELECT id AS user_id FROM users - WHERE insights_cached_text IS NOT NULL`, ]); const userIds = new Set(); diff --git a/src/lib/profile/__tests__/health-facts.test.ts b/src/lib/profile/__tests__/health-facts.test.ts index 8fb2c0a0b..2e3baf688 100644 --- a/src/lib/profile/__tests__/health-facts.test.ts +++ b/src/lib/profile/__tests__/health-facts.test.ts @@ -93,7 +93,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, @@ -133,7 +132,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, @@ -199,7 +197,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, @@ -247,7 +244,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, diff --git a/tests/integration/ai-optional-actions.test.ts b/tests/integration/ai-optional-actions.test.ts index 5a29d565a..255b4467a 100644 --- a/tests/integration/ai-optional-actions.test.ts +++ b/tests/integration/ai-optional-actions.test.ts @@ -24,6 +24,7 @@ import { type StateName, } from "./ai-optional-fixtures"; import { getPrismaClient } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -172,9 +173,11 @@ describe("insights/generate refuses by the briefing capability", () => { data: { // The read resolves (never calls) the chain, so the key must decrypt. aiAnthropicKeyEncrypted: encrypt("sk-ant-integration"), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "Held text.", keyFindings: [] }, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "Held text.", keyFindings: [] }, + }), + ), insightsCachedAt: new Date(), insightsCachedLocale: "en", }, diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts index 4529cae12..2475a1ab5 100644 --- a/tests/integration/ai-optional-mixed-routes.test.ts +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -23,6 +23,7 @@ import { type StateName, } from "./ai-optional-fixtures"; import { getPrismaClient } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -94,10 +95,12 @@ async function seed(recordId: string): Promise { where: { id: recordId }, data: { // Generated now: only text written today is served as today's read. - insightsCachedText: JSON.stringify({ - ...BRIEFING, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + ...BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), + ), insightsCachedAt: new Date(), insightsCachedLocale: "en", }, diff --git a/tests/integration/briefing-cache-locale.test.ts b/tests/integration/briefing-cache-locale.test.ts index 0469710b9..8589571c3 100644 --- a/tests/integration/briefing-cache-locale.test.ts +++ b/tests/integration/briefing-cache-locale.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -67,10 +68,12 @@ async function seedUserWithCachedBriefing(cachedLocale: string | null) { locale: "de", insightsCachedAt: new Date(), // Generated now: only text written today is served as today's read. - insightsCachedText: JSON.stringify({ - dailyBriefing: BRIEFING, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), + ), insightsCachedLocale: cachedLocale, // The briefing is shown only while its AI capability is available, so // the account has its own provider (presence only) and a receipt. diff --git a/tests/integration/consent-withdrawal-purge.test.ts b/tests/integration/consent-withdrawal-purge.test.ts index e80e75790..e18ea479c 100644 --- a/tests/integration/consent-withdrawal-purge.test.ts +++ b/tests/integration/consent-withdrawal-purge.test.ts @@ -20,6 +20,7 @@ import { purgeRegenerableAiText, withdrawConsent, } from "@/lib/consent/withdrawal"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; const BYTES = Buffer.from("ciphertext-stand-in", "utf8"); @@ -33,7 +34,9 @@ async function seed(username: string, kinds: string[]) { data: { username, email: `${username}@example.test`, - insightsCachedText: JSON.stringify({ briefing: "model prose" }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ briefing: "model prose" }), + ), insightsCachedAt: new Date(), insightsSnapshotHash: "hash", }, @@ -153,7 +156,7 @@ async function snapshot(userId: string) { prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { - insightsCachedText: true, + insightsCachedTextEncrypted: true, insightsCachedAt: true, insightsSnapshotHash: true, }, @@ -207,7 +210,7 @@ describe("withdrawing the analysis consent", () => { const after = await snapshot(user.id); expect(after.user).toEqual({ - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsCachedAt: null, insightsSnapshotHash: null, }); diff --git a/tests/integration/free-text-encryption-backfill.test.ts b/tests/integration/free-text-encryption-backfill.test.ts index d4743a457..beb3ef479 100644 --- a/tests/integration/free-text-encryption-backfill.test.ts +++ b/tests/integration/free-text-encryption-backfill.test.ts @@ -30,6 +30,7 @@ import { runFreeTextEncryptionBackfillForUser, } from "@/lib/jobs/free-text-encryption-backfill"; import { + INSIGHTS_CACHE_SELECT, insightsCacheWrite, openInsightsCachedText, } from "@/lib/insights/cached-text-cipher"; @@ -498,73 +499,31 @@ describe("free-text encryption backfill (real Postgres)", () => { expect(second.sent).toEqual([]); }); - it("seals the cached insights payload, keeps updatedAt, and converges", async () => { + it("has no readable insights cache left after migration 0430", async () => { const prisma = getPrismaClient(); - await Promise.all([seedUser("ic-a"), seedUser("ic-b"), seedUser("ic-c")]); - const payload = JSON.stringify({ - dailyBriefing: { paragraph: "Pressure eased this week." }, - }); - // ic-a: written by the previous release, in the clear. + const columns = await prisma.$queryRaw<{ column_name: string }[]>` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'users' AND column_name LIKE 'insights_cached_text%' + ORDER BY column_name`; + expect(columns.map((c) => c.column_name)).toEqual([ + "insights_cached_text_encrypted", + ]); + const indexes = await prisma.$queryRaw<{ indexname: string }[]>` + SELECT indexname FROM pg_indexes + WHERE indexname = 'users_insights_cached_text_backfill_idx'`; + expect(indexes).toEqual([]); + + // The sealed column still round-trips through the one module. + await seedUser("ic-a"); await prisma.user.update({ where: { id: "ic-a" }, - data: { insightsCachedText: payload }, - }); - // ic-b: written the current way. - await prisma.user.update({ - where: { id: "ic-b" }, data: insightsCacheWrite("current"), }); - const before = await prisma.user.findUniqueOrThrow({ + const row = await prisma.user.findUniqueOrThrow({ where: { id: "ic-a" }, - select: { updatedAt: true }, + select: INSIGHTS_CACHE_SELECT, }); - - const { boss, sent } = makeCapturingBoss(); - setGlobalBoss(boss as never); - const discovered = await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(discovered.error).toBeNull(); - expect(sent.map((s) => s.userId)).toEqual(["ic-a"]); - - const summary = await runFreeTextEncryptionBackfillForUser("ic-a"); - expect(summary.insightsCachesMigrated).toBe(1); - - const rowA = await prisma.user.findUniqueOrThrow({ - where: { id: "ic-a" }, - select: { - insightsCachedText: true, - insightsCachedTextEncrypted: true, - updatedAt: true, - }, - }); - expect(rowA.insightsCachedText).toBeNull(); - expect(rowA.insightsCachedTextEncrypted).not.toBeNull(); - expect(openInsightsCachedText(rowA)).toBe(payload); - expect(rowA.updatedAt).toEqual(before.updatedAt); - - const rowB = await prisma.user.findUniqueOrThrow({ - where: { id: "ic-b" }, - select: { insightsCachedText: true, insightsCachedTextEncrypted: true }, - }); - expect(openInsightsCachedText(rowB)).toBe("current"); - - const again = await runFreeTextEncryptionBackfillForUser("ic-a"); - expect(again.insightsCachesMigrated).toBe(0); - const second = makeCapturingBoss(); - setGlobalBoss(second.boss as never); - await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(second.sent).toEqual([]); - }); - - it("has the partial index migration 0411 adds", async () => { - const indexes = await getPrismaClient().$queryRaw< - { indexname: string; indexdef: string }[] - >` - SELECT indexname, indexdef FROM pg_indexes - WHERE indexname = 'users_insights_cached_text_backfill_idx'`; - expect(indexes).toHaveLength(1); - expect(indexes[0].indexdef).toContain( - "WHERE (insights_cached_text IS NOT NULL)", - ); + expect(openInsightsCachedText(row)).toBe("current"); }); it("has the partial indexes migration 0361 adds", async () => { diff --git a/tests/integration/insights-provider-chain.test.ts b/tests/integration/insights-provider-chain.test.ts index aff86654d..017d27094 100644 --- a/tests/integration/insights-provider-chain.test.ts +++ b/tests/integration/insights-provider-chain.test.ts @@ -288,7 +288,7 @@ describe("POST /api/insights/generate — chain fallback", () => { const prisma = getPrismaClient(); await prisma.user.update({ where: { username: "chain-user" }, - data: { insightsCachedAt: null, insightsCachedText: null }, + data: { insightsCachedAt: null, insightsCachedTextEncrypted: null }, }); // Second call: cache should reorder so Anthropic is the first diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 04ebedfe0..16f1a0d28 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -33,6 +33,7 @@ import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -292,7 +293,9 @@ frontDoorRead("GET /api/daily/digest", { await getPrismaClient().user.update({ where: { id: userId }, data: { - insightsCachedText: cachedBriefing(marker), + insightsCachedTextEncrypted: sealInsightsCachedText( + cachedBriefing(marker), + ), insightsCachedAt: new Date(), ...ownProvider(), }, From 7585994697ee41be1a77ddccd1291c7f4b1b1aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:35:47 +0200 Subject: [PATCH 12/98] feat(oura): import workouts and intraday heart rate Workouts from /v2/usercollection/workout land as Workout rows keyed on the Oura id, with the activity mapped onto the canonical sport set and the raw label, intensity and recording source kept in metadata. Heart rate from /v2/usercollection/heartrate lands as PULSE, streamed page by page and written in bounded batches, first write wins per instant. The hourly window for heart rate is hours, not days: six by default, reaching back to the newest reading after a gap, at most 72. Both collections sit behind their own scopes (workout, heartrate). A grant from before they were requested answers 401 naming the scope; that is recorded as a scope note on the card, never as a failure or a reconnect. Collection refusals carry Retry-After on a 429. --- docs/api/openapi.yaml | 2 + messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/nl.json | 4 +- messages/pl.json | 4 +- ...asurement-write-plausibility-guard.test.ts | 1 + .../measurement-write-tail-guard.test.ts | 2 + .../workout-sport-provenance-guard.test.ts | 9 + .../integrations/integration-health-copy.ts | 4 + src/lib/oura/__tests__/client.test.ts | 4 +- src/lib/oura/__tests__/scope.test.ts | 13 +- src/lib/oura/__tests__/sync.test.ts | 67 +++- .../__tests__/workouts-heart-rate.test.ts | 298 +++++++++++++++ src/lib/oura/client.ts | 212 +++++++++-- src/lib/oura/response-classifier.ts | 10 +- src/lib/oura/scope.ts | 6 + src/lib/oura/sport-map.ts | 133 +++++++ src/lib/oura/sync-heart-rate.ts | 200 ++++++++++ src/lib/oura/sync-workouts.ts | 150 ++++++++ src/lib/oura/sync.ts | 348 +++++++++++------- 24 files changed, 1312 insertions(+), 179 deletions(-) create mode 100644 src/lib/oura/__tests__/workouts-heart-rate.test.ts create mode 100644 src/lib/oura/sport-map.ts create mode 100644 src/lib/oura/sync-heart-rate.ts create mode 100644 src/lib/oura/sync-workouts.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..67e285bea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -79471,6 +79471,8 @@ components: - vo2max - cardio_age - resilience + - workout + - heartrate hasToken: description: Nightscout only. type: boolean diff --git a/messages/de.json b/messages/de.json index b8afe311c..da1c237f4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "VO2max", "cardio_age": "kardiovaskuläres Alter", - "resilience": "Resilienz" + "resilience": "Resilienz", + "workout": "Trainings", + "heartrate": "Herzfrequenz" } } } diff --git a/messages/en.json b/messages/en.json index 8ec8c0e61..6f93d58f4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "VO2 max", "cardio_age": "cardiovascular age", - "resilience": "resilience" + "resilience": "resilience", + "workout": "workouts", + "heartrate": "heart rate" } } } diff --git a/messages/es.json b/messages/es.json index 80928dc2d..03a2bf58a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "VO2 máx.", "cardio_age": "edad cardiovascular", - "resilience": "resiliencia" + "resilience": "resiliencia", + "workout": "entrenamientos", + "heartrate": "frecuencia cardiaca" } } } diff --git a/messages/fr.json b/messages/fr.json index 4f7058a57..1cecfdc1c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "la VO2 max", "cardio_age": "l’âge cardiovasculaire", - "resilience": "la résilience" + "resilience": "la résilience", + "workout": "les entraînements", + "heartrate": "la fréquence cardiaque" } } } diff --git a/messages/it.json b/messages/it.json index a1dc08274..bd128a3b9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "VO2 max", "cardio_age": "età cardiovascolare", - "resilience": "resilienza" + "resilience": "resilienza", + "workout": "allenamenti", + "heartrate": "frequenza cardiaca" } } } diff --git a/messages/ko.json b/messages/ko.json index 8712f3f1a..240a2d143 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "VO2 max", "cardio_age": "심혈관 나이", - "resilience": "회복탄력성" + "resilience": "회복탄력성", + "workout": "운동", + "heartrate": "심박수" } } } diff --git a/messages/nl.json b/messages/nl.json index dc0c5e5f0..c883d8661 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "VO2 max", "cardio_age": "cardiovasculaire leeftijd", - "resilience": "veerkracht" + "resilience": "veerkracht", + "workout": "trainingen", + "heartrate": "hartslag" } } } diff --git a/messages/pl.json b/messages/pl.json index 625abe2a4..2970b3de5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6665,7 +6665,9 @@ "items": { "vo2max": "VO2 max", "cardio_age": "wiek sercowo-naczyniowy", - "resilience": "odporność" + "resilience": "odporność", + "workout": "treningi", + "heartrate": "tętno" } } } diff --git a/src/__tests__/measurement-write-plausibility-guard.test.ts b/src/__tests__/measurement-write-plausibility-guard.test.ts index 9b3cf83e0..e334df976 100644 --- a/src/__tests__/measurement-write-plausibility-guard.test.ts +++ b/src/__tests__/measurement-write-plausibility-guard.test.ts @@ -62,6 +62,7 @@ const WRITERS: Record = { "lib/fitbit/sync-core.ts": { kind: "gate" }, "lib/google-health/sync-core.ts": { kind: "gate" }, "lib/nightscout/sync.ts": { kind: "gate" }, + "lib/oura/sync-heart-rate.ts": { kind: "gate" }, // The shared identity seam. Polar, Oura, WHOOP, the Withings sleep leg, // the Apple export importer and the batch endpoint all write through it, // so the gate here is what makes a new provider fail closed. diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index da045a6ed..3a72f236a 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -61,6 +61,8 @@ const WRITE_SITES: Record = { "refolds the touched days and re-warms the touched types at the end of the upsert", "lib/nightscout/sync.ts": "refolds the touched days and re-warms the touched types at the end of the sync", + "lib/oura/sync-heart-rate.ts": + "collects the days each heart-rate page touched; refolds them and re-warms the type once per window", "lib/withings/sync.ts": "refolds the touched days and re-warms the touched types at the end of the sync", "lib/withings/sync-activity.ts": diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts index f135279b1..9eda369d3 100644 --- a/src/__tests__/workout-sport-provenance-guard.test.ts +++ b/src/__tests__/workout-sport-provenance-guard.test.ts @@ -116,6 +116,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [ writeFile: "lib/import/health-connect/import.ts", metadataKey: "healthConnectExerciseType", }, + { + provider: "Oura", + mapper: "mapOuraSportType", + mapperFile: "lib/oura/sport-map.ts", + captureFile: "lib/oura/sync-workouts.ts", + captureField: "w.activity", + writeFile: "lib/oura/sync-workouts.ts", + metadataKey: "ouraActivity", + }, { provider: "Fitbit", mapper: "mapFitbitSportType", diff --git a/src/components/settings/integrations/integration-health-copy.ts b/src/components/settings/integrations/integration-health-copy.ts index f9340dd4e..17e8f8cfa 100644 --- a/src/components/settings/integrations/integration-health-copy.ts +++ b/src/components/settings/integrations/integration-health-copy.ts @@ -130,6 +130,10 @@ export function unavailableItemLabel(item: string, t: Translate): string { return t("settings.integrations.oura.scope.items.cardio_age"); case "resilience": return t("settings.integrations.oura.scope.items.resilience"); + case "workout": + return t("settings.integrations.oura.scope.items.workout"); + case "heartrate": + return t("settings.integrations.oura.scope.items.heartrate"); case "activity": return t("settings.integrationCard.items.activity"); case "sleep": diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 7636ffd05..46d800995 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -77,7 +77,9 @@ describe("getAuthorizationUrl", () => { it("builds the Oura authorize URL", () => { const url = getAuthorizationUrl("signed", CREDS); expect(url).toContain("cloud.ouraring.com/oauth/authorize"); - expect(url).toContain("scope=daily+spo2+heart_health+stress+personal"); + expect(url).toContain( + "scope=daily+spo2+heart_health+stress+workout+heartrate+personal", + ); expect(url).toContain("state=signed"); }); }); diff --git a/src/lib/oura/__tests__/scope.test.ts b/src/lib/oura/__tests__/scope.test.ts index caf2bdfab..0df17a1a4 100644 --- a/src/lib/oura/__tests__/scope.test.ts +++ b/src/lib/oura/__tests__/scope.test.ts @@ -30,6 +30,8 @@ describe("Oura scope map", () => { "spo2", "heart_health", "stress", + "workout", + "heartrate", "personal", ]); }); @@ -40,6 +42,8 @@ describe("Oura scope map", () => { "vo2max", "cardio_age", "resilience", + "workout", + "heartrate", ]); expect(ouraScopeReconnectList(["resilience", "readiness", "spo2"])).toEqual( ["spo2", "resilience"], @@ -65,11 +69,18 @@ describe("granted scopes", () => { ouraCollectionsMissingFromGrant( "extapi:daily extapi:personal extapi:spo2 extapi:heart_health", ), - ).toEqual(["resilience"]); + ).toEqual(["resilience", "workout", "heartrate"]); + // A grant from before workouts and heart rate were read: those two are + // the scope note, nothing else. expect( ouraCollectionsMissingFromGrant( "daily personal spo2 heart_health stress", ), + ).toEqual(["workout", "heartrate"]); + expect( + ouraCollectionsMissingFromGrant( + "daily personal spo2 heart_health stress workout heartrate", + ), ).toEqual([]); }); }); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 63616a85d..cd2de619b 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -21,6 +21,8 @@ const { fetchCardioAgeMock, fetchResilienceMock, fetchCyclePhasesMock, + fetchWorkoutsMock, + heartRatePagesMock, refreshMock, createManyAndReturnMock, updateMock, @@ -48,6 +50,8 @@ const { fetchCardioAgeMock: vi.fn(), fetchResilienceMock: vi.fn(), fetchCyclePhasesMock: vi.fn(), + fetchWorkoutsMock: vi.fn(), + heartRatePagesMock: vi.fn(), refreshMock: vi.fn(), createManyAndReturnMock: vi.fn(), updateMock: vi.fn(), @@ -142,6 +146,8 @@ vi.mock("../client", async (importOriginal) => { fetchCardiovascularAge: fetchCardioAgeMock, fetchResilience: fetchResilienceMock, fetchDailyCyclePhases: fetchCyclePhasesMock, + fetchWorkouts: fetchWorkoutsMock, + forEachHeartRatePage: heartRatePagesMock, refreshAccessToken: refreshMock, }; }); @@ -158,6 +164,13 @@ import { isSyncFailureRecorded } from "@/lib/integrations/status"; const DAY_MS = 24 * 60 * 60 * 1000; +/** The day-window watermark reads (the heart-rate window's carries a type). */ +function dayWatermarkReads() { + return findFirstMock.mock.calls.filter( + ([args]) => !(args as { where: { type?: string } }).where.type, + ); +} + const CONN = { accessToken: "acc", refreshTokenCiphertext: "enc(ref)", @@ -180,6 +193,10 @@ beforeEach(() => { fetchCardioAgeMock.mockReset().mockResolvedValue([]); fetchResilienceMock.mockReset().mockResolvedValue([]); fetchCyclePhasesMock.mockReset().mockResolvedValue([]); + fetchWorkoutsMock.mockReset().mockResolvedValue([]); + heartRatePagesMock + .mockReset() + .mockResolvedValue({ pages: 1, truncated: false }); refreshMock.mockReset(); scopeDeniedMock.mockReset().mockResolvedValue(undefined); transactionMock @@ -902,9 +919,10 @@ describe("syncUserOura — window resolution", () => { await syncUserOura("u1"); expect(windowDays()).toBe(7); - // One indexed read per tick, and tombstones are excluded from it — a - // soft-deleted row must not pass for a live one. - expect(findFirstMock).toHaveBeenCalledTimes(1); + // One indexed read per tick for the day window (the heart-rate window + // reads its own, typed watermark), and tombstones are excluded from it — + // a soft-deleted row must not pass for a live one. + expect(dayWatermarkReads()).toHaveLength(1); expect(findFirstMock).toHaveBeenCalledWith({ where: { userId: "u1", source: "OURA", deletedAt: null }, orderBy: { measuredAt: "desc" }, @@ -958,7 +976,7 @@ describe("syncUserOura — window resolution", () => { await syncUserOura("u1", { lookbackDays: 3 }); expect(windowDays()).toBe(3); - expect(findFirstMock).not.toHaveBeenCalled(); + expect(dayWatermarkReads()).toHaveLength(0); }); it("passes the resolved window to the cycle-phases leg too", async () => { @@ -1113,6 +1131,8 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () = fetchVo2MaxMock, fetchCardioAgeMock, fetchResilienceMock, + fetchWorkoutsMock, + heartRatePagesMock, ]) { m.mockRejectedValue(err401()); } @@ -1286,3 +1306,42 @@ describe("syncUserOura — Oura names the missing scope", () => { ); }); }); + +describe("syncUserOura — a grant from before workouts and heart rate were read", () => { + it("notes the two collections calmly: no refresh, no failure, everything else imports", async () => { + getConnMock.mockResolvedValue(CONN); + fetchActivityMock.mockResolvedValue([ + { id: "a1", day: "2026-06-10", steps: 1200, active_calories: 80 }, + ]); + fetchWorkoutsMock.mockRejectedValue(errScope401("workout")); + heartRatePagesMock.mockRejectedValue(errScope401("heartrate")); + + const result = await syncUserOura("u1"); + + expect(result.failed).toBe(false); + expect(result.imported).toBeGreaterThan(0); + expect(refreshMock).not.toHaveBeenCalled(); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); + expect(scopeDeniedMock).toHaveBeenCalledWith( + "u1", + expect.arrayContaining(["workout", "heartrate"]), + ); + expect(scopeDeniedMock.mock.calls[0]![1]).toHaveLength(2); + }); + + it("reads heart rate over a window of hours, not the day window", async () => { + getConnMock.mockResolvedValue(CONN); + findFirstMock.mockResolvedValue(null); + + await syncUserOura("u1"); + + const range = heartRatePagesMock.mock.calls[0]![1] as { + start: Date; + end: Date; + }; + expect(range.end.getTime() - range.start.getTime()).toBeLessThanOrEqual( + 72 * 60 * 60 * 1000, + ); + }); +}); diff --git a/src/lib/oura/__tests__/workouts-heart-rate.test.ts b/src/lib/oura/__tests__/workouts-heart-rate.test.ts new file mode 100644 index 000000000..7460e99f1 --- /dev/null +++ b/src/lib/oura/__tests__/workouts-heart-rate.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createManyAndReturnMock, findFirstMock, updateMock, safeFetchMock } = + vi.hoisted(() => ({ + createManyAndReturnMock: vi.fn(), + findFirstMock: vi.fn(), + updateMock: vi.fn(), + safeFetchMock: vi.fn(), + })); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + createManyAndReturn: createManyAndReturnMock, + findFirst: findFirstMock, + }, + workout: { + createManyAndReturn: createManyAndReturnMock, + update: updateMock, + }, + }, +})); +vi.mock("@/lib/safe-fetch", () => ({ safeFetch: safeFetchMock })); +vi.mock("@/lib/arrivals/measurement-emit", () => ({ + emitInsertedMeasurementArrivals: vi.fn(async () => {}), +})); +vi.mock("@/lib/arrivals/workout-emit", () => ({ + emitInsertedWorkoutArrival: vi.fn(async () => {}), +})); +vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/rollups/measurement-rollups") + >()), + recomputeBucketsForKeys: vi.fn(async () => {}), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => {}), +})); + +import { mapHeartRateSample } from "../client"; +import { OuraApiError } from "../response-classifier"; +import { mapOuraSportType } from "../sport-map"; +import { + OURA_HEART_RATE_LOOKBACK_HOURS, + OURA_HEART_RATE_MAX_LOOKBACK_HOURS, + OURA_HEART_RATE_WRITE_CHUNK, + resolveOuraHeartRateWindow, + syncOuraHeartRateRange, +} from "../sync-heart-rate"; +import { mapOuraWorkout, upsertOuraWorkouts } from "../sync-workouts"; + +const HOUR = 60 * 60 * 1000; + +function jsonResponse(body: unknown, status = 200, headers = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +beforeEach(() => { + createManyAndReturnMock.mockReset(); + findFirstMock.mockReset(); + updateMock.mockReset(); + safeFetchMock.mockReset(); +}); + +describe("mapOuraSportType", () => { + it("maps Oura's labels onto the canonical buckets", () => { + expect(mapOuraSportType("walking")).toBe("walking"); + expect(mapOuraSportType("strength_training")).toBe("strength"); + expect(mapOuraSportType("Strength Training")).toBe("strength"); + expect(mapOuraSportType("cycling")).toBe("cycling"); + expect(mapOuraSportType("kayaking")).toBe("rowing"); + expect(mapOuraSportType("cross_country_skiing")).toBe("other"); + }); + + it("lands an unknown or blank label on other", () => { + expect(mapOuraSportType("underwater_hockey")).toBe("other"); + expect(mapOuraSportType("")).toBe("other"); + expect(mapOuraSportType(null)).toBe("other"); + }); +}); + +describe("mapOuraWorkout", () => { + const base = { + id: "w-1", + activity: "cycling", + calories: 412.6, + distance: 18250.4, + intensity: "moderate", + source: "autodetected", + label: "Commute with a friend", + start_datetime: "2026-10-01T07:00:00+02:00", + end_datetime: "2026-10-01T07:45:30+02:00", + }; + + it("maps times, energy, real distance and provenance", () => { + expect(mapOuraWorkout(base)).toEqual({ + externalId: "w-1", + sportType: "cycling", + startedAt: new Date("2026-10-01T05:00:00Z"), + endedAt: new Date("2026-10-01T05:45:30Z"), + durationSec: 2730, + totalEnergyKcal: 413, + totalDistanceM: 18250.4, + metadata: { + ouraActivity: "cycling", + ouraIntensity: "moderate", + ouraSource: "autodetected", + }, + }); + }); + + it("never carries the person's free-text label", () => { + expect(JSON.stringify(mapOuraWorkout(base))).not.toContain("Commute"); + }); + + it("refuses a record without an id or with an end before its start", () => { + expect(mapOuraWorkout({ ...base, id: "" })).toBeNull(); + expect( + mapOuraWorkout({ ...base, end_datetime: "2026-10-01T06:00:00+02:00" }), + ).toBeNull(); + expect(mapOuraWorkout({ ...base, start_datetime: "nope" })).toBeNull(); + }); + + it("keeps a missing energy or distance null rather than zero", () => { + const row = mapOuraWorkout({ ...base, calories: null, distance: -1 }); + expect(row?.totalEnergyKcal).toBeNull(); + expect(row?.totalDistanceM).toBeNull(); + }); +}); + +describe("upsertOuraWorkouts", () => { + it("inserts a new workout and overwrites an existing one in place", async () => { + const row = mapOuraWorkout({ + id: "w-1", + activity: "running", + start_datetime: "2026-10-01T07:00:00Z", + end_datetime: "2026-10-01T07:30:00Z", + })!; + createManyAndReturnMock + .mockResolvedValueOnce([{ id: "x", startedAt: row.startedAt }]) + .mockResolvedValueOnce([]); + expect(await upsertOuraWorkouts("u1", [row])).toBe(1); + expect(createManyAndReturnMock.mock.calls[0]![0].data).toMatchObject({ + userId: "u1", + source: "OURA", + externalId: "w-1", + sportType: "running", + }); + expect(updateMock).not.toHaveBeenCalled(); + + expect(await upsertOuraWorkouts("u1", [row])).toBe(1); + expect(updateMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_source_externalId: { + userId: "u1", + source: "OURA", + externalId: "w-1", + }, + }, + }), + ); + }); +}); + +describe("mapHeartRateSample", () => { + it("maps a sample to a PULSE row keyed on its instant", () => { + expect( + mapHeartRateSample({ + bpm: 61.4, + source: "awake", + timestamp: "2026-10-01T10:05:00+00:00", + }), + ).toEqual({ + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: new Date("2026-10-01T10:05:00Z"), + fieldTag: "hr", + externalId: "heartrate:2026-10-01T10:05:00.000Z", + }); + }); + + it("drops a sample without an instant or outside a human band", () => { + expect(mapHeartRateSample({ bpm: 60, timestamp: null })).toBeNull(); + expect(mapHeartRateSample({ bpm: 60, timestamp: "x" })).toBeNull(); + expect( + mapHeartRateSample({ bpm: 0, timestamp: "2026-10-01T10:05:00Z" }), + ).toBeNull(); + expect( + mapHeartRateSample({ bpm: 400, timestamp: "2026-10-01T10:05:00Z" }), + ).toBeNull(); + }); +}); + +describe("syncOuraHeartRateRange", () => { + it("writes every page as it arrives, in bounded batches, following next_token", async () => { + const start = new Date("2026-10-01T00:00:00Z"); + const page = (offset: number, n: number) => + Array.from({ length: n }, (_, i) => ({ + bpm: 60, + source: "awake", + timestamp: new Date( + start.getTime() + (offset + i) * 60_000, + ).toISOString(), + })); + safeFetchMock + .mockResolvedValueOnce( + jsonResponse({ data: page(0, 1200), next_token: "p2" }), + ) + .mockResolvedValueOnce( + jsonResponse({ data: page(1200, 10), next_token: null }), + ); + createManyAndReturnMock.mockImplementation( + async ({ data }: { data: Array<{ measuredAt: Date }> }) => + data.map((d, i) => ({ id: `r${i}`, type: "PULSE", ...d })), + ); + + const result = await syncOuraHeartRateRange("u1", "tok", { + start, + end: new Date(start.getTime() + 2 * 24 * HOUR), + }); + + expect(result).toEqual({ imported: 1210, truncated: false }); + // 1 200 samples in chunks of 500, then the second page. + expect( + createManyAndReturnMock.mock.calls.map(([a]) => a.data.length), + ).toEqual([ + OURA_HEART_RATE_WRITE_CHUNK, + OURA_HEART_RATE_WRITE_CHUNK, + 200, + 10, + ]); + expect(createManyAndReturnMock.mock.calls[0]![0].skipDuplicates).toBe(true); + const firstUrl = new URL(safeFetchMock.mock.calls[0]![0] as string); + expect(firstUrl.pathname).toBe("/v2/usercollection/heartrate"); + expect(firstUrl.searchParams.get("start_datetime")).toBe( + start.toISOString(), + ); + const secondUrl = new URL(safeFetchMock.mock.calls[1]![0] as string); + expect(secondUrl.searchParams.get("next_token")).toBe("p2"); + }); + + it("carries Retry-After on a 429", async () => { + safeFetchMock.mockResolvedValueOnce( + jsonResponse({ detail: "slow down" }, 429, { "retry-after": "120" }), + ); + const err = await syncOuraHeartRateRange("u1", "tok", { + start: new Date("2026-10-01T00:00:00Z"), + end: new Date("2026-10-02T00:00:00Z"), + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(OuraApiError); + expect((err as OuraApiError).retryAfterMs).toBe(120_000); + expect((err as OuraApiError).classification).toBe("transient"); + }); +}); + +describe("resolveOuraHeartRateWindow", () => { + const now = new Date("2026-10-21T12:00:00Z"); + + it("reads the last few hours while readings are flowing", async () => { + findFirstMock.mockResolvedValue({ + measuredAt: new Date(now.getTime() - 20 * 60_000), + }); + const w = await resolveOuraHeartRateWindow("u1", now); + expect(now.getTime() - w.start.getTime()).toBe( + OURA_HEART_RATE_LOOKBACK_HOURS * HOUR, + ); + expect(w.end).toEqual(now); + }); + + it("reaches back to the newest reading after a gap, up to the ceiling", async () => { + findFirstMock.mockResolvedValueOnce({ + measuredAt: new Date(now.getTime() - 30 * HOUR), + }); + let w = await resolveOuraHeartRateWindow("u1", now); + expect(now.getTime() - w.start.getTime()).toBe(31 * HOUR); + + findFirstMock.mockResolvedValueOnce({ + measuredAt: new Date(now.getTime() - 30 * 24 * HOUR), + }); + w = await resolveOuraHeartRateWindow("u1", now); + expect(now.getTime() - w.start.getTime()).toBe( + OURA_HEART_RATE_MAX_LOOKBACK_HOURS * HOUR, + ); + }); + + it("leaves history to the backfill on a fresh connection", async () => { + findFirstMock.mockResolvedValue(null); + const w = await resolveOuraHeartRateWindow("u1", now); + expect(now.getTime() - w.start.getTime()).toBe( + OURA_HEART_RATE_LOOKBACK_HOURS * HOUR, + ); + }); +}); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index c7a40fb83..99e7140c2 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -23,6 +23,7 @@ import { OuraApiError, classifyOuraResponse } from "./response-classifier"; import { envOr, envValue } from "@/lib/env"; import { importValue } from "@/lib/integrations/import-value"; import { walkPages } from "@/lib/integrations/page-walk"; +import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; import { OURA_OAUTH_SCOPE, parseOuraMissingScope } from "./scope"; const OURA_API_BASE = "https://api.ouraring.com"; @@ -463,6 +464,13 @@ interface DateRangeQuery { maxPages?: number; } +/** A time-range read (`heartrate`): instants, not calendar dates. */ +export interface DateTimeRangeQuery { + start: Date; + end: Date; + maxPages?: number; +} + /** * The error for a refused collection read. Oura's status alone does not say * what went wrong; its body does (see `./scope`): @@ -473,13 +481,16 @@ interface DateRangeQuery { * - any other 401: the token is dead or expired (refresh, then reconnect). * - 403: a lapsed Oura membership, per the API reference. A reconnect does * not fix that, so it is a persistent failure rather than a reauth. + * - 429 (and 503): transient, with the `Retry-After` delay on + * `retryAfterMs` so a backfill can wait it out instead of guessing. */ function collectionRefusal( verb: string, - status: number, + res: Pick, verdict: ReturnType, body: unknown, ): OuraApiError { + const status = res.status; if (status === 401) { const missingScope = parseOuraMissingScope(body); if (missingScope) { @@ -505,9 +516,43 @@ function collectionRefusal( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, + ...(status === 429 || status === 503 + ? { retryAfterMs: retryAfterMsOf(res) } + : {}), }); } +/** Read one page of a collection; throws the classified refusal. */ +async function readCollectionPage( + path: string, + accessToken: string, + verb: string, + params: URLSearchParams, + page: number, +): Promise<{ items: T[]; next: string | null }> { + const pageStart = performance.now(); + const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + const json = (await res.json().catch(() => null)) as OuraCollection | null; + const verdict = classifyOuraResponse(res.status); + getEvent()?.addExternalCall({ + service: "oura", + method: `${verb}(page=${page})`, + duration_ms: Math.round(performance.now() - pageStart), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw collectionRefusal(verb, res, verdict, json); + } + return { items: json?.data ?? [], next: json?.next_token ?? null }; +} + async function fetchCollection( path: string, accessToken: string, @@ -517,42 +562,61 @@ async function fetchCollection( const walk = await walkPages( `oura.${verb}`, query.maxPages ?? 100, - async (nextToken, page) => { + (nextToken, page) => { const params = new URLSearchParams({ start_date: query.startDate, end_date: query.endDate, }); if (nextToken) params.set("next_token", nextToken); - - const pageStart = performance.now(); - const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }); - const json = (await res - .json() - .catch(() => null)) as OuraCollection | null; - const verdict = classifyOuraResponse(res.status); - getEvent()?.addExternalCall({ - service: "oura", - method: `${verb}(page=${page})`, - duration_ms: Math.round(performance.now() - pageStart), - status: res.status, - error: - verdict.classification === "success" ? undefined : verdict.reason, - }); - if (verdict.classification !== "success") { - throw collectionRefusal(verb, res.status, verdict, json); - } - return { items: json?.data ?? [], next: json?.next_token ?? null }; + return readCollectionPage(path, accessToken, verb, params, page); }, ); return walk.items; } +/** What a streamed walk came to. */ +export interface OuraStreamedWalk { + pages: number; + /** Stopped at the page ceiling or on a repeated cursor with pages left. */ + truncated: boolean; +} + +/** + * Walk a time-range collection page by page, handing each page to `onPage` + * before the next is read. Nothing is collected: the walk keeps one page at a + * time, so a dense stream (heart rate, a sample every few minutes for years) + * costs the memory of a page, not of the history. + */ +async function streamCollection( + path: string, + accessToken: string, + verb: string, + query: DateTimeRangeQuery, + onPage: (items: T[]) => Promise, +): Promise { + const walk = await walkPages( + `oura.${verb}`, + query.maxPages ?? 200, + async (nextToken, page) => { + const params = new URLSearchParams({ + start_datetime: query.start.toISOString(), + end_datetime: query.end.toISOString(), + }); + if (nextToken) params.set("next_token", nextToken); + const { items, next } = await readCollectionPage( + path, + accessToken, + verb, + params, + page, + ); + await onPage(items); + return { items: [], next }; + }, + ); + return { pages: walk.pages, truncated: walk.truncated }; +} + export function fetchSleep( accessToken: string, query: DateRangeQuery, @@ -669,6 +733,68 @@ export function fetchDailyCyclePhases( ); } +/** + * Oura `workout` — one activity session, auto-detected or entered. `activity` + * is Oura's own lowercase label (`walking`, `cycling`, `strength_training`, + * ...); `distance` is metres actually covered (a GPS or step-derived figure, + * unlike the daily activity-equivalent distance); `calories` is kcal. + */ +export interface OuraWorkout { + id: string; + day?: string; + activity?: string | null; + calories?: number | null; + distance?: number | null; + start_datetime: string; + end_datetime: string; + /** `easy` | `moderate` | `hard` */ + intensity?: string | null; + /** `manual` | `autodetected` | `confirmed` | `workout_heart_rate` */ + source?: string | null; + /** A name the person gave the workout. Free text; never imported. */ + label?: string | null; +} + +export function fetchWorkouts( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/workout", + accessToken, + "fetchWorkouts", + query, + ); +} + +/** One `heartrate` sample: a reading at an instant, tagged with its context. */ +export interface OuraHeartRateSample { + bpm?: number | null; + /** `awake` | `rest` | `sleep` | `session` | `live` | `workout` */ + source?: string | null; + timestamp?: string | null; +} + +/** + * Stream the `heartrate` collection for a time range, one page at a time. + * Oura answers it by instant (`start_datetime` / `end_datetime`) and pages + * with `next_token`; a page goes to `onPage` and is dropped before the next + * is read. + */ +export function forEachHeartRatePage( + accessToken: string, + query: DateTimeRangeQuery, + onPage: (samples: OuraHeartRateSample[]) => Promise, +): Promise { + return streamCollection( + "/v2/usercollection/heartrate", + accessToken, + "fetchHeartRate", + query, + onPage, + ); +} + // ─── Field → Measurement mapping ─────────────────────────────── const SEC_TO_MIN = 1 / 60; @@ -1105,6 +1231,38 @@ export function mapResilience( ]; } +/** Heart-rate readings outside this band are not a ring's reading. */ +const OURA_BPM_MIN = 20; +const OURA_BPM_MAX = 250; + +/** + * Map one `heartrate` sample → `PULSE` (bpm), the intraday heart-rate type + * Google Health and Fitbit write their minute stream to. Keyed on the + * sample's own instant (`heartrate:`): Oura emits one reading per + * instant, and a sample is final once it is in the cloud, so a re-read lands + * on the same key. A sample without a parseable instant or with a bpm + * outside a human band maps to nothing. + */ +export function mapHeartRateSample( + sample: OuraHeartRateSample, +): MappedMeasurement | null { + const bpm = sample.bpm; + if (typeof bpm !== "number" || !Number.isFinite(bpm)) return null; + if (bpm < OURA_BPM_MIN || bpm > OURA_BPM_MAX) return null; + if (typeof sample.timestamp !== "string") return null; + const at = new Date(sample.timestamp); + if (Number.isNaN(at.getTime())) return null; + const iso = at.toISOString(); + return { + type: "PULSE", + value: Math.round(bpm), + unit: "bpm", + measuredAt: at, + fieldTag: "hr", + externalId: `heartrate:${iso}`, + }; +} + // ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ─────────────────── // // `daily_cycle_phases` does NOT feed `Measurement` like every collection diff --git a/src/lib/oura/response-classifier.ts b/src/lib/oura/response-classifier.ts index 46a748ba0..f3c378e56 100644 --- a/src/lib/oura/response-classifier.ts +++ b/src/lib/oura/response-classifier.ts @@ -36,6 +36,12 @@ export class OuraApiError extends IntegrationApiError { * scope."}`). Undefined for every other failure, a dead token included. */ readonly missingScope: string | undefined; + /** + * How long Oura asked to be left alone, from the `Retry-After` of a 429 (or + * 503). Undefined when the refusal carried none or was not a rate limit. + * A backfill waits at least this long before its next slice. + */ + readonly retryAfterMs: number | undefined; constructor(opts: { verb: string; @@ -44,8 +50,9 @@ export class OuraApiError extends IntegrationApiError { reason: string; upstreamError?: string; missingScope?: string; + retryAfterMs?: number; }) { - const { missingScope, ...rest } = opts; + const { missingScope, retryAfterMs, ...rest } = opts; super({ vendor: "Oura", ...rest, @@ -55,6 +62,7 @@ export class OuraApiError extends IntegrationApiError { }); this.name = "OuraApiError"; this.missingScope = missingScope; + this.retryAfterMs = retryAfterMs; } } diff --git a/src/lib/oura/scope.ts b/src/lib/oura/scope.ts index 8356c6246..7189c22ff 100644 --- a/src/lib/oura/scope.ts +++ b/src/lib/oura/scope.ts @@ -34,6 +34,8 @@ * vo2max vO2_max heart_health * cardio_age daily_cardiovascular_age heart_health * resilience daily_resilience stress + * workout workout workout + * heartrate heartrate heartrate * (profile) personal_info personal * (cycle) daily_cycle_phases reproductive_cycle * @@ -53,8 +55,12 @@ export const OURA_COLLECTIONS = [ { name: "vo2max", scope: "heart_health" }, { name: "cardio_age", scope: "heart_health" }, { name: "resilience", scope: "stress" }, + { name: "workout", scope: "workout" }, + { name: "heartrate", scope: "heartrate" }, ] as const; +export type OuraCollectionName = (typeof OURA_COLLECTIONS)[number]["name"]; + type OuraCollectionEntry = (typeof OURA_COLLECTIONS)[number]; /** The scope the profile probe (`personal_info`) needs. */ diff --git a/src/lib/oura/sport-map.ts b/src/lib/oura/sport-map.ts new file mode 100644 index 000000000..bed526098 --- /dev/null +++ b/src/lib/oura/sport-map.ts @@ -0,0 +1,133 @@ +/** + * Oura workout `activity` → HealthLog canonical `WorkoutSportType`. + * + * Oura labels a workout with its own lowercase snake_case vocabulary + * (`walking`, `cycling`, `strength_training`, `cross_country_skiing`, ...) + * and publishes no closed list of it. The table below covers the activities + * the Oura app offers; the bucket choices follow the Polar and Strava tables + * (paddle sports → rowing, racket sports → tennis, climbing and functional + * training → crossTraining, mobility work → mindAndBody, snow sports → other), + * so the same real-world session lands in the same bucket whichever source + * reported it. Anything unknown lands on `"other"`, the row still persists, + * and the raw label is kept in `Workout.metadata.ouraActivity`. + */ +import type { WorkoutSportType } from "@/lib/validations/workout"; + +const OURA_SPORT_TABLE: ReadonlyArray = [ + // running + ["running", "running"], + ["jogging", "running"], + ["trail_running", "running"], + ["treadmill_running", "running"], + // cycling + ["cycling", "cycling"], + ["indoor_cycling", "cycling"], + ["mountain_biking", "cycling"], + ["road_cycling", "cycling"], + ["e_biking", "cycling"], + ["spinning", "cycling"], + // walking / hiking + ["walking", "walking"], + ["nordic_walking", "walking"], + ["treadmill_walking", "walking"], + ["hiking", "hiking"], + ["backpacking", "hiking"], + // water + ["swimming", "swimming"], + ["open_water_swimming", "swimming"], + ["rowing", "rowing"], + ["indoor_rowing", "rowing"], + ["kayaking", "rowing"], + ["canoeing", "rowing"], + ["paddling", "rowing"], + ["stand_up_paddling", "rowing"], + // machines + ["elliptical", "elliptical"], + ["cross_trainer", "elliptical"], + ["stair_exercise", "stairClimber"], + ["stair_climbing", "stairClimber"], + ["stairs", "stairClimber"], + // strength / intervals / functional + ["strength_training", "strength"], + ["weight_training", "strength"], + ["weightlifting", "strength"], + ["core_training", "strength"], + ["hiit", "hiit"], + ["high_intensity_interval_training", "hiit"], + ["circuit_training", "hiit"], + ["cross_training", "crossTraining"], + ["crossfit", "crossTraining"], + ["functional_training", "crossTraining"], + ["climbing", "crossTraining"], + ["bouldering", "crossTraining"], + ["rock_climbing", "crossTraining"], + ["mixed_cardio", "mixedCardio"], + ["cardio", "mixedCardio"], + ["aerobics", "dance"], + // mind and body + ["yoga", "yoga"], + ["hot_yoga", "yoga"], + ["pilates", "mindAndBody"], + ["stretching", "mindAndBody"], + ["mobility", "mindAndBody"], + ["barre", "mindAndBody"], + ["tai_chi", "mindAndBody"], + // dance + ["dancing", "dance"], + ["dance", "dance"], + ["zumba", "dance"], + // ball and racket sports + ["golf", "golf"], + ["tennis", "tennis"], + ["table_tennis", "tennis"], + ["squash", "tennis"], + ["padel", "tennis"], + ["pickleball", "tennis"], + ["racquetball", "tennis"], + ["badminton", "badminton"], + ["basketball", "basketball"], + ["soccer", "soccer"], + ["football", "soccer"], + // everything else that persists outside a bucket + ["skiing", "other"], + ["cross_country_skiing", "other"], + ["downhill_skiing", "other"], + ["snowboarding", "other"], + ["skating", "other"], + ["ice_skating", "other"], + ["surfing", "other"], + ["boxing", "other"], + ["martial_arts", "other"], + ["volleyball", "other"], + ["hockey", "other"], + ["housework", "other"], + ["gardening", "other"], + ["other", "other"], +]; + +/** Fold case and drop separators, so `Strength Training` meets `strength_training`. */ +function normaliseOuraActivity(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); +} + +const OURA_SPORT_MAP: ReadonlyMap = new Map( + OURA_SPORT_TABLE.map(([name, canonical]) => [ + normaliseOuraActivity(name), + canonical, + ]), +); + +/** + * Resolve an Oura workout's `activity` to a canonical `WorkoutSportType`; + * `"other"` for an absent, blank or unknown label. Never returns the raw + * label. + */ +export function mapOuraSportType( + activity: string | null | undefined, +): WorkoutSportType { + if (typeof activity !== "string" || activity.trim() === "") return "other"; + return OURA_SPORT_MAP.get(normaliseOuraActivity(activity)) ?? "other"; +} diff --git a/src/lib/oura/sync-heart-rate.ts b/src/lib/oura/sync-heart-rate.ts new file mode 100644 index 000000000..17597a23e --- /dev/null +++ b/src/lib/oura/sync-heart-rate.ts @@ -0,0 +1,200 @@ +/** + * Oura intraday heart rate (`/v2/usercollection/heartrate`) → `PULSE`. + * + * The ring writes a reading every few minutes through the day, more during a + * workout or a session, so a history of years is hundreds of thousands of + * rows. The walk is streamed (`forEachHeartRatePage`): each page is mapped and + * written in bounded batches before the next page is read, and nothing of a + * page outlives its write. What the window keeps is the set of touched days + * (a handful per window), which the caller refolds once at the end. + * + * Writes are first-write-wins: a sample is final once it is in Oura's cloud, + * and it is keyed on its own instant (`heartrate:`), so a re-read of the + * same range is an insert that `skipDuplicates` drops. A row the person + * deleted keeps its tombstone; the re-read does not bring it back. Oura's + * samples go raw, like Google Health's and Fitbit's minute stream: the dense + * hourly fold covers only the export sources (`DENSE_INTRADAY_FOLD_SOURCES`). + */ +import { prisma } from "@/lib/db"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; +import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; +import { + collapseToTypeDayKeys, + recomputeBucketsForKeys, +} from "@/lib/rollups/measurement-rollups"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { getEvent } from "@/lib/logging/context"; +import { + forEachHeartRatePage, + mapHeartRateSample, + type MappedMeasurement, + type OuraHeartRateSample, +} from "./client"; + +/** Rows per `createMany`: bounded whatever size of page Oura sends. */ +export const OURA_HEART_RATE_WRITE_CHUNK = 500; + +/** + * The hourly window: how far back an hourly sync reads heart rate. Short on + * purpose, the history belongs to the backfill. A ring that has not synced + * for a while widens it up to the ceiling, anchored on the newest reading + * already held (`resolveOuraHeartRateWindow`). + */ +export const OURA_HEART_RATE_LOOKBACK_HOURS = 6; +export const OURA_HEART_RATE_MAX_LOOKBACK_HOURS = 72; + +const HOUR_MS = 60 * 60 * 1000; + +/** Day keys touched by a write, collapsed as they arrive. */ +export class TouchedDays { + private readonly keys = new Map< + string, + { type: MeasurementType; measuredAt: Date } + >(); + + add(rows: Array<{ type: MeasurementType; measuredAt: Date }>): void { + for (const k of collapseToTypeDayKeys(rows)) { + this.keys.set(`${k.type}|${k.measuredAt.getTime()}`, k); + } + } + + get size(): number { + return this.keys.size; + } + + list(): Array<{ type: MeasurementType; measuredAt: Date }> { + return [...this.keys.values()]; + } +} + +/** + * Write one page of samples. Returns how many rows were inserted; the days + * the page reached go into `touched`. + */ +export async function writeOuraHeartRatePage( + userId: string, + samples: readonly OuraHeartRateSample[], + touched: TouchedDays, + opts: { emitArrivals?: boolean } = {}, +): Promise { + const mapped: MappedMeasurement[] = []; + for (const s of samples) { + const m = mapHeartRateSample(s); + if (m) mapped.push(m); + } + const kept = dropImplausibleMeasurements("oura", mapped, (m) => ({ + type: m.type, + value: m.value, + })); + let inserted = 0; + for (let i = 0; i < kept.length; i += OURA_HEART_RATE_WRITE_CHUNK) { + const chunk = kept.slice(i, i + OURA_HEART_RATE_WRITE_CHUNK); + const rows = await prisma.measurement.createManyAndReturn({ + data: chunk.map((m) => ({ + userId, + type: m.type as MeasurementType, + source: "OURA" as const, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.externalId!, + })), + skipDuplicates: true, + select: { id: true, type: true, measuredAt: true }, + }); + inserted += rows.length; + touched.add(rows); + if (opts.emitArrivals !== false && rows.length > 0) { + void emitInsertedMeasurementArrivals(userId, rows, "oura").catch( + () => {}, + ); + } + } + return inserted; +} + +/** Refold the touched days once a window is written. Best-effort. */ +export async function refoldOuraTouchedDays( + userId: string, + touched: TouchedDays, +): Promise { + if (touched.size === 0) return; + const keys = touched.list(); + try { + await recomputeBucketsForKeys(userId, keys); + invalidateStatusInsightsForTypes( + userId, + [...new Set(keys.map((k) => k.type))], + { source: "sync" }, + ).catch((err) => { + getEvent()?.addWarning( + `oura: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); + } catch (err) { + getEvent()?.addWarning( + `oura: heart-rate rollup recompute failed for ${userId}: ${err}`, + ); + } +} + +/** + * Read and write one time range of heart rate, page by page. Returns how many + * rows were inserted and whether the walk stopped short (page ceiling or a + * cursor Oura handed out twice). A refused page throws the classified + * `OuraApiError` (a 429 with its `retryAfterMs`); the pages written before it + * stay written. + */ +export async function syncOuraHeartRateRange( + userId: string, + accessToken: string, + range: { start: Date; end: Date }, + opts: { emitArrivals?: boolean; touched?: TouchedDays } = {}, +): Promise<{ imported: number; truncated: boolean }> { + const touched = opts.touched ?? new TouchedDays(); + let imported = 0; + const walk = await forEachHeartRatePage( + accessToken, + { start: range.start, end: range.end }, + async (samples) => { + imported += await writeOuraHeartRatePage(userId, samples, touched, { + emitArrivals: opts.emitArrivals, + }); + }, + ); + if (!opts.touched) await refoldOuraTouchedDays(userId, touched); + return { imported, truncated: walk.truncated }; +} + +/** + * The hourly heart-rate window: the last `OURA_HEART_RATE_LOOKBACK_HOURS`, or + * back to an hour before the newest reading held when that is older, never + * further than `OURA_HEART_RATE_MAX_LOOKBACK_HOURS`. The ring uploads what it + * stored all at once when the app next opens, so a gap closes in one window. + */ +export async function resolveOuraHeartRateWindow( + userId: string, + now: Date = new Date(), +): Promise<{ start: Date; end: Date }> { + const newest = await prisma.measurement.findFirst({ + where: { + userId, + source: "OURA", + type: "PULSE", + deletedAt: null, + externalId: { startsWith: "heartrate:" }, + }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + const floor = now.getTime() - OURA_HEART_RATE_MAX_LOOKBACK_HOURS * HOUR_MS; + const steady = now.getTime() - OURA_HEART_RATE_LOOKBACK_HOURS * HOUR_MS; + const fromNewest = newest + ? newest.measuredAt.getTime() - HOUR_MS + : Number.POSITIVE_INFINITY; + return { + start: new Date(Math.max(floor, Math.min(steady, fromNewest))), + end: now, + }; +} diff --git a/src/lib/oura/sync-workouts.ts b/src/lib/oura/sync-workouts.ts new file mode 100644 index 000000000..0d3ff8def --- /dev/null +++ b/src/lib/oura/sync-workouts.ts @@ -0,0 +1,150 @@ +/** + * Oura workouts → `Workout` rows (`source = OURA`). + * + * Each Oura workout becomes one row keyed on `(userId, source, externalId)` + * with the Oura id as `externalId`, so a re-read (the hourly window, a + * backfill slice, a workout Oura revised) overwrites in place. The write is + * the same `createManyAndReturn` + `skipDuplicates`, update-on-conflict shape + * the Polar, WHOOP and Strava ingests use; the arrival event fires only for a + * fresh insert. + * + * Cross-source dedup is not done here: an Oura-detected walk and the same walk + * from Apple Health stay two rows, and the read-time `pickCanonicalWorkoutRows` + * collapses the twin by the user's source ladder, like every other provider. + * + * `sportType` is the canonical bucket from `mapOuraSportType`; the raw label + * travels in `metadata.ouraActivity`, with Oura's intensity grade and how the + * workout was recorded (`manual`, `autodetected`, ...). The person's own name + * for the workout (`label`) is free text and is not imported. + */ +import { prisma } from "@/lib/db"; +import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; +import { annotate, getEvent } from "@/lib/logging/context"; +import type { Prisma } from "@/generated/prisma/client"; +import type { WorkoutSportType } from "@/lib/validations/workout"; +import type { OuraWorkout } from "./client"; +import { mapOuraSportType } from "./sport-map"; + +export interface OuraWorkoutRow { + externalId: string; + sportType: WorkoutSportType; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + metadata: Prisma.InputJsonValue; +} + +function finiteNonNegative(v: number | null | undefined): number | null { + return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null; +} + +/** + * Map one Oura workout to a row, or null when it has no id or no usable time + * range (an end before its start included). + */ +export function mapOuraWorkout(w: OuraWorkout): OuraWorkoutRow | null { + if (typeof w.id !== "string" || w.id === "") return null; + const startedAt = new Date(w.start_datetime); + const endedAt = new Date(w.end_datetime); + if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) { + return null; + } + if (endedAt.getTime() < startedAt.getTime()) return null; + const activity = typeof w.activity === "string" ? w.activity : null; + const metadata: Prisma.InputJsonValue = { + ...(activity ? { ouraActivity: activity } : {}), + ...(typeof w.intensity === "string" ? { ouraIntensity: w.intensity } : {}), + ...(typeof w.source === "string" ? { ouraSource: w.source } : {}), + }; + const kcal = finiteNonNegative(w.calories); + return { + externalId: w.id, + sportType: mapOuraSportType(activity), + startedAt, + endedAt, + durationSec: Math.round((endedAt.getTime() - startedAt.getTime()) / 1000), + totalEnergyKcal: kcal === null ? null : Math.round(kcal), + totalDistanceM: finiteNonNegative(w.distance), + metadata, + }; +} + +/** + * Upsert mapped Oura workouts. Every row is attempted; the first failure is + * rethrown afterwards so a partially written batch is never reported clean. + * `emitArrivals: false` (the history backfill) writes without arrival events: + * a workout from three years ago is not news. + */ +export async function upsertOuraWorkouts( + userId: string, + rows: readonly OuraWorkoutRow[], + opts: { emitArrivals?: boolean } = {}, +): Promise { + if (rows.length === 0) return 0; + const emitArrivals = opts.emitArrivals ?? true; + let imported = 0; + let firstError: unknown; + let hadError = false; + for (const r of rows) { + const data = { + sportType: r.sportType, + startedAt: r.startedAt, + endedAt: r.endedAt, + durationSec: r.durationSec, + totalEnergyKcal: r.totalEnergyKcal, + totalDistanceM: r.totalDistanceM, + metadata: r.metadata, + }; + try { + const [inserted] = await prisma.workout.createManyAndReturn({ + data: { userId, source: "OURA", externalId: r.externalId, ...data }, + skipDuplicates: true, + select: { id: true, startedAt: true }, + }); + if (inserted) { + if (emitArrivals) { + void emitInsertedWorkoutArrival(userId, inserted, "oura").catch( + () => {}, + ); + } + } else { + await prisma.workout.update({ + where: { + userId_source_externalId: { + userId, + source: "OURA", + externalId: r.externalId, + }, + }, + data, + }); + } + imported += 1; + } catch (err) { + getEvent()?.addWarning(`oura: failed to upsert workout: ${err}`); + if (!hadError) { + firstError = err; + hadError = true; + } + } + } + if (hadError) throw firstError; + annotate({ action: { name: "oura.workout.ingest", details: { imported } } }); + return imported; +} + +/** Map and write one window's workouts; unmappable records are skipped. */ +export async function writeOuraWorkouts( + userId: string, + workouts: readonly OuraWorkout[], + opts: { emitArrivals?: boolean } = {}, +): Promise { + const rows: OuraWorkoutRow[] = []; + for (const w of workouts) { + const row = mapOuraWorkout(w); + if (row) rows.push(row); + } + return upsertOuraWorkouts(userId, rows, opts); +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index a22b87561..8c345c0ce 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -75,6 +75,7 @@ import { fetchResilience, fetchSleep, fetchVo2Max, + fetchWorkouts, isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, @@ -90,7 +91,18 @@ import { getOuraConnection, syncOuraScopeDenied } from "./credentials"; import { refreshOuraAfterRefusal } from "./oauth-descriptor"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; -import { isOuraScopeGatedCollection } from "./scope"; +import { + OURA_COLLECTIONS, + isOuraScopeGatedCollection, + type OuraCollectionName, +} from "./scope"; +import { writeOuraWorkouts } from "./sync-workouts"; +import { + refoldOuraTouchedDays, + resolveOuraHeartRateWindow, + syncOuraHeartRateRange, + TouchedDays, +} from "./sync-heart-rate"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -220,9 +232,12 @@ function toUpsert( /** One Oura collection: its ledger name and a self-contained fetch+map closure * that resolves to the collection's mapped upserts. Each closure owns its own - * client call and mapper so a throw is contained to this one collection. */ + * client call and mapper so a throw is contained to this one collection. A + * collection that writes rows of its own (workouts, the heart-rate stream) + * writes inside the closure, counts on `OuraRunContext.sideImported`, and + * resolves to no upserts. */ interface OuraCollectionSpec { - name: string; + name: OuraCollectionName; collect: (token: string) => Promise; } @@ -261,6 +276,161 @@ export interface OuraFetchResult { scopeDenied: string[] | null; } +/** + * What one run reads: the day window of the day-keyed collections, the time + * range of the heart-rate stream, the user's zone, and the sinks the sleep + * collection and the self-writing collections fill as they go. + */ +export interface OuraRunContext { + userId: string; + tz: string; + query: { startDate: string; endDate: string }; + heartRate: { start: Date; end: Date }; + /** False on a history backfill: old rows are not arrivals. */ + emitArrivals: boolean; + sleepSweeps: SleepSegmentSweep[]; + sleepRetirements: string[]; + /** Rows written by the collections that write their own. */ + sideImported: number; + /** Days the heart-rate stream touched; refolded once by the caller. */ + heartRateTouched: TouchedDays; +} + +export function ouraRunContext( + userId: string, + tz: string, + query: { startDate: string; endDate: string }, + heartRate: { start: Date; end: Date }, + emitArrivals = true, +): OuraRunContext { + return { + userId, + tz, + query, + heartRate, + emitArrivals, + sleepSweeps: [], + sleepRetirements: [], + sideImported: 0, + heartRateTouched: new TouchedDays(), + }; +} + +/** + * The fetch+map (or fetch+write) closure of one collection over the context's + * window. The one place each collection's read is defined, for the hourly + * sync and the history backfill alike. + */ +export function ouraCollectionReader( + name: OuraCollectionName, + ctx: OuraRunContext, +): (token: string) => Promise { + const { tz, query } = ctx; + switch (name) { + case "readiness": + return async (t) => + (await fetchReadiness(t, query)).flatMap((r) => + toUpsert( + mapReadiness(r, tz), + "readiness", + r.timestamp ? undefined : r.day, + ), + ); + case "sleep": + return async (t) => { + const records = await fetchSleep(t, query); + const ups: OuraMeasurementUpsert[] = []; + for (const s of records) { + if (isRetiredOuraSleepPeriod(s)) { + if (s.id) ctx.sleepRetirements.push(`sleep:${s.id}:`); + continue; + } + const rows = toUpsert( + mapSleep(s, tz), + "sleep", + s.bedtime_end ? undefined : s.day, + ); + ups.push(...rows); + // Record-scoped sweep entry: every SLEEP_DURATION row of this + // record keys under `sleep::` (hypnogram runs AND the + // stage-total fallback), so the prefix bounds the cleanup to this + // one record. Clears whatever a revised hypnogram orphaned — + // shifted run boundaries, legacy run-indexed `seg:` rows, and a + // stale stage-total set once the hypnogram appears. + ctx.sleepSweeps.push({ + prefix: `sleep:${s.id}:`, + keepIds: rows + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.externalId), + }); + } + return ups; + }; + case "activity": + return async (t) => + (await fetchDailyActivity(t, query)).flatMap((a) => + toUpsert( + mapDailyActivity(a, tz), + "activity", + a.timestamp ? undefined : a.day, + ), + ); + case "daily_sleep": + return async (t) => + (await fetchDailySleep(t, query)).flatMap((d) => + toUpsert( + mapDailySleep(d, tz), + "daily_sleep", + d.timestamp ? undefined : d.day, + ), + ); + case "spo2": + return async (t) => + (await fetchDailySpo2(t, query)).flatMap((s) => + toUpsert(mapDailySpo2(s, tz), "spo2", s.day), + ); + case "vo2max": + return async (t) => + (await fetchVo2Max(t, query)).flatMap((v) => + toUpsert(mapVo2Max(v, tz), "vo2max", v.timestamp ? undefined : v.day), + ); + case "cardio_age": + return async (t) => + (await fetchCardiovascularAge(t, query)).flatMap((c) => + toUpsert(mapCardiovascularAge(c, tz), "cardio_age", c.day), + ); + case "resilience": + return async (t) => + (await fetchResilience(t, query)).flatMap((r) => + toUpsert(mapResilience(r, tz), "resilience", r.day), + ); + case "workout": + return async (t) => { + const workouts = await fetchWorkouts(t, query); + ctx.sideImported += await writeOuraWorkouts(ctx.userId, workouts, { + emitArrivals: ctx.emitArrivals, + }); + return []; + }; + case "heartrate": + return async (t) => { + const { imported, truncated } = await syncOuraHeartRateRange( + ctx.userId, + t, + ctx.heartRate, + { emitArrivals: ctx.emitArrivals, touched: ctx.heartRateTouched }, + ); + ctx.sideImported += imported; + if (truncated) { + // A window Oura could not page to its end is not read: fail it so + // the run is not reported clean over a gap. + throw new Error("oura heart-rate walk stopped short of its window"); + } + return []; + }; + } +} + /** * A 401 whose body names the missing scope (see `./scope`): Oura checked the * token and found it valid, the grant just does not cover this collection. A @@ -346,124 +516,17 @@ function splitScopeDenied( * connection at `reauth_required`. */ async function fetchAll( - userId: string, + ctx: OuraRunContext, accessToken: string, refreshTokenCiphertext: string, - lookbackDays: number, knownScopeDenied: readonly string[] = [], ): Promise { - // Oura keys its daily collections by the user's calendar date, so the - // window is cut on the user's own days, and date-only records are anchored - // at local noon of their date in this zone. - const tz = await resolveUserTimezone(userId); - const today = userDayKey(new Date(), tz); - const query = { - startDate: shiftDateKey(today, -lookbackDays), - endDate: today, - }; - - // Filled by the sleep collection's closure as it maps records; carried on - // the result so the caller can run the record-scoped sweep before the - // upsert. A collection that throws pushes nothing (the fetch is its first - // await), so a 401-retry cannot double-enter a record. - const sleepSweeps: SleepSegmentSweep[] = []; - const sleepRetirements: string[] = []; - - const collections: OuraCollectionSpec[] = [ - { - name: "readiness", - collect: async (t) => - (await fetchReadiness(t, query)).flatMap((r) => - toUpsert( - mapReadiness(r, tz), - "readiness", - r.timestamp ? undefined : r.day, - ), - ), - }, - { - name: "sleep", - collect: async (t) => { - const records = await fetchSleep(t, query); - const ups: OuraMeasurementUpsert[] = []; - for (const s of records) { - if (isRetiredOuraSleepPeriod(s)) { - if (s.id) sleepRetirements.push(`sleep:${s.id}:`); - continue; - } - const rows = toUpsert( - mapSleep(s, tz), - "sleep", - s.bedtime_end ? undefined : s.day, - ); - ups.push(...rows); - // Record-scoped sweep entry: every SLEEP_DURATION row of this - // record keys under `sleep::` (hypnogram runs AND the - // stage-total fallback), so the prefix bounds the cleanup to this - // one record. Clears whatever a revised hypnogram orphaned — - // shifted run boundaries, legacy run-indexed `seg:` rows, and a - // stale stage-total set once the hypnogram appears. - sleepSweeps.push({ - prefix: `sleep:${s.id}:`, - keepIds: rows - .filter((r) => r.type === "SLEEP_DURATION") - .map((r) => r.externalId), - }); - } - return ups; - }, - }, - { - name: "activity", - collect: async (t) => - (await fetchDailyActivity(t, query)).flatMap((a) => - toUpsert( - mapDailyActivity(a, tz), - "activity", - a.timestamp ? undefined : a.day, - ), - ), - }, - { - name: "daily_sleep", - collect: async (t) => - (await fetchDailySleep(t, query)).flatMap((d) => - toUpsert( - mapDailySleep(d, tz), - "daily_sleep", - d.timestamp ? undefined : d.day, - ), - ), - }, - { - name: "spo2", - collect: async (t) => - (await fetchDailySpo2(t, query)).flatMap((s) => - toUpsert(mapDailySpo2(s, tz), "spo2", s.day), - ), - }, - { - name: "vo2max", - collect: async (t) => - (await fetchVo2Max(t, query)).flatMap((v) => - toUpsert(mapVo2Max(v, tz), "vo2max", v.timestamp ? undefined : v.day), - ), - }, - { - name: "cardio_age", - collect: async (t) => - (await fetchCardiovascularAge(t, query)).flatMap((c) => - toUpsert(mapCardiovascularAge(c, tz), "cardio_age", c.day), - ), - }, - { - name: "resilience", - collect: async (t) => - (await fetchResilience(t, query)).flatMap((r) => - toUpsert(mapResilience(r, tz), "resilience", r.day), - ), - }, - ]; + const userId = ctx.userId; + const { sleepSweeps, sleepRetirements } = ctx; + const collections: OuraCollectionSpec[] = OURA_COLLECTIONS.map((c) => ({ + name: c.name, + collect: ouraCollectionReader(c.name, ctx), + })); // Run a set of collections isolated from one another. A rejection is captured // per-collection, never propagated, so siblings still resolve. @@ -629,13 +692,25 @@ export async function syncUserOura( const lookbackDays = opts.lookbackDays ?? (await resolveOuraLookbackDays(userId)); + // Oura keys its daily collections by the user's calendar date, so the + // window is cut on the user's own days, and date-only records are anchored + // at local noon of their date in this zone. Heart rate is read by instant + // over a window of hours (`resolveOuraHeartRateWindow`). + const tz = await resolveUserTimezone(userId); + const today = userDayKey(new Date(), tz); + const ctx = ouraRunContext( + userId, + tz, + { startDate: shiftDateKey(today, -lookbackDays), endDate: today }, + await resolveOuraHeartRateWindow(userId), + ); + let result: OuraFetchResult; try { result = await fetchAll( - userId, + ctx, conn.accessToken, conn.refreshTokenCiphertext, - lookbackDays, conn.scopeDenied, ); } catch (err) { @@ -677,13 +752,15 @@ export async function syncUserOura( // Import everything the healthy collections returned regardless of whether a // sibling collection failed — one bad collection must not blank the source. let insertedSleepMeasuredAts: Date[] = []; - const imported = await upsertOuraMeasurements(userId, result.readings, { - onInserted: (rows) => { - insertedSleepMeasuredAts = rows - .filter((row) => row.type === "SLEEP_DURATION") - .map((row) => row.measuredAt); - }, - }); + const imported = + (await upsertOuraMeasurements(userId, result.readings, { + onInserted: (rows) => { + insertedSleepMeasuredAts = rows + .filter((row) => row.type === "SLEEP_DURATION") + .map((row) => row.measuredAt); + }, + })) + ctx.sideImported; + await refoldOuraTouchedDays(userId, ctx.heartRateTouched); // v1.29.x — best-effort Cycle Insights import. Fully isolated from the // measurement sync's status ledger on purpose: `daily_cycle_phases` sits @@ -695,14 +772,7 @@ export async function syncUserOura( try { const freshConn = await getOuraConnection(userId); if (freshConn) { - const cycleToday = userDayKey( - new Date(), - await resolveUserTimezone(userId), - ); - await syncUserOuraCyclePhases(userId, freshConn.accessToken, { - startDate: shiftDateKey(cycleToday, -lookbackDays), - endDate: cycleToday, - }); + await syncUserOuraCyclePhases(userId, freshConn.accessToken, ctx.query); } } catch (err) { getEvent()?.addWarning( @@ -761,6 +831,8 @@ export async function upsertOuraMeasurements( readings: OuraMeasurementUpsert[], opts: { onInserted?: (rows: InsertedMeasurementArrivalRow[]) => void; + /** False on a history backfill: old rows are not arrivals. */ + emitArrivals?: boolean; } = {}, ): Promise { if (readings.length === 0) return 0; @@ -818,9 +890,11 @@ export async function upsertOuraMeasurements( } opts.onInserted?.(insertedRows); - void emitInsertedMeasurementArrivals(userId, insertedRows, "oura").catch( - () => {}, - ); + if (opts.emitArrivals !== false) { + void emitInsertedMeasurementArrivals(userId, insertedRows, "oura").catch( + () => {}, + ); + } try { const keys = collapseToTypeDayKeys(touched); await recomputeBucketsForKeys(userId, keys); From 07f9615dbaed93c234e66f0aed224ce2c574435f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:36:06 +0200 Subject: [PATCH 13/98] feat(ops): name 1536 MB as the supported memory floor The getting-started requirements now say a host needs 3 GB of RAM, 4 GB recommended, and drop the advice to lower APP_MEMORY_LIMIT to 1g on a 2 GB host. The compose and .env example comments say the same. At boot the app reads its cgroup memory limit (v2, then v1) and logs one line when it is below MEMORY_FLOOR_MB. The support report adds the container limit and a note below the floor, and Admin > System status shows "below the supported floor" next to the existing low-heap flag. The app keeps running either way. --- .env.production.example | 8 +- docker-compose.yml | 7 +- docs/self-hosting/getting-started.md | 18 ++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + .../api/admin/status/__tests__/route.test.ts | 43 +++++++++- src/app/api/admin/status/route.ts | 18 +++- src/components/admin/_shared.tsx | 15 +++- .../admin/system-status-section.tsx | 19 ++++- src/instrumentation.ts | 9 ++ .../support/__tests__/memory-floor.test.ts | 69 ++++++++++++++++ .../support-report-redaction.test.ts | 25 ++++++ src/lib/support/heap-limit.ts | 82 +++++++++++++++++++ src/lib/support/report.ts | 18 ++++ 20 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 src/lib/support/__tests__/memory-floor.test.ts diff --git a/.env.production.example b/.env.production.example index 0428201a7..ecf31e293 100644 --- a/.env.production.example +++ b/.env.production.example @@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="" # TRUST_CF_CONNECTING_IP="1" # Memory cap of the app container (read by docker-compose.yml, not by the app). -# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB, -# which a long-used account fills in normal operation. Lower it on a host with -# 2 GB or less, raise it on a busy instance. +# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the +# supported floor: below it a long-used account fills the heap in normal +# operation, and the boot log, the support report and Admin > System status +# say the app runs below the supported floor. The host needs 3 GB of RAM, +# 4 GB recommended. Raise it on a busy instance; do not lower it. # APP_MEMORY_LIMIT="1536m" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index fb778bee4..af4954609 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -503,9 +503,10 @@ services: # workload can grow large enough to starve Postgres on a shared host. # Node sizes its heap from this limit: 1g gives a 524 MB heap, which a # long-used account fills in normal operation, so garbage collection - # stalls requests for seconds. 1536m gives 792 MB. The support report and - # Admin > System status flag a heap below 700 MB. On a host with 2 GB or less, lower it with - # APP_MEMORY_LIMIT in .env; on a busy instance, raise it. + # stalls requests for seconds. 1536m gives 792 MB and is the supported + # floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the + # support report and Admin > System status say so. On a busy instance, + # raise it with APP_MEMORY_LIMIT in .env; do not lower it. mem_limit: "${APP_MEMORY_LIMIT:-1536m}" healthcheck: test: diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index e9ee7aa1f..0541c2333 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -11,14 +11,16 @@ already installed, longer if a TLS-fronted public hostname is in scope pulls a multi-arch image from GitHub Container Registry (linux/amd64 - linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged. -- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with - measurement history; the Apple Health import worker can briefly - hold a 1.5 GB upload in `/tmp` while it parses. The app container is - capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its - heap at about 792 MB). On a 2 GB host that leaves too little for - Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env` - (a heap of about 524 MB); a long-used account then runs closer to - that heap, and the support report and Admin > System status say so. +- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data + volume grows with measurement history; the Apple Health import worker + can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app + container is capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node + then sizes its heap at about 792 MB), and Postgres and the system need + the rest. 1536 MB is the supported floor for the app container: do not + lower it to fit a smaller host. Below it, a long-used account or a large + import can fill the heap and restart the app; the app still starts, but + the boot log, the support report and Admin > System status say it runs + below the supported floor. On a busy instance, raise it. - **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever integration endpoints you plan to enable (Withings, OpenAI, etc.). The opt-in environmental-context module additionally reaches diff --git a/messages/de.json b/messages/de.json index b8afe311c..ba1ca12ef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heap-Grenze", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, zu knapp. APP_MEMORY_LIMIT=1536m in .env setzen", + "memoryBelowFloor": "Container {mb} MB, unter der unterstützten Untergrenze von {floor} MB. APP_MEMORY_LIMIT={floor}m in .env setzen", "offlineGeoOff": "Aus", "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-Endpunkte und API-Tokens erlauben.", diff --git a/messages/en.json b/messages/en.json index 8ec8c0e61..3a6dfe846 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heap limit", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, low. Set APP_MEMORY_LIMIT=1536m in .env", + "memoryBelowFloor": "Container {mb} MB, below the supported floor of {floor} MB. Set APP_MEMORY_LIMIT={floor}m in .env", "offlineGeoOff": "Off", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Allow API endpoints and API tokens.", diff --git a/messages/es.json b/messages/es.json index 80928dc2d..0196a1f7b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Límite de heap", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, escaso: define APP_MEMORY_LIMIT=1536m en .env", + "memoryBelowFloor": "Contenedor {mb} MB, por debajo del mínimo admitido de {floor} MB: define APP_MEMORY_LIMIT={floor}m en .env", "offlineGeoOff": "Desactivado", "offlineGeoChosen": "En línea ({host})", "apiGlobal": "Permitir endpoints de API y tokens de API.", diff --git a/messages/fr.json b/messages/fr.json index 4f7058a57..d7109278c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limite du tas", "heapLimitValue": "{mb} Mo", "heapLimitLow": "{mb} Mo, trop juste : définissez APP_MEMORY_LIMIT=1536m dans .env", + "memoryBelowFloor": "Conteneur {mb} Mo, sous le minimum pris en charge de {floor} Mo : définissez APP_MEMORY_LIMIT={floor}m dans .env", "offlineGeoOff": "Désactivé", "offlineGeoChosen": "En ligne ({host})", "apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.", diff --git a/messages/it.json b/messages/it.json index a1dc08274..91db78f4b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limite heap", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, poco: imposta APP_MEMORY_LIMIT=1536m in .env", + "memoryBelowFloor": "Container {mb} MB, sotto il minimo supportato di {floor} MB: imposta APP_MEMORY_LIMIT={floor}m in .env", "offlineGeoOff": "Disattivato", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Consentire endpoint API e token API.", diff --git a/messages/ko.json b/messages/ko.json index 8712f3f1a..0708547d5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7575,6 +7575,7 @@ "heapLimitLabel": "힙 한도", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, 부족합니다. .env에 APP_MEMORY_LIMIT=1536m을 설정하세요", + "memoryBelowFloor": "컨테이너 {mb} MB, 지원 최소치 {floor} MB 미만입니다. .env에 APP_MEMORY_LIMIT={floor}m을 설정하세요", "offlineGeoOff": "꺼짐", "offlineGeoChosen": "온라인 ({host})", "apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.", diff --git a/messages/nl.json b/messages/nl.json index dc0c5e5f0..c5e9d9a13 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heaplimiet", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, laag. Stel APP_MEMORY_LIMIT=1536m in via .env", + "memoryBelowFloor": "Container {mb} MB, onder het ondersteunde minimum van {floor} MB. Stel APP_MEMORY_LIMIT={floor}m in via .env", "offlineGeoOff": "Uit", "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-endpoints en API-tokens toestaan.", diff --git a/messages/pl.json b/messages/pl.json index 625abe2a4..6d55a5d6d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limit sterty", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, za mało: ustaw APP_MEMORY_LIMIT=1536m w .env", + "memoryBelowFloor": "Kontener {mb} MB, poniżej obsługiwanego minimum {floor} MB: ustaw APP_MEMORY_LIMIT={floor}m w .env", "offlineGeoOff": "Wyłączone", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Zezwól na punkty końcowe API i tokeny API.", diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index 3bf2f8165..46a6b2a86 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,12 +44,19 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); -const heapMock = vi.hoisted(() => ({ mb: 792 })); +const heapMock = vi.hoisted(() => ({ + mb: 792, + container: 1536 as number | null, +})); vi.mock("@/lib/support/heap-limit", async () => { const actual = await vi.importActual< typeof import("@/lib/support/heap-limit") >("@/lib/support/heap-limit"); - return { ...actual, heapLimitMb: () => heapMock.mb }; + return { + ...actual, + heapLimitMb: () => heapMock.mb, + containerMemoryLimitMb: () => heapMock.container, + }; }); const geoMock = vi.hoisted(() => ({ @@ -200,12 +207,40 @@ describe("GET /api/admin/status (integration health summary)", () => { const low = (await (await GET()).json()) as { data: { memory: { heapLimitMb: number; low: boolean } }; }; - expect(low.data.memory).toEqual({ heapLimitMb: 524, low: true }); + expect(low.data.memory).toMatchObject({ heapLimitMb: 524, low: true }); heapMock.mb = 792; const enough = (await (await GET()).json()) as { data: { memory: { heapLimitMb: number; low: boolean } }; }; - expect(enough.data.memory).toEqual({ heapLimitMb: 792, low: false }); + expect(enough.data.memory).toMatchObject({ heapLimitMb: 792, low: false }); + }); + + it("flags a container memory limit below the supported floor", async () => { + type Memory = { + containerLimitMb: number | null; + floorMb: number; + belowFloor: boolean; + }; + heapMock.container = 1024; + const below = (await (await GET()).json()) as { data: { memory: Memory } }; + expect(below.data.memory).toMatchObject({ + containerLimitMb: 1024, + floorMb: 1536, + belowFloor: true, + }); + heapMock.container = 1536; + const atFloor = (await (await GET()).json()) as { + data: { memory: Memory }; + }; + expect(atFloor.data.memory.belowFloor).toBe(false); + // No limit, or none readable: nothing to flag. + heapMock.container = null; + const none = (await (await GET()).json()) as { data: { memory: Memory } }; + expect(none.data.memory).toMatchObject({ + containerLimitMb: null, + belowFloor: false, + }); + heapMock.container = 1536; }); it("reports named failing queues through failingJobs only", async () => { diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index a8d9968d8..0cfd3a0c4 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -9,7 +9,13 @@ import { readFailingQueues, } from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; -import { heapLimitMb, isHeapLimitLow } from "@/lib/support/heap-limit"; +import { + containerMemoryLimitMb, + heapLimitMb, + isBelowMemoryFloor, + isHeapLimitLow, + MEMORY_FLOOR_MB, +} from "@/lib/support/heap-limit"; import { geoLookupMode, geoProviderChosen, @@ -77,6 +83,8 @@ export const GET = apiHandler(async () => { // The process's heap ceiling, and whether it is the 1 GB-container size // that a long-used account can fill (`heap-limit.ts`). const heapLimit = heapLimitMb(); + // And the container's own limit against the supported floor. + const containerLimit = containerMemoryLimitMb(); return apiSuccess({ version: process.env.npm_package_version || "0.1.0", @@ -85,7 +93,13 @@ export const GET = apiHandler(async () => { buildTime: BUILD_TIME, startTime: START_TIME, database: "connected", - memory: { heapLimitMb: heapLimit, low: isHeapLimitLow(heapLimit) }, + memory: { + heapLimitMb: heapLimit, + low: isHeapLimitLow(heapLimit), + containerLimitMb: containerLimit, + floorMb: MEMORY_FLOOR_MB, + belowFloor: isBelowMemoryFloor(containerLimit), + }, worker: { running: workerStatus.running, startedAt: workerStatus.startedAt, diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 69f00d639..09bd986b8 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -61,8 +61,19 @@ export interface SystemStatus { buildTime: string; startTime: string; database: string; - /** The process's heap ceiling; `low` when it is below what a long-used account needs. */ - memory: { heapLimitMb: number; low: boolean }; + /** + * The process's heap ceiling; `low` when it is below what a long-used + * account needs. `containerLimitMb` is the container's memory limit + * (`null` when there is none or it cannot be read), `belowFloor` when it is + * under the supported floor of `floorMb`. + */ + memory: { + heapLimitMb: number; + low: boolean; + containerLimitMb: number | null; + floorMb: number; + belowFloor: boolean; + }; worker: WorkerStatus; /** * Queues whose jobs exhausted their retries recently. `null` means the diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 8f520e46c..c23d4c0d9 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -164,11 +164,22 @@ export function SystemStatusSection() { icon={MemoryStick} label={t("admin.heapLimitLabel")} value={ - status.memory.low - ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) - : t("admin.heapLimitValue", { mb: status.memory.heapLimitMb }) + status.memory.belowFloor && status.memory.containerLimitMb + ? t("admin.memoryBelowFloor", { + mb: status.memory.containerLimitMb, + floor: status.memory.floorMb, + }) + : status.memory.low + ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) + : t("admin.heapLimitValue", { + mb: status.memory.heapLimitMb, + }) + } + tone={ + status.memory.belowFloor || status.memory.low + ? "warning" + : undefined } - tone={status.memory.low ? "warning" : undefined} /> { + it("is the compose default", () => { + expect(MEMORY_FLOOR_MB).toBe(1536); + }); + + it("parses cgroup limit files", () => { + expect(parseCgroupMemoryLimit(`${1536 * MiB}\n`)).toBe(1536); + expect(parseCgroupMemoryLimit(`${1024 * MiB}`)).toBe(1024); + expect(parseCgroupMemoryLimit("max\n")).toBeNull(); + // cgroup v1 reports "no limit" as a huge page-aligned sentinel. + expect(parseCgroupMemoryLimit("9223372036854771712")).toBeNull(); + expect(parseCgroupMemoryLimit("")).toBeNull(); + expect(parseCgroupMemoryLimit("garbage")).toBeNull(); + expect(parseCgroupMemoryLimit("0")).toBeNull(); + }); + + it("reads cgroup v2 first, then v1, and gives null without either", () => { + const files: Record = { + "/sys/fs/cgroup/memory/memory.limit_in_bytes": `${1024 * MiB}`, + }; + const read = (path: string) => { + if (path in files) return files[path]; + throw new Error("ENOENT"); + }; + expect(containerMemoryLimitMb(read)).toBe(1024); + files["/sys/fs/cgroup/memory.max"] = `${2048 * MiB}`; + expect(containerMemoryLimitMb(read)).toBe(2048); + files["/sys/fs/cgroup/memory.max"] = "max"; + expect(containerMemoryLimitMb(read)).toBeNull(); + expect( + containerMemoryLimitMb(() => { + throw new Error("ENOENT"); + }), + ).toBeNull(); + }); + + it("flags only a known limit under the floor", () => { + expect(isBelowMemoryFloor(1024)).toBe(true); + expect(isBelowMemoryFloor(1535)).toBe(true); + expect(isBelowMemoryFloor(1536)).toBe(false); + expect(isBelowMemoryFloor(4096)).toBe(false); + expect(isBelowMemoryFloor(null)).toBe(false); + }); + + it("logs exactly one line at boot below the floor, none otherwise", () => { + const warn = vi.fn(); + expect(logMemoryFloorAtBoot(1024, warn)).toBe(1024); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch( + /^\[boot\] The container memory limit of 1024 MB is below the supported floor of 1536 MB\./, + ); + expect(warn.mock.calls[0][0]).not.toContain("\n"); + warn.mockClear(); + logMemoryFloorAtBoot(1536, warn); + logMemoryFloorAtBoot(null, warn); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/support/__tests__/support-report-redaction.test.ts b/src/lib/support/__tests__/support-report-redaction.test.ts index 72bc7f1ea..f42c18c92 100644 --- a/src/lib/support/__tests__/support-report-redaction.test.ts +++ b/src/lib/support/__tests__/support-report-redaction.test.ts @@ -57,6 +57,7 @@ function seededFacts(): SupportReportFacts { node: "v22.11.0", processType: "web,worker", heapLimitMb: 792, + containerMemoryLimitMb: 1536, uptimeSeconds: 7260, }, database: { @@ -219,6 +220,30 @@ describe("support report redaction", () => { expect(enough).not.toContain("Heap limit note"); }); + it("says when the container runs below the supported memory floor", () => { + const facts = seededFacts(); + const below = redactSupportText( + renderSupportReport({ + ...facts, + build: { ...facts.build, containerMemoryLimitMb: 1024 }, + }), + CONTEXT, + ); + expect(below).toContain("- Container memory limit: 1024 MB"); + expect(below).toContain( + "- Memory note: Below the supported floor. The container memory limit of 1024 MB is below the supported floor of 1536 MB.", + ); + const atFloor = renderSupportReport(facts); + expect(atFloor).toContain("- Container memory limit: 1536 MB"); + expect(atFloor).not.toContain("Memory note"); + const unknown = renderSupportReport({ + ...facts, + build: { ...facts.build, containerMemoryLimitMb: null }, + }); + expect(unknown).toContain("- Container memory limit: none or unreadable"); + expect(unknown).not.toContain("Memory note"); + }); + it("the seeds really are in the unredacted text", () => { const raw = renderSupportReport(seededFacts()); for (const value of FORBIDDEN) expect(raw).toContain(value); diff --git a/src/lib/support/heap-limit.ts b/src/lib/support/heap-limit.ts index 80f0dcfc6..dcf6a512b 100644 --- a/src/lib/support/heap-limit.ts +++ b/src/lib/support/heap-limit.ts @@ -9,6 +9,7 @@ * system status say so in one sentence, because the number alone ("Heap * limit: 524 MB") reads as a fact rather than as the cause (#1239). */ +import { readFileSync } from "node:fs"; import { getHeapStatistics } from "node:v8"; /** Below this, the heap is flagged. Between the 1g (524) and 1536m (792) heaps. */ @@ -38,3 +39,84 @@ export function lowHeapLimitAdvice(limitMb: number): string { "docker compose up -d." ); } + +/** + * The smallest container memory limit HealthLog supports: the compose + * default. Below it the heap is too small for a long-used account and a + * large import, so the boot log, the support report and Admin > System + * status say the host runs below the supported floor. The app keeps + * running; the floor is advice, not a gate. + */ +export const MEMORY_FLOOR_MB = 1536; + +/** + * The cgroup files that hold a container's memory limit: cgroup v2 first, + * then v1. A v1 host without a limit reports a huge sentinel value rather + * than "max", so anything at or above 1 TiB counts as unlimited. + */ +const CGROUP_LIMIT_FILES = [ + "/sys/fs/cgroup/memory.max", + "/sys/fs/cgroup/memory/memory.limit_in_bytes", +] as const; +const UNLIMITED_BYTES = 1024 ** 4; + +/** Parse one cgroup limit file's content; `null` for no limit or garbage. */ +export function parseCgroupMemoryLimit(raw: string): number | null { + const text = raw.trim(); + if (text === "" || text === "max") return null; + if (!/^\d+$/.test(text)) return null; + const bytes = Number(text); + if (!Number.isFinite(bytes) || bytes <= 0 || bytes >= UNLIMITED_BYTES) { + return null; + } + return megabytes(bytes); +} + +/** + * This process's container memory limit in megabytes, or `null` when there + * is none or it cannot be read (no cgroup, not Linux, unlimited). + */ +export function containerMemoryLimitMb( + read: (path: string) => string = (path) => readFileSync(path, "utf8"), +): number | null { + for (const file of CGROUP_LIMIT_FILES) { + let raw: string; + try { + raw = read(file); + } catch { + continue; + } + return parseCgroupMemoryLimit(raw); + } + return null; +} + +/** Whether a known container limit is below the supported floor. */ +export function isBelowMemoryFloor(limitMb: number | null): boolean { + return limitMb !== null && limitMb < MEMORY_FLOOR_MB; +} + +/** The sentence the boot log and the support report use for it. */ +export function memoryFloorAdvice(limitMb: number): string { + return ( + `The container memory limit of ${limitMb} MB is below the supported ` + + `floor of ${MEMORY_FLOOR_MB} MB. HealthLog keeps running, but a ` + + "long-used account or a large import can fill the heap and restart " + + `the app. Set APP_MEMORY_LIMIT=${MEMORY_FLOOR_MB}m or more in .env and ` + + "run docker compose up -d; the host needs 3 GB of RAM, 4 GB recommended." + ); +} + +/** + * Log one line at boot when the container runs below the floor. Never + * throws. Returns the limit it found, for tests. + */ +export function logMemoryFloorAtBoot( + limitMb: number | null = containerMemoryLimitMb(), + warn: (line: string) => void = console.warn, +): number | null { + if (isBelowMemoryFloor(limitMb)) { + warn(`[boot] ${memoryFloorAdvice(limitMb as number)}`); + } + return limitMb; +} diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts index 1160b0327..d186ee20b 100644 --- a/src/lib/support/report.ts +++ b/src/lib/support/report.ts @@ -22,10 +22,13 @@ import { prisma } from "@/lib/db"; import { failureCondition } from "@/lib/jobs/failure-condition"; import { + containerMemoryLimitMb, heapLimitMb, + isBelowMemoryFloor, isHeapLimitLow, lowHeapLimitAdvice, megabytes, + memoryFloorAdvice, } from "@/lib/support/heap-limit"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; @@ -85,6 +88,8 @@ export interface SupportReportFacts { node: string; processType: string; heapLimitMb: number; + /** The container's memory limit; `null` when there is none or it cannot be read. */ + containerMemoryLimitMb: number | null; uptimeSeconds: number; }; database: { @@ -617,6 +622,7 @@ export async function collectSupportReportFacts( node: process.version, processType: getProcessType(), heapLimitMb: heapLimitMb(), + containerMemoryLimitMb: containerMemoryLimitMb(), uptimeSeconds: Math.round(process.uptime()), }, database, @@ -730,6 +736,18 @@ export function renderSupportReport(facts: SupportReportFacts): string { row("Built at", facts.build.builtAt); row("Node", facts.build.node); row("Process type", facts.build.processType); + row( + "Container memory limit", + facts.build.containerMemoryLimitMb === null + ? "none or unreadable" + : `${facts.build.containerMemoryLimitMb} MB`, + ); + if (isBelowMemoryFloor(facts.build.containerMemoryLimitMb)) { + row( + "Memory note", + `Below the supported floor. ${memoryFloorAdvice(facts.build.containerMemoryLimitMb as number)}`, + ); + } row("Heap limit", `${facts.build.heapLimitMb} MB`); if (isHeapLimitLow(facts.build.heapLimitMb)) { row("Heap limit note", lowHeapLimitAdvice(facts.build.heapLimitMb)); From d44af0646782b107f2ef02f55f0bb6373ab34346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:37:22 +0200 Subject: [PATCH 14/98] docs: publish the UI standards in docs/contributing The binding UI rulebook (card anatomy, spacing scale, text colour, tokens, primitive mapping, enforcement) lived outside the repository, so a contributor could follow a code comment to a file they could not open. It now lives at docs/contributing/ui-standards.md, every comment that cited it points there, and the docs index and ui-guidelines.md say which document wins when they disagree. --- docs/README.md | 6 +- docs/contributing/ui-standards.md | 184 ++++++++++++++++++ docs/ui-guidelines.md | 9 +- e2e/settings-overscroll.spec.ts | 2 +- ...v137-sharing-managed-profiles-a11y.spec.ts | 2 +- eslint-plugins/healthlog/spacing-scale.js | 4 +- .../sharing-recoverable-errors.test.tsx | 2 +- src/app/achievements/page.tsx | 2 +- src/app/globals.css | 2 +- src/app/insights/workouts/[id]/page.tsx | 2 +- .../charts/nutrient-daily-bar-chart.tsx | 2 +- src/components/daily/today-hero.tsx | 2 +- src/components/day/day-sections.tsx | 2 +- .../insights/__tests__/hero-strip.test.tsx | 2 +- .../sleep-stage-stacked-bar.test.tsx | 2 +- .../chat-bubble-unverified-notice.test.tsx | 2 +- .../insights/coach-panel/chat-bubble.tsx | 4 +- src/components/insights/ecg-detail.tsx | 2 +- src/components/insights/hero-strip.tsx | 2 +- .../insights/insight-section-card.tsx | 2 +- .../insights/intraday-pulse-chart.tsx | 2 +- src/components/insights/labs-changes-card.tsx | 2 +- .../insights/medication-efficacy-summary.tsx | 2 +- .../insights/nutrients/hydration-card.tsx | 2 +- src/components/layout/auth-shell.tsx | 2 +- src/components/layout/nav-model.ts | 2 +- .../layout/shared-record-banner.tsx | 2 +- .../medications/medication-order-editor.tsx | 2 +- .../module-list/module-order-editor.tsx | 2 +- .../mood/manage/tag-groups-card.tsx | 2 +- .../mood/manage/tag-manager-card.tsx | 2 +- src/components/onboarding/step-heading.tsx | 2 +- .../managed-profile-affordances.test.tsx | 6 +- .../settings/access/grant-action-error.tsx | 2 +- .../settings/access/grant-invite-card.tsx | 2 +- src/components/settings/access/grant-row.tsx | 2 +- .../settings/access/managed-profile-card.tsx | 8 +- .../access/managed-profile-create-form.tsx | 2 +- .../access/managed-profile-edit-form.tsx | 2 +- .../access/managed-profile-guardians.tsx | 2 +- .../access/managed-profile-handover.tsx | 2 +- .../settings/dashboard-layout-section.tsx | 2 +- .../report-selection/report-scope-picker.tsx | 2 +- .../settings/share-link-create-form.tsx | 2 +- src/components/ui/query-error-row.tsx | 2 +- tests/fixtures/v137/e2e-journeys.ts | 2 +- 46 files changed, 244 insertions(+), 55 deletions(-) create mode 100644 docs/contributing/ui-standards.md diff --git a/docs/README.md b/docs/README.md index 34465adf4..aabff21cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,7 +56,11 @@ notes, and machine-readable specs that ship alongside the source: — the iOS submission gate. - [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT OAuth protocol reverse-engineering notes. -- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style. +- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the + binding UI standards: card anatomy, spacing, text colour, tokens and + which primitive to use where. Read before touching any surface. +- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where + it disagrees with the UI standards, the standards win. If you're a self-hoster looking to spin up an instance, start at or jump to the [Quick Start in the project diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md new file mode 100644 index 000000000..f2c982788 --- /dev/null +++ b/docs/contributing/ui-standards.md @@ -0,0 +1,184 @@ +# UI standards + +The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request. + +--- + +## 1. Card anatomy + +**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap** — never from slot padding. + +- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: ``. +- Two densities, nothing else: + - `default` — Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`. + - `compact` — `` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card). + - No `md:py-5` half-steps. +- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input). +- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile) — never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`). +- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes ``, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`) — the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it. +- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card. + +### Header patterns — exactly three, pick by surface + +| Surface | Primitive | Contract | +| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot | +| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below | +| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot | + +**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent — primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node — the primitive owns size and colour. + +The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them — do not invent a fourth header language. + +## 2. Spacing scale + +> **Scroll height is shell-owned (over-scroll class, #154 lineage).** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
` — nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. + +- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication-card rhythm uses them docblock-sanctioned across ~78 sites and the in-between steps earn their keep in dense card interiors — reality wins over a sweep that would only churn settled surfaces. `5` (20 px) and `py-5`, `p-5` are banned in new code, swept on touch. `7` (28 px) is banned with them — it entered as the Settings/Admin `pl-7` body gutter and is gone (§13). +- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. +- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`. +- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding. +- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`. +- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already). + +## 3. Text colour rules + +The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers: + +- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted. +- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader) — keep it; do not "fix" it to foreground. +- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `

` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run — it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it. +- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot. +- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1 — below AA; they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor). +- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node. +- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast — never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented. +- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme. + +## 4. Colour token rules + +- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune. +- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values. +- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot — review for it explicitly until the rule covers it. +- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`). +- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`. + +## 5. Typography scale + +| Tier | Classes | Where | +| ------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------- | +| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader` — weight is **bold**, always | +| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` | +| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` | +| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters — never muted prose | +| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings | +| Body | `text-sm` (foreground) | default content | +| Meta | `text-xs text-muted-foreground` | timestamps, units, hints — the floor | +| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only | + +No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling). + +## 6. Component mapping — when to use which primitive + +| Situation | Use | Never | +| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Page title + description + actions | `PageHeader` — `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) | +| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"` — one real `

`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `

` | +| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections | +| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` | +| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div | +| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box | +| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs | +| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it | +| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) | +| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` | +| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) | +| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset` — keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error | +| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}` — same decision as the other two | either of the above | +| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` | +| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) | +| Honest no-data state | `EmptyState` | reusing error copy — a failed query must never fall through to "no data yet" | +| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) | +| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | +| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`) — tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined ``; + const draftCard = ``; + expect( + savesInstantly(splitCardUnits(mixedCard)[0]) && + savesExplicitly(splitCardUnits(mixedCard)[0]), + ).toBe(true); + expect(savesInstantly(splitCardUnits(draftCard)[0])).toBe(false); + }); + + it("finds no card that mixes instant and explicit saving", () => { + const { mixed, units, files } = scan(); + // An empty scan would pass for the wrong reason. + expect(files).toBeGreaterThan(50); + expect(units).toBeGreaterThan(100); + expect( + mixed, + `cards mixing both save models:\n${mixed.join("\n")}`, + ).toEqual([]); + }); + + it("keeps every allowlist entry pointing at a unit that still mixes", () => { + for (const key of Object.keys(ALLOWLIST)) { + const [file, index] = key.split("#"); + const source = readFileSync(join(ROOT, file), "utf8"); + const unit = splitCardUnits(source)[Number(index)]; + expect(unit, key).toBeDefined(); + expect(savesInstantly(unit, source) && savesExplicitly(unit), key).toBe( + true, + ); + } + }); +}); diff --git a/src/components/settings/__tests__/token-revoke-labels.test.ts b/src/components/settings/__tests__/token-revoke-labels.test.ts index 880765edc..04d528293 100644 --- a/src/components/settings/__tests__/token-revoke-labels.test.ts +++ b/src/components/settings/__tests__/token-revoke-labels.test.ts @@ -26,10 +26,14 @@ function read(rel: string): string { return readFileSync(join(process.cwd(), rel), "utf8"); } -/** The body of every `…` block. */ +/** + * Every confirm the source spells: an `…` + * block, or a `` whose `confirmLabel` is the action label. + */ function actions(source: string): string[] { return [ ...source.matchAll(//g), + ...source.matchAll(//g), ].map((m) => m[0]); } @@ -41,7 +45,9 @@ describe("token revoke labels", () => { ); expect(revokes.length).toBeGreaterThan(0); for (const action of revokes) { - expect(action).toContain('t("settings.tokenRevokeAction")'); + expect(action).toMatch( + /(confirmLabel=\{|>\s*\{)t\("settings\.tokenRevokeAction"\)/, + ); } }); diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index d490d8186..a3c74d5b9 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -4,17 +4,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-button"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -137,62 +127,38 @@ function DataResetCard() {

)} - setConfirmOpen(true)} + variant="destructive" + size="sm" + disabled={deleting} + className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto" + > + {deleting ? ( + + ) : ( + + )} + {t("settings.dangerZone")} + + { - // Hold the dialog open while the destructive mutation is in - // flight so the in-dialog pending state stays visible and a - // stray backdrop tap can't dismiss it mid-request. + // Hold the dialog open while the request runs so the pending + // state stays on screen and a stray dismissal cannot fire it twice. if (deleting) return; setConfirmOpen(open); }} - > - - - - - - - {t("settings.dangerZoneConfirm")} - - - {t("settings.dangerZoneConfirmDescription")} - - - - - {t("common.cancel")} - - { - e.preventDefault(); - void handleDeleteAllData(); - }} - disabled={deleting} - aria-busy={deleting || undefined} - data-slot="settings-data-reset-confirm" - > - {deleting && ( - - )} - {t("settings.finalDelete")} - - - - + title={t("settings.dangerZoneConfirm")} + body={t("settings.dangerZoneConfirmDescription")} + confirmLabel={t("settings.finalDelete")} + onConfirm={() => void handleDeleteAllData()} + pending={deleting} + slot="settings-data-reset" + holdOpen + destructive + /> ); @@ -291,63 +257,39 @@ function AccountDeleteCard() {

)} - setConfirmOpen(true)} + variant="destructive" + size="sm" + disabled={deleting} + className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto" + data-slot="settings-account-delete-trigger" + > + {deleting ? ( + + ) : ( + + )} + {t("settings.deleteAccountCta")} + + { - // Hold the dialog open while the irreversible delete runs so - // the pending state stays on screen and a stray dismissal - // can't fire a second request. + // Hold the dialog open while the request runs so the pending + // state stays on screen and a stray dismissal cannot fire it twice. if (deleting) return; setConfirmOpen(open); }} - > - - - - - - - {t("settings.deleteAccountConfirmTitle")} - - - {t("settings.deleteAccountConfirmDescription")} - - - - - {t("common.cancel")} - - { - e.preventDefault(); - void handleDeleteAccount(); - }} - disabled={deleting} - aria-busy={deleting || undefined} - data-slot="settings-account-delete-confirm" - > - {deleting && ( - - )} - {t("settings.deleteAccountFinal")} - - - - + title={t("settings.deleteAccountConfirmTitle")} + body={t("settings.deleteAccountConfirmDescription")} + confirmLabel={t("settings.deleteAccountFinal")} + onConfirm={() => void handleDeleteAccount()} + pending={deleting} + slot="settings-account-delete" + holdOpen + destructive + /> ); diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index c563d943a..1c6631ec0 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -18,18 +18,8 @@ import { X, } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -246,30 +236,17 @@ export function FallbackChainCard({

)} - - - - - - - - {t("settings.ai.providerChain.resetConfirmTitle")} - - - {t("settings.ai.providerChain.resetConfirmBody")} - - - - {t("common.cancel")} - - {t("settings.ai.providerChain.resetDefaults")} - - - - + void resetToDefaults()} + size="sm" + className="min-h-11 sm:min-h-9" + icon={} + destructive + /> - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevokeAction")} - - - - + handleRevoke(tok.id)} + size="icon" + variant="ghost" + className="text-muted-foreground hover:text-foreground h-9 w-9" + icon={} + destructive + /> ); @@ -690,39 +660,17 @@ function ApiTokensCard() { ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

- - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevokeAction")} - - - - + handleRevoke(tok.id)} + size="sm" + className="min-h-11 w-full" + icon={} + destructive + /> ); })} diff --git a/src/components/settings/channel-form-dirty.ts b/src/components/settings/channel-form-dirty.ts index 24fd4d928..cc2a77210 100644 --- a/src/components/settings/channel-form-dirty.ts +++ b/src/components/settings/channel-form-dirty.ts @@ -7,16 +7,29 @@ * note left over from an earlier save would say the opposite of the truth. * A secret field (header value, auth token, bot token) is write-only: the * server never returns it, so any typed value counts as a change. + * + * The enable switch is a field of the same form: it saves with the one Save + * button like every other field, so a flipped switch is a change too. */ +function enabledChanged( + form: { enabled: boolean }, + saved: { enabled?: boolean }, +): boolean { + return form.enabled !== (saved.enabled ?? false); +} + export function isWebhookFormDirty( form: { url: string; headerName: string; headerValue: string; format: "generic" | "gotify"; + enabled: boolean; }, - saved: { url: string; headerName: string; format?: string } | undefined, + saved: + | { url: string; headerName: string; format?: string; enabled?: boolean } + | undefined, ): boolean { if (!saved) return false; const savedFormat = saved.format === "gotify" ? "gotify" : "generic"; @@ -24,34 +37,45 @@ export function isWebhookFormDirty( form.url !== saved.url || form.headerName !== saved.headerName || form.headerValue !== "" || - form.format !== savedFormat + form.format !== savedFormat || + enabledChanged(form, saved) ); } export function isNtfyFormDirty( - form: { serverUrl: string; topic: string; authToken: string }, - saved: { serverUrl: string; topic: string } | undefined, + form: { + serverUrl: string; + topic: string; + authToken: string; + enabled: boolean; + }, + saved: { serverUrl: string; topic: string; enabled?: boolean } | undefined, ): boolean { if (!saved) return false; return ( form.serverUrl !== saved.serverUrl || form.topic !== saved.topic || - form.authToken !== "" + form.authToken !== "" || + enabledChanged(form, saved) ); } export function isEmailFormDirty( - form: { recipient: string }, - saved: { recipient: string } | undefined, + form: { recipient: string; enabled: boolean }, + saved: { recipient: string; enabled?: boolean } | undefined, ): boolean { if (!saved) return false; - return form.recipient !== saved.recipient; + return form.recipient !== saved.recipient || enabledChanged(form, saved); } export function isTelegramFormDirty( - form: { botToken: string; chatId: string }, - saved: { chatId?: string | null } | undefined, + form: { botToken: string; chatId: string; enabled: boolean }, + saved: { chatId?: string | null; enabled?: boolean } | undefined, ): boolean { if (!saved) return false; - return form.botToken.trim() !== "" || form.chatId !== (saved.chatId ?? ""); + return ( + form.botToken.trim() !== "" || + form.chatId !== (saved.chatId ?? "") || + enabledChanged(form, saved) + ); } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index c5fa86302..9a991caca 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -34,17 +34,6 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Brain, Loader2, Pencil, Trash2 } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -375,54 +364,20 @@ export function CoachMemorySection({

{facts.length > 0 ? ( - - - - - - - - {t("settings.ai.coachMemory.forgetAllConfirmTitle")} - - - {t("settings.ai.coachMemory.forgetAllConfirmBody")} - - - - - {t("settings.ai.coachMemory.cancel")} - - forgetAll.mutate()} - > - {forgetAll.isPending && ( - - )} - {t("settings.ai.coachMemory.forgetAllConfirmAction")} - - - - + forgetAll.mutate()} + pending={forgetAll.isPending} + disabled={!isAuthenticated} + size="sm" + className="min-h-11 sm:min-h-9" + icon={} + testId="settings-coach-memory-forget-all" + destructive + /> ) : null} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 51dcafd28..8128ad160 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -765,6 +765,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { confirmLabel={t("dashboard.layoutResetConfirm")} pending={resetMutation.isPending} onConfirm={() => resetMutation.mutate()} + destructive /> {dirty && ( <> diff --git a/src/components/settings/date-format-select.tsx b/src/components/settings/date-format-select.tsx index 0b3b222b7..cf53eae8a 100644 --- a/src/components/settings/date-format-select.tsx +++ b/src/components/settings/date-format-select.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; + import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useAuth } from "@/hooks/use-auth"; @@ -39,30 +42,6 @@ export function DateFormatSelect({ const [optimistic, setOptimistic] = useState( null, ); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO"; const mutation = useMutation({ @@ -84,31 +63,19 @@ export function DateFormatSelect({ // Repaint every formatter consumer right away; the /me refetch below // re-asserts the same value once it settles. storeDateFormat(next); - setMsg(t("settings.dateFormat.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.dateFormat.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); setOptimistic(null); - scheduleClear(); }, onError: (err) => { setOptimistic(null); - setMsg( - err instanceof Error ? err.message : t("settings.dateFormat.saveError"), - ); - setMsgType("error"); - scheduleClear(); + showErrorToast(t("settings.dateFormat.saveError"), { cause: err }); }, }); function handleSelect(next: DateFormatPreference) { if (next === value || mutation.isPending || !isAuthenticated) return; setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } mutation.mutate(next); } @@ -128,16 +95,8 @@ export function DateFormatSelect({ ))} -

- {msg ?? t("settings.dateFormat.description")} +

+ {t("settings.dateFormat.description")}

); diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx index 5cd80fe91..0ea1b1657 100644 --- a/src/components/settings/email-card.tsx +++ b/src/components/settings/email-card.tsx @@ -13,11 +13,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isEmailFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -33,10 +30,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [recipient, setRecipient] = useState(""); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); + // The enable switch is a draft field: it saves with the form's one Save. + const [enabled, setEnabled] = useState(false); + const [saveError, setSaveError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.settingsEmail(), @@ -47,16 +43,19 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { }); // React-recommended sync-from-server pattern (no setState-in-effect). - const settingsKey = settings ? settings.recipient : null; + const settingsKey = settings + ? `${settings.recipient}|${settings.enabled}` + : null; const [seededKey, setSeededKey] = useState(null); if (settingsKey !== null && settingsKey !== seededKey) { setSeededKey(settingsKey); setRecipient(settings!.recipient); + setEnabled(settings!.enabled); } - const dirty = isEmailFormDirty({ recipient }, settings); + const dirty = isEmailFormDirty({ recipient, enabled }, settings); const save = useMutation({ - mutationFn: async (enabled: boolean) => { + mutationFn: async () => { const res = await apiFetchRaw("/api/settings/email", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -69,45 +68,12 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); + setSaveError(null); + toastWrittenOutcome("success", t("settings.saved")); }, onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - EmailSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled("/api/settings/email", enabled, t("common.error")), - onMutate: async (enabled) => { - const queryKey = queryKeys.settingsEmail(); - await queryClient.cancelQueries({ queryKey }); - setSaveMsg(null); - setSaveMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous); - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); + setSaveError(err.message); + showErrorToast(err.message, { cause: err }); }, }); @@ -128,16 +94,16 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { toggleEnabled.mutate(checked)} - disabled={save.isPending || toggleEnabled.isPending} + checked={enabled} + onCheckedChange={setEnabled} + disabled={save.isPending} /> { e.preventDefault(); - save.mutate(settings?.enabled ?? false); + save.mutate(); }} className="space-y-4" > @@ -156,12 +122,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { /> - {saveMsg && (saveMsgType === "error" || !dirty) && ( -

- {saveMsg} + {saveError && ( +

+ {saveError}

)} @@ -173,7 +136,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { /> - - - - - - - - {t("settings.deletePasskey")} - - - {t("settings.deletePasskeyDescription")} - - - - - {t("common.cancel")} - - remove.mutate(pk.id)} - > - {remove.isPending && ( - - )} - {t("common.delete")} - - - - + remove.mutate(pk.id)} + pending={remove.isPending && remove.variables === pk.id} + disabled={remove.isPending} + size="icon" + variant="ghost" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + icon={} + destructive + /> )} diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index ce4b99415..8bb5c0a8a 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -13,18 +13,8 @@ import { X, } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; import { Input } from "@/components/ui/input"; import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -308,46 +298,23 @@ export function SecurityKeysCard({ > - - - - - - - - {t("settings.security.keys.remove")} - - - {t("settings.security.keys.removeConfirm")} - - - - - {t("common.cancel")} - - remove.mutate(key.id)} - > - {remove.isPending && ( - - )} - {t("common.delete")} - - - - + remove.mutate(key.id)} + pending={ + remove.isPending && remove.variables === key.id + } + disabled={remove.isPending} + size="icon" + variant="ghost" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + icon={} + destructive + /> )} diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 17e62753f..2acac8456 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -498,6 +498,7 @@ export function TotpCard({ {t("common.cancel")} - - - - - - - {t("settings.sharing.revoke")} - - - {t("settings.sharing.revokeDescription")} - - - - - {t("common.cancel")} - - revokeMutation.mutate(link.id)} - > - {t("settings.sharing.revoke")} - - - - + revokeMutation.mutate(link.id)} + pending={ + revokeMutation.isPending && + revokeMutation.variables === link.id + } + size="sm" + className="ml-auto flex min-h-11 w-fit sm:min-h-9" + icon={} + destructive + /> ))} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 1860af9dc..4846c99eb 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -697,6 +697,7 @@ export function SourcesSection() { disabled={saveMutation.isPending} pending={resetMutation.isPending} onConfirm={() => resetMutation.mutate()} + destructive /> {dirty && priority ? ( <> diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index 3c685ce6b..551974655 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Info, Loader2, MessageCircle, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -39,9 +36,10 @@ export function TelegramCard({ const queryClient = useQueryClient(); const [botToken, setBotToken] = useState(""); const [chatId, setChatId] = useState(""); + // The enable switch is a draft field: it saves with the form's one Save. + const [enabled, setEnabled] = useState(false); const [saving, setSaving] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const [error, setError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.telegramSettings(), @@ -52,67 +50,25 @@ export function TelegramCard({ }); // Sync from server using the React-recommended "previous-payload-id" - // pattern instead of setState-in-effect. This avoids the strict - // `react-hooks/set-state-in-effect` lint failure that the legacy file - // disabled at module scope. - const settingsKey = settings ? (settings.chatId ?? "") : null; + // pattern instead of setState-in-effect. Reseeds after every save, so the + // switch reads what the server stored. + const settingsKey = settings + ? `${settings.chatId ?? ""}|${settings.enabled}` + : null; const [seededKey, setSeededKey] = useState(null); if (settingsKey && settingsKey !== seededKey) { setSeededKey(settingsKey); if (settings!.chatId) setChatId(settings!.chatId); + setEnabled(settings!.enabled); } - const dirty = isTelegramFormDirty({ botToken, chatId }, settings); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - TelegramSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled( - "/api/settings/telegram", - enabled, - t("settings.savingError"), - ), - onMutate: async (enabled) => { - const queryKey = queryKeys.telegramSettings(); - await queryClient.cancelQueries({ queryKey }); - setMsg(null); - setMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setMsg(t("settings.saved")); - setMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled( - queryClient, - queryKeys.telegramSettings(), - previous, - ); - setMsg(err.message); - setMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() }); - }, - }); + const dirty = isTelegramFormDirty({ botToken, chatId, enabled }, settings); async function handleSave(e: React.FormEvent) { e.preventDefault(); setSaving(true); - setMsg(null); - setMsgType(null); + setError(null); - const body: Record = { - enabled: settings?.enabled ?? false, - }; + const body: Record = { enabled }; if (botToken.trim()) body.botToken = botToken.trim(); if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; @@ -126,16 +82,16 @@ export function TelegramCard({ }).catch(() => null); if (res?.ok) { - setMsg(t("settings.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.saved")); setBotToken(""); queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); } else { const json = (await res?.json().catch(() => null)) as { error?: string; } | null; - setMsg(json?.error || t("settings.savingError")); - setMsgType("error"); + const message = json?.error || t("settings.savingError"); + setError(message); + showErrorToast(message); } } finally { setSaving(false); @@ -192,9 +148,9 @@ export function TelegramCard({
toggleEnabled.mutate(checked)} - disabled={saving || toggleEnabled.isPending} + checked={enabled} + onCheckedChange={setEnabled} + disabled={saving} />
diff --git a/src/components/settings/time-format-select.tsx b/src/components/settings/time-format-select.tsx index d8c0d823e..528c5e37d 100644 --- a/src/components/settings/time-format-select.tsx +++ b/src/components/settings/time-format-select.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; + import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useAuth } from "@/hooks/use-auth"; @@ -39,30 +42,6 @@ export function TimeFormatSelect({ const [optimistic, setOptimistic] = useState( null, ); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - const value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO"; const mutation = useMutation({ @@ -84,31 +63,19 @@ export function TimeFormatSelect({ // Repaint every formatter consumer right away; the /me refetch below // re-asserts the same value once it settles. storeTimeFormat(next); - setMsg(t("settings.timeFormat.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.timeFormat.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); setOptimistic(null); - scheduleClear(); }, onError: (err) => { setOptimistic(null); - setMsg( - err instanceof Error ? err.message : t("settings.timeFormat.saveError"), - ); - setMsgType("error"); - scheduleClear(); + showErrorToast(t("settings.timeFormat.saveError"), { cause: err }); }, }); function handleSelect(next: TimeFormatPreference) { if (next === value || mutation.isPending || !isAuthenticated) return; setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } mutation.mutate(next); } @@ -126,16 +93,8 @@ export function TimeFormatSelect({ -

- {msg ?? t("settings.timeFormat.description")} +

+ {t("settings.timeFormat.description")}

); diff --git a/src/components/settings/unit-preference-select.tsx b/src/components/settings/unit-preference-select.tsx index 43a435715..0ba6d4712 100644 --- a/src/components/settings/unit-preference-select.tsx +++ b/src/components/settings/unit-preference-select.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; + import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useAuth } from "@/hooks/use-auth"; @@ -36,30 +39,6 @@ export function UnitPreferenceSelect({ const { user } = useAuth(); const [optimistic, setOptimistic] = useState(null); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric"; const mutation = useMutation({ @@ -78,36 +57,22 @@ export function UnitPreferenceSelect({ return next; }, onSuccess: () => { - setMsg(t("settings.dashboard.units.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.dashboard.units.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); queryClient.invalidateQueries({ queryKey: queryKeys.userUnitPreference(), }); setOptimistic(null); - scheduleClear(); }, onError: (err) => { setOptimistic(null); - setMsg( - err instanceof Error - ? err.message - : t("settings.dashboard.units.saveError"), - ); - setMsgType("error"); - scheduleClear(); + showErrorToast(t("settings.dashboard.units.saveError"), { cause: err }); }, }); function handleSelect(next: UnitPreference) { if (next === value || mutation.isPending || !isAuthenticated) return; setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } mutation.mutate(next); } @@ -126,16 +91,8 @@ export function UnitPreferenceSelect({ {t("settings.dashboard.units.imperial")} -

- {msg ?? t("settings.dashboard.units.description")} +

+ {t("settings.dashboard.units.description")}

); diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index f09528c7e..de11154fc 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -41,10 +38,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { const [headerName, setHeaderName] = useState(""); const [headerValue, setHeaderValue] = useState(""); const [format, setFormat] = useState("generic"); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); + // The enable switch is a draft field: it saves with the form's one Save. + const [enabled, setEnabled] = useState(false); + const [saveError, setSaveError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.settingsWebhook(), @@ -56,7 +52,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { // React-recommended sync-from-server pattern (no setState-in-effect). const settingsKey = settings - ? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}` + ? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}|${settings.enabled}` : null; const [seededKey, setSeededKey] = useState(null); if (settingsKey && settingsKey !== seededKey) { @@ -64,14 +60,15 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { setUrl(settings!.url); setHeaderName(settings!.headerName); setFormat(settings!.format === "gotify" ? "gotify" : "generic"); + setEnabled(settings!.enabled); } const dirty = isWebhookFormDirty( - { url, headerName, headerValue, format }, + { url, headerName, headerValue, format, enabled }, settings, ); const save = useMutation({ - mutationFn: async (enabled: boolean) => { + mutationFn: async () => { const res = await apiFetchRaw("/api/settings/webhook", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -102,54 +99,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); + setSaveError(null); + toastWrittenOutcome("success", t("settings.saved")); setHeaderValue(""); }, onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - WebhookSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled( - "/api/settings/webhook", - enabled, - t("common.error"), - ), - onMutate: async (enabled) => { - const queryKey = queryKeys.settingsWebhook(); - await queryClient.cancelQueries({ queryKey }); - setSaveMsg(null); - setSaveMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled( - queryClient, - queryKeys.settingsWebhook(), - previous, - ); - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); + setSaveError(err.message); + showErrorToast(err.message, { cause: err }); }, }); @@ -166,16 +122,16 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { toggleEnabled.mutate(checked)} - disabled={save.isPending || toggleEnabled.isPending} + checked={enabled} + onCheckedChange={setEnabled} + disabled={save.isPending} /> { e.preventDefault(); - save.mutate(settings?.enabled ?? false); + save.mutate(); }} className="space-y-4" > @@ -244,12 +200,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { - {saveMsg && (saveMsgType === "error" || !dirty) && ( -

- {saveMsg} + {saveError && ( +

+ {saveError}

)} @@ -261,7 +214,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { /> - - - ); -} diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index 8988fd20b..12ca02e37 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -1,17 +1,16 @@ "use client"; /** - * v1.18.6 — the Labs module's own settings page body. + * The Labs module's settings, in two places since v1.44: * - * Reached from the wrench beside the page's "hinzufügen" button. Lets the - * user: - * - pick the card-vs-list page view (MOD-03), - * - choose the sort order (by most-recent reading, descending / ascending, - * or manual) — MOD-04, - * - reorder biomarkers when sort = manual (MOD-03/04), - * - and create / edit / delete biomarkers via the existing - * `BiomarkerManager` (MOD-04). - * View / sort / order persist client-side via `useModuleListPrefs("labs")`. + * `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view + * (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual + * order of the biomarkers. Order and view only, persisted per account through + * `useModuleListPrefs("labs")`. + * + * `LabsModuleSettings` — Settings > Modules > Labs: the on-device text + * recognition opt-in and the biomarker catalog (`BiomarkerManager`). Reached + * from the wrench on the labs page. */ import { useQuery } from "@tanstack/react-query"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -52,12 +51,6 @@ export function LabsSettings() { const { t } = useTranslations(); const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs"); - // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider - // can't read images still scan a report: the image is OCR'd on-device and - // only the extracted text is sent to the text-only provider. - const localOcr = useLabsLocalOcr(); - const updateLocalOcr = useUpdateLabsLocalOcr(); - const { data, isLoading } = useQuery({ queryKey: queryKeys.biomarkers(), queryFn: () => apiGet("/api/biomarkers"), @@ -130,7 +123,20 @@ export function LabsSettings() { )} ) : null} + + ); +} +export function LabsModuleSettings() { + const { t } = useTranslations(); + // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider + // can't read images still scan a report: the image is OCR'd on-device and + // only the extracted text is sent to the text-only provider. + const localOcr = useLabsLocalOcr(); + const updateLocalOcr = useUpdateLabsLocalOcr(); + + return ( +
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */} Arrangement > Checkups: the card-vs-list + * view and the manual order, persisted per account through + * `useModuleListPrefs("vorsorge")`. + * + * `VorsorgeReminderSwitchesCard` — the per-reminder on/off switches, under + * Settings > Notifications with the other reminders, persisted through the * reminder PATCH route. */ import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react"; @@ -47,7 +48,6 @@ function resolveLabel( export function VorsorgeSettings() { const { t } = useTranslations(); const { data: reminders, isLoading } = useMeasurementReminders(true); - const { update } = useMeasurementReminderMutations(); const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge"); const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id); @@ -59,7 +59,7 @@ export function VorsorgeSettings() { : t("measurementReminders.selfPlanned"), })); - // v1.18.10 — the three Vorsorge settings groups adopt the shared + // v1.18.10 — the Vorsorge settings groups adopt the shared // `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered // surface, neutral muted icon, `text-lg` title, short muted description) // so this surface reads like every other Settings section instead of bare @@ -96,67 +96,80 @@ export function VorsorgeSettings() { )}
- - - -
- {isLoading ? ( - - ) : (reminders?.length ?? 0) === 0 ? ( - - ) : ( - - - {ordered.map((reminder) => ( -
- - {resolveLabel(reminder, t)} - - - update.mutate( - { id: reminder.id, body: { enabled: next } }, - { - onSuccess: () => - toastWrittenOutcome( - "success", - t("settings.saved"), - ), - onError: (failure) => - showErrorToast(t("settings.savingError"), { - cause: failure, - }), - }, - ) - } - disabled={update.isPending} - // Label each row's Switch by its own name span so SR - // users hear the reminder name (plus the switch role's - // on/off state) instead of N identical generic labels. - aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} - /> -
- ))} -
-
- )} -
-
); } + +/** + * The on/off switch of each preventive-care reminder. A reminder, so since + * v1.44 it lives with the other reminders under Settings > Notifications + * (`#vorsorge-reminders`, the wrapper there); the checkups page links + * there. + */ +export function VorsorgeReminderSwitchesCard() { + const { t } = useTranslations(); + const { data: reminders, isLoading } = useMeasurementReminders(true); + const { update } = useMeasurementReminderMutations(); + const { prefs } = useModuleListPrefs("vorsorge"); + const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id); + + return ( + + +
+ {isLoading ? ( + + ) : (reminders?.length ?? 0) === 0 ? ( + + ) : ( + + + {ordered.map((reminder) => ( +
+ + {resolveLabel(reminder, t)} + + + update.mutate( + { id: reminder.id, body: { enabled: next } }, + { + onSuccess: () => + toastWrittenOutcome("success", t("settings.saved")), + onError: (failure) => + showErrorToast(t("settings.savingError"), { + cause: failure, + }), + }, + ) + } + disabled={update.isPending} + // Label each row's Switch by its own name span so SR + // users hear the reminder name (plus the switch role's + // on/off state) instead of N identical generic labels. + aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} + /> +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/records/screening-hints-card.tsx b/src/components/records/screening-hints-card.tsx index 76b7859c1..fa76cec15 100644 --- a/src/components/records/screening-hints-card.tsx +++ b/src/components/records/screening-hints-card.tsx @@ -2,10 +2,11 @@ /** * v1.43 (#614) — screening hints from the life circumstances, in Settings → - * Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound, - * each with its status, the reasons in the account's language and the rule's - * source and country. A hint, never a reminder: nothing is scheduled here, - * and the card says so. Resolved on the server; rendered as delivered. + * Health profile → Medical history (under Checkups until v1.44). Low-dose CT + * for lung cancer and the abdominal-aorta ultrasound, each with its status, + * the reasons in the account's language and the rule's source and country. + * A hint, never a reminder: nothing is scheduled here, and the card says so. + * Resolved on the server; rendered as delivered. * * The card stays away until it has something to say: the person has said * something about smoking, or a hint already applies without it (the German diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index b647fc056..24a3327e7 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -243,6 +243,6 @@ describe(" — dropdown-driven UX (B2)", () => { it("no longer mounts the Coach cards (moved to the Coach section)", () => { activeProviderQuery = "codex"; const html = render(); - expect(html).not.toContain('data-testid="settings-disable-coach-card"'); + expect(html).not.toContain('data-slot="settings-coach-state-card"'); }); }); diff --git a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx index 40ae5dc22..0383d285b 100644 --- a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx +++ b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx @@ -35,6 +35,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () => null, })); +vi.mock("@/components/records/screening-hints-card", () => ({ + ScreeningHintsCard: () => null, +})); vi.mock("@/components/records/life-circumstances-manager", () => ({ LifeCircumstancesManager: () => null, })); diff --git a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx index 83b4a1e14..2db363219 100644 --- a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx +++ b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx @@ -18,6 +18,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () =>
, })); +vi.mock("@/components/records/screening-hints-card", () => ({ + ScreeningHintsCard: () => null, +})); vi.mock("@/components/records/life-circumstances-manager", () => ({ LifeCircumstancesManager: () => null, })); diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx index d93d4b891..aaf49c186 100644 --- a/src/components/settings/__tests__/coach-memory-section.test.tsx +++ b/src/components/settings/__tests__/coach-memory-section.test.tsx @@ -12,8 +12,7 @@ * The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts` * → `queryKeys.coachFacts()` invalidation + toast) is pinned by the * route-shape contract in the component plus the typecheck gate; this - * suite guards the render contract the way the sibling - * `disable-coach-card.test.tsx` does for the toggle. + * suite guards the render contract. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index d7811ef97..101f834fa 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -1,10 +1,10 @@ /** * v1.18.0 — Settings → Coach section. * - * The Coach preference cards (disable toggle, preferences, memory) moved - * out of the AI / Assistant section into their own dedicated, module-gated - * entry. SSR-only smoke test — interactive contracts live in the cards' - * own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`). + * The Coach preference cards (state, preferences, memory) moved out of the + * AI / Assistant section into their own dedicated, module-gated entry. + * SSR-only smoke test — interactive contracts live in the cards' own suites + * (`coach-memory-section.test.tsx`; the switch in `modules-section.test.tsx`). */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -78,34 +78,51 @@ function render(locale: "en" | "de" = "en") { } describe(" — SSR smoke", () => { - it("renders the section scaffold + the Activate-Coach toggle card", () => { + it("shows the Coach state with a link to the one switch, not a second switch", () => { authState.disableCoach = false; const html = render(); expect(html).toContain('id="settings-section-coach-title"'); - expect(html).toContain('data-testid="settings-disable-coach-card"'); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // v1.18.1 — polarity flipped to activate/default-on. - expect(html).toContain("Activate Coach"); - expect(html).toContain("Show the Coach button and drawer"); - // v1.18.6 — the proactive Coach nudge card moved here from - // Benachrichtigungen; its anchor renders under the enabled-coach gate. - expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('data-slot="settings-coach-state-card"'); + expect(html).toContain('data-enabled="true"'); + expect(html).toContain('href="/settings/modules#module-coach"'); + // v1.44 — no switch of its own: one state, one control, in Modules. + const card = html.match( + /data-slot="settings-coach-state-card"[\s\S]*?<\/a>/, + )?.[0]; + expect(card).toBeDefined(); + expect(card).not.toContain('role="switch"'); + expect(html).not.toContain("settings-disable-coach"); // Raw key never leaks past i18n. expect(html).not.toContain("settings.sections.coach."); - expect(html).not.toContain("settings.coach.activate."); + expect(html).not.toContain("settings.coach.state."); }); - it("renders the German Activate-Coach copy", () => { - authState.disableCoach = false; + it("reads off once the account has the Coach off", () => { + mountState.mounted = true; + authState.disableCoach = true; + try { + expect(render()).toContain('data-enabled="false"'); + } finally { + mountState.mounted = false; + authState.disableCoach = false; + } + }); + + it("points the nudges at Settings > Notifications", () => { + const html = render(); + expect(html).toContain('href="/settings/notifications#coach-nudge"'); + expect(html).not.toContain('id="coach-nudge"'); + }); + + it("renders the German copy", () => { const html = render("de"); - expect(html).toContain("Coach aktivieren"); expect(html).not.toContain("settings.sections.coach."); - expect(html).not.toContain("settings.coach.activate."); + expect(html).not.toContain("settings.coach.state."); }); }); 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", () => { + it("hides the nudge pointer 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. @@ -113,10 +130,10 @@ describe(" — the tuning follows the record, not the account", () authState.disableCoach = false; authState.modules = { coach: false }; try { - expect(render()).not.toContain('id="coach-nudge"'); + expect(render()).not.toContain('data-slot="settings-coach-nudge-link"'); authState.disableCoach = true; authState.modules = { coach: true }; - expect(render()).toContain('id="coach-nudge"'); + expect(render()).toContain('data-slot="settings-coach-nudge-link"'); } finally { mountState.mounted = false; authState.disableCoach = false; diff --git a/src/components/settings/__tests__/confirm-style-guard.test.ts b/src/components/settings/__tests__/confirm-style-guard.test.ts index a83d4f0c3..b78389e39 100644 --- a/src/components/settings/__tests__/confirm-style-guard.test.ts +++ b/src/components/settings/__tests__/confirm-style-guard.test.ts @@ -24,8 +24,6 @@ const ALLOWED: Readonly> = { "the add-passkey re-authentication form; the delete confirm uses ConfirmButton", "security-section/totp-card.tsx": "the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style", - "mcp-section.tsx": - "the connection and token revoke confirms; the MCP card is reshaped separately", }; /** Owned by the integration cards, which follow their own card kit. */ diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx deleted file mode 100644 index 87acdf78b..000000000 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * v1.4.47 — Settings → Coach activation toggle persistence contract. - * - * v1.18.1 — the card reads "Activate Coach" (default ON). The persisted - * column is still `User.disableCoach`; the Switch shows the activated view - * (`checked = !disableCoach`). The SSR render is `mounted=false`, where the - * card paints the default-ON (checked) state so the default-on contract holds - * before the wire value resolves — both default and opted-out users render - * checked under `renderToStaticMarkup`. - * - * The card is a small client-only `` that PATCHes - * `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so - * every Coach mount point in the app reacts on the next /me refetch. - * - * Test strategy: use the real `useMutation` + `QueryClient` from - * `@tanstack/react-query`, stub `fetch`, and drive the Switch via a - * `renderToStaticMarkup` pass plus a hand-invoked mutation call. The - * file SSR-renders inside a `` so the hook - * branches execute and the spies fire. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import type { AuthUser } from "@/hooks/use-auth"; - -const authSpy = vi.fn< - () => { user: AuthUser | null; isAuthenticated: boolean } ->(() => ({ user: buildUser(false), isAuthenticated: true })); -vi.mock("@/hooks/use-auth", async () => { - const actual = - await vi.importActual( - "@/hooks/use-auth", - ); - return { - ...actual, - useAuth: () => authSpy(), - }; -}); - -// Stub the navigation hooks the parent `` (and its child -// cards) reach for so the SSR pass doesn't blow up. The disable-coach card -// itself doesn't trigger any GET — it only writes through PATCH on click. -// -// v1.18.0 — the Coach cards moved from `` to the dedicated -// ``; this contract test renders the new parent. -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/settings/coach", - useSearchParams: () => new URLSearchParams(""), -})); - -function buildUser(disableCoach: boolean): AuthUser { - return { - id: "user-1", - username: "testuser", - email: null, - role: "USER", - heightCm: null, - dateOfBirth: null, - gender: null, - timezone: "Europe/Berlin", - onboardingCompletedAt: null, - onboardingTourCompleted: true, - onboardingTourProgress: null, - avatarUrl: null, - glucoseUnit: null, - unitPreference: "metric", - timeFormat: "AUTO", - dateFormat: "AUTO", - disableCoach, - fullName: null, - insurerName: null, - insurerIkNumber: null, - insuranceNumber: null, - lastReportPracticeName: null, - reportSelection: null, - cycleTrackingEnabled: false, - modules: {}, - }; -} - -import { CoachSection } from "../coach-section"; - -function makeFetch() { - return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { - const body = init?.body ? JSON.parse(init.body as string) : {}; - return new Response( - JSON.stringify({ - data: { disableCoach: body.disableCoach ?? false }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - }); -} - -beforeEach(() => { - authSpy.mockClear(); - authSpy.mockImplementation(() => ({ - user: buildUser(false), - isAuthenticated: true, - })); - vi.stubGlobal("fetch", makeFetch()); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function render(): string { - const client = new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); - return renderToStaticMarkup( - - - - - , - ); -} - -describe("Settings — DisableCoachCard", () => { - it("renders the activation Switch checked at SSR (default-on contract)", () => { - const html = render(); - // v1.18.1 — the SSR render is `mounted=false`, where the card paints - // the default-ON (activated → checked) state regardless of the wire value, - // so the Switch never flashes off before /me resolves. - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - expect(html).toContain('data-state="checked"'); - }); - - it("paints the activated (checked) SSR state even for an opted-out user", () => { - authSpy.mockImplementation(() => ({ - user: buildUser(true), - isAuthenticated: true, - })); - const html = render(); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // The activated state resolves on the first client re-render once - // `mounted` flips; the SSR pass stays on the default-ON shape. - expect(html).toContain('data-state="checked"'); - }); - - it("renders the Coach-activation title + description", () => { - const html = render(); - // v1.18.1 — polarity flipped to activate/default-on. - expect(html).toContain("Activate Coach"); - expect(html).toContain("Show the Coach button and drawer"); - }); -}); diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts index b028df7dc..cb8e2b113 100644 --- a/src/components/settings/__tests__/layout-groups.test.ts +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -16,7 +16,6 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../dashboard-section", () => ({ DashboardSection: () => null })); vi.mock("../insights-section", () => ({ InsightsSection: () => null })); vi.mock("../medications-section", () => ({ MedicationsSection: () => null })); -vi.mock("../mood-section", () => ({ MoodSection: () => null })); vi.mock("../labs-section", () => ({ LabsSection: () => null })); vi.mock("../illness-section", () => ({ IllnessSection: () => null })); vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null })); @@ -29,12 +28,13 @@ import { } from "../layout-groups"; describe("layout-groups registry", () => { - it("exposes the eight Appearance modules as statically-generated subpage params", () => { + it("exposes the seven Arrangement modules as statically-generated subpage params", () => { + // v1.44 — mood left: its page was tag management, which lives under + // Settings > Modules > Mood now (`/settings/layout/mood` redirects). expect(LAYOUT_GROUP_IDS).toEqual([ "dashboard", "insights", "medications", - "mood", "labs", "illness", "documents", @@ -45,7 +45,6 @@ describe("layout-groups registry", () => { { module: "dashboard" }, { module: "insights" }, { module: "medications" }, - { module: "mood" }, { module: "labs" }, { module: "illness" }, { module: "documents" }, @@ -66,7 +65,7 @@ describe("layout-groups registry", () => { ? layoutGroupModule(id) : "not-a-group"; expect(gate("medications")).toBe("medications"); - expect(gate("mood")).toBe("mood"); + expect(gate("mood")).toBe("not-a-group"); expect(gate("labs")).toBe("labs"); expect(gate("illness")).toBe("illness"); // dashboard / insights / vorsorge are never gated. diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index 5b4935d64..97d29dadd 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -54,7 +54,6 @@ describe(" hub", () => { "dashboard", "insights", "medications", - "mood", "labs", "illness", "vorsorge", @@ -68,7 +67,7 @@ describe(" hub", () => { expect(html).not.toContain('href="/settings/layout/medications"'); expect(html).not.toContain('href="/settings/layout/labs"'); // Other modules unaffected; Vorsorge is never gated. - expect(html).toContain('href="/settings/layout/mood"'); + expect(html).toContain('href="/settings/layout/illness"'); expect(html).toContain('href="/settings/layout/vorsorge"'); }); @@ -82,6 +81,7 @@ describe(" hub", () => { expect(html).toContain('href="/settings/layout/dashboard"'); expect(html).toContain('href="/settings/layout/insights"'); expect(html).toContain('href="/settings/layout/vorsorge"'); + // v1.44 — mood is tag management and left Arrangement for Modules. expect(html).not.toContain('href="/settings/layout/mood"'); }); diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 65e5e7db5..141437220 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -137,11 +137,11 @@ describe(" — SSR smoke", () => { expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin")); }); - it("gathers the injection-sites card (moved from the account profile)", () => { - // v1.18.0 — injection-site exclusions are a medication setting and - // now live on this screen alongside the list view + order. + it("holds order and view only; the injection sites live under Modules", () => { + // v1.44 — Arrangement holds order and view; the injection-site + // exclusions moved to Settings > Modules > Medications. const html = render(); - expect(html).toContain("Globally excluded injection sites"); + expect(html).not.toContain("Globally excluded injection sites"); }); it("feeds the editor the saved order (active block re-sorted by layout.order)", () => { diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index 2735bb59c..2d2fbbb76 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -131,7 +131,6 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "./section-frame-harness"; import { MoodSection } from "../mood-section"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; @@ -142,9 +141,7 @@ function render(locale: "en" | "de" = "en"): string { // shared frame the route wraps the section in. return renderToStaticMarkup( - - - + , ); } @@ -159,7 +156,8 @@ describe(" — SSR smoke", () => { // v1.18.1 — the section blurb was dropped; the sr-only heading + the // card content carry the meaning. const html = render(); - expect(html).toContain("settings-section-mood-title"); + // v1.44 — the page lives under Settings > Modules > Mood; its heading + // comes from the route, not from a frame here. expect(html).not.toContain("settings.sections."); expect(html).not.toContain("mood.manage."); }); diff --git a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx index c5cbbd4cc..0416810c0 100644 --- a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx +++ b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx @@ -1,14 +1,12 @@ /** - * v1.18.0 — module-gated per-type visibility on the consolidated - * "Benachrichtigungen" reminder-types home. + * Settings > Notifications is the one home for every reminder kind, each + * card gated on its module and failing open. * - * The mood reminder shows only when the `mood` module is enabled. Low-stock - * maps to the medications domain (shown unless explicitly disabled). The gate - * fails OPEN, so a `/me` payload without a module map keeps every card - * visible. - * - * v1.18.6 — the proactive Coach nudge card moved to Settings → Coach, so - * it no longer appears on this screen. + * v1.18.0 — mood reminder on `mood`, low-stock on `medications`. + * v1.44 — the Coach nudges came back (gated on the Coach surface), the + * checkup reminder switches joined (not a module, always shown), and a card + * names the reminders set where their subject lives (dose times on the + * medication, the cycle reminders in the per-event matrix). */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -25,8 +23,9 @@ vi.mock("@tanstack/react-query", () => ({ useMutation: () => ({ mutate: vi.fn(), isPending: false }), })); -// The auth mock is driven per-test via a mutable ref so each render can pick -// a different module map without re-mocking the module. +// Mounted, so the Coach gate reads the module map like a hydrated page. +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + const authRef: { modules?: Record } = {}; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ @@ -49,38 +48,43 @@ function render(modules?: Record) { ); } -describe(" module-gated reminder types", () => { - it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => { +describe(" reminder kinds", () => { + it("shows every reminder card when no module map (fails open)", () => { const html = render(undefined); expect(html).toContain('id="mood-reminder"'); expect(html).toContain('id="low-stock"'); - // v1.18.6 — the coach nudge moved to Settings → Coach. - expect(html).not.toContain('id="coach-nudge"'); + expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('id="vorsorge-reminders"'); + expect(html).toContain('data-slot="notifications-elsewhere"'); + expect(html).toContain('id="cycle-reminders"'); }); it("hides the mood card when the mood module is disabled", () => { const html = render({ mood: false }); expect(html).not.toContain('id="mood-reminder"'); - // Low-stock is unaffected. expect(html).toContain('id="low-stock"'); }); - it("always shows low-stock unless medications is disabled", () => { - const html = render({ mood: false }); - expect(html).toContain('id="low-stock"'); - expect(html).not.toContain('id="mood-reminder"'); - expect(html).not.toContain('id="coach-nudge"'); + it("hides the low-stock card and the dose row when medications is disabled", () => { + const html = render({ medications: false }); + expect(html).not.toContain('id="low-stock"'); + expect(html).not.toContain('href="/medications"'); }); - it("drops the channels / inbox cross-links and the Vorsorge block (D5)", () => { - // v1.18.1 — the page is lean: no section blurb, no channels/inbox - // cross-links, and no embedded Vorsorge editor (it has its own page). + it("hides the Coach nudges when the Coach is off", () => { + expect(render({ coach: false })).not.toContain('id="coach-nudge"'); + }); + + it("drops the elsewhere card when neither of its reminders applies", () => { + const html = render({ medications: false, cycle: false }); + expect(html).not.toContain('data-slot="notifications-elsewhere"'); + // The checkup reminders are not a module and stay. + expect(html).toContain('id="vorsorge-reminders"'); + }); + + it("links to the per-event matrix and to the delivery channels", () => { const html = render({}); - expect(html).not.toContain('data-slot="notifications-channels-cross-link"'); - expect(html).not.toContain('data-slot="notifications-inbox-cross-link"'); - // The remaining reminder-type cards (no coach nudge here anymore). - expect(html).toContain('id="mood-reminder"'); - expect(html).toContain('id="low-stock"'); - expect(html).not.toContain('id="coach-nudge"'); + expect(html).toContain('href="/notifications"'); + expect(html).toContain('href="/settings/integrations#channels"'); }); }); diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx index 3104f1fe5..0f87f2d1e 100644 --- a/src/components/settings/__tests__/privacy-section.test.tsx +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -55,7 +55,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe(" — assembled Data & Privacy dashboard", () => { - it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => { + it("renders the assembled sections (encryption, retention, delete)", () => { const html = render(); // Encryption-at-rest block driven from the seeded summary. expect(html).toContain("AES-256-GCM"); @@ -63,15 +63,14 @@ describe(" — assembled Data & Privacy dashboard", () => { expect(html).toContain("365"); // The honest backup↔deletion lag disclosure is present. expect(html.toLowerCase()).toContain("backup"); - // Links to the existing export + destructive surfaces (assembly, not rebuild). - expect(html).toContain('href="/settings/export"'); - expect(html).toContain('href="/settings/gesundheitsakte"'); + // Links to the destructive surface (assembly, not rebuild). expect(html).toContain('href="/settings/advanced"'); - // Embedded active-session + security-activity cards. - expect(html).toContain("settings-security-sessions-card"); - // Trusted devices now lives here (and only here) — its single home is the - // Privacy dashboard, not the Security section. - expect(html).toContain("settings-trusted-devices-card"); + // v1.44 — sessions, trusted devices and the activity feed live under + // Security, and the duplicate export card is gone: Export & import and + // the Health record sit beside this page in the Data group. + expect(html).not.toContain("settings-security-sessions-card"); + expect(html).not.toContain("settings-trusted-devices-card"); + expect(html).not.toContain('href="/settings/export"'); }); it("shows a loading placeholder for the retention block before the summary resolves", () => { diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index ace887480..e6420d6a3 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -246,7 +246,8 @@ describe("settings sections — SSR smoke", () => { // card is gone. expect(html).toContain('id="mood-reminder"'); expect(html).toContain('id="low-stock"'); - expect(html).not.toContain('id="coach-nudge"'); + // v1.44 — the Coach nudges are back with the other reminders. + expect(html).toContain('id="coach-nudge"'); // v1.25.7 — delivery channels moved to Settings → Integrationen, so the // channels group + panel no longer render on this surface. expect(html).not.toContain('id="channels"'); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 963048306..dfeed9e79 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -11,6 +11,7 @@ import { join } from "node:path"; const mockPathnameRef = { value: "/settings/account" }; vi.mock("next/navigation", () => ({ usePathname: () => mockPathnameRef.value, + useRouter: () => ({ replace: () => {}, push: () => {} }), })); // v1.18.0 — the shell module-gates per-submodule nav entries off @@ -169,6 +170,8 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.23 — "Data & Privacy" assembles the export / deletion / retention / // encryption / session / activity pieces into one pane, after `advanced`. "privacy", + // v1.44 — per-user preview opt-ins, in the System group before About. + "preview", "about", ]); }); @@ -193,23 +196,59 @@ describe("SETTINGS_SECTION_SLUGS", () => { }); describe("", () => { - it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => { + it("renders the nine groups as non-interactive sidebar headers, in order", () => { const html = renderShell({ active: "account" }); - for (const label of [ + const labels = [ "Account", - "Your data", - "Display", + "Tracking", + "Arrangement", + "Notifications", "Health profile", "AI", - "Connectivity", + "Connections", + "Data", "System", - ]) { - expect(html).toContain(`>${label}`); + ]; + let cursor = -1; + for (const label of labels) { + const at = html.indexOf(`>${label}`); + expect(at, label).toBeGreaterThan(cursor); + cursor = at; } - // Seven groups produce six quiet separators in the mobile strip. - expect( - html.match(/aria-hidden="true"/g)?.length ?? 0, - ).toBeGreaterThanOrEqual(6); + }); + + it("puts every section in the group the plan names", () => { + const groupOf = Object.fromEntries( + SETTINGS_SECTIONS.map((section) => [section.slug, section.group]), + ); + expect(groupOf).toEqual({ + account: "account", + security: "account", + access: "account", + modules: "tracking", + layout: "arrangement", + notifications: "notifications", + score: "healthProfile", + thresholds: "healthProfile", + anamnesis: "healthProfile", + environment: "healthProfile", + ai: "ai", + coach: "ai", + integrations: "connections", + sources: "connections", + api: "connections", + mcp: "connections", + export: "data", + gesundheitsakte: "data", + privacy: "data", + advanced: "data", + preview: "system", + about: "system", + }); + // Every routable section has a nav entry, and nothing else does. + expect([...SETTINGS_SECTIONS.map((s) => s.slug)].sort()).toEqual( + [...SETTINGS_SECTION_SLUGS].sort(), + ); }); it("starts the sticky desktop sidebar on the heading row", () => { @@ -230,19 +269,14 @@ describe("", () => { expect(source).not.toMatch(/ { + it("renders every navigable section link in the sidebar", () => { const html = renderShell({ active: "account" }); - // Every slug — including `about`, which returned to the shell nav - // as the last entry after living dropdown-only since v1.4.33 IW7 — - // renders in both layouts. + // The phone's section sheet mounts its list only when opened, so the + // server markup carries each link once, in the desktop sidebar. const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug); for (const slug of navigableSlugs) { const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g")); - // Two renders — mobile strip + desktop sidebar — guarantee the link - // exists in both layouts. Tablet/desktop hide the strip with `md:`, - // but the markup is always present so it's keyboard-discoverable - // before media queries resolve. - expect(matches?.length ?? 0).toBe(2); + expect(matches?.length ?? 0).toBe(1); } }); @@ -259,9 +293,9 @@ describe("", () => { it('marks the active section with aria-current="page" (and only that one)', () => { const html = renderShell({ active: "notifications" }); - // Both layouts emit the active link, so two `aria-current="page"`. + // The sidebar marks the active link; the phone sheet is closed. const occurrences = html.match(/aria-current="page"/g); - expect(occurrences?.length ?? 0).toBe(2); + expect(occurrences?.length ?? 0).toBe(1); // And the active link is on the notifications href. Attribute order // in React SSR is alphabetic, so `aria-current` precedes `href` — @@ -269,28 +303,38 @@ describe("", () => { // future React renderer changes. const activeLinkRegex = /]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1); }); it("derives the active slug from the current pathname when `active` is omitted", () => { const html = renderShell({ pathname: "/settings/api" }); const activeLinkRegex = /]*\baria-current="page"[^>]*\bhref="\/settings\/api"|]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1); }); - it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => { - // On `/settings/dashboard` (a Layout child reached through the hub) the - // Layout nav entry must read active even though Dashboard has no nav - // entry of its own. Both layouts emit the active link → two matches. - for (const child of ["/settings/dashboard", "/settings/insights"]) { + it("highlights the Arrangement hub on a legacy child path and on its sub-pages", () => { + // `/settings/dashboard` (a redirected legacy child) and the sub-pages + // under `/settings/layout/` both read as the Arrangement entry. + for (const child of [ + "/settings/dashboard", + "/settings/insights", + "/settings/layout/labs", + ]) { const html = renderShell({ pathname: child }); const layoutActive = /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; - expect(html.match(layoutActive)?.length ?? 0).toBe(2); + expect(html.match(layoutActive)?.length ?? 0).toBe(1); } }); + it("highlights Modules on a module's own settings page", () => { + const html = renderShell({ pathname: "/settings/modules/labs" }); + const modulesActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/modules"|]*\bhref="\/settings\/modules"[^>]*\baria-current="page"/g; + expect(html.match(modulesActive)?.length ?? 0).toBe(1); + }); + // v1.37.19 — the Layout children left `SETTINGS_SECTION_SLUGS`, so no // page can pass one as the `active` override anymore; the pathname-based // case above is the remaining (defensive) mapping. @@ -424,7 +468,7 @@ describe("", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); const activeLinkRegex = /]*\baria-current="page"[^>]*\bhref="\/settings\/account"|]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(1); }); it("resolves every section title via the i18n provider — English", () => { @@ -440,7 +484,8 @@ describe("", () => { // "Layout": it arranges and picks what each module shows and holds no // theme, so "Appearance" promised a control it does not have. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Layout"); + // v1.44 — the hub holds only order and view, and says so. + expect(html).toContain(">Arrangement"); // v1.25.3 — Channels folded into Notifications, so it is no longer a // left-side entry; Sources keeps its own. expect(html).not.toContain('href="/settings/channels"'); @@ -484,7 +529,7 @@ describe("", () => { // (route stays `/settings/layout`). v1.42 — "Layout" in German too; // "Darstellung" now names the account page's language-and-units card. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Layout"); + expect(html).toContain(">Anordnung"); // v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps // its own left-side entry. expect(html).not.toContain('href="/settings/channels"'); @@ -512,17 +557,14 @@ describe("", () => { expect(html).not.toContain("settings.sections."); }); - it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => { - // The 10-section strip is wider than 393 CSS px, so without - // `no-scrollbar` Chromium/WebKit paints an always-on horizontal - // scrollbar at the top of every settings page. The class is - // defined in `globals.css` and combines `scrollbar-width: none` - // (Firefox) with `::-webkit-scrollbar { display: none }` (everyone - // else) — scroll behaviour is preserved. - const html = renderShell({ active: "account" }); - const nav = html.match(/]*md:hidden[^>]*>/); + it("gives the phone one button naming the section, not a strip of every section", () => { + const html = renderShell({ active: "security" }); + const nav = html.match(/]*md:hidden[^>]*>[\s\S]*?<\/nav>/); expect(nav).not.toBeNull(); - expect(nav![0]).toMatch(/\bno-scrollbar\b/); - expect(nav![0]).toMatch(/\boverflow-x-auto\b/); + expect(nav![0]).toContain('data-slot="settings-mobile-nav-trigger"'); + expect(nav![0]).toContain("Security"); + expect(nav![0]).toContain("Account"); + expect(nav![0]).not.toContain("overflow-x-auto"); + expect(nav![0]).not.toContain('href="/settings/'); }); }); diff --git a/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx b/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx index c9e9a88a2..4f7dc0ae6 100644 --- a/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx +++ b/src/components/settings/account-section/__tests__/display-preferences-card.test.tsx @@ -44,10 +44,15 @@ describe("Settings → Account display card", () => { "GlucoseUnitSelect", "TimeFormatSelect", "DateFormatSelect", + // v1.44 — the timezone saves on change too, in the display card. + "TimezoneSelect", + "TimezonePicker", ]) { expect(form).not.toContain(control); } - // The timezone saves with the form, so it stays inside it. - expect(form).toContain("TimezonePicker"); + const card = src.slice( + src.indexOf("export function DisplayPreferencesCard"), + ); + expect(card).toContain("(EMPTY_HEIGHT_DRAFT); const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); - const [timezone, setTimezone] = useState(DEFAULT_TIMEZONE); // The name the app addresses the account by; set in the setup flow, and // until now not editable anywhere after it. const [displayName, setDisplayName] = useState(""); @@ -109,7 +102,6 @@ export function AccountSection() { height: EMPTY_HEIGHT_DRAFT, dateOfBirth: "", gender: "", - timezone: DEFAULT_TIMEZONE, fullName: "", insurerName: "", insuranceNumber: "", @@ -160,7 +152,6 @@ export function AccountSection() { ? dateOnlyKey(new Date(user.dateOfBirth)) : "", gender: user.gender ?? "", - timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()), fullName: user.fullName ?? "", insurerName: user.insurerName ?? "", insuranceNumber: user.insuranceNumber ?? "", @@ -171,7 +162,6 @@ export function AccountSection() { setHeight(seed.height); setDateOfBirth(seed.dateOfBirth); setGender(seed.gender); - setTimezone(seed.timezone); setFullName(seed.fullName); setInsurerName(seed.insurerName); setInsuranceNumber(seed.insuranceNumber); @@ -203,7 +193,6 @@ export function AccountSection() { height, dateOfBirth, gender, - timezone, fullName, insurerName, insuranceNumber, @@ -268,55 +257,36 @@ export function AccountSection() { height, dateOfBirth, gender, - timezone, fullName: fullName.trim(), insurerName: insurerName.trim(), insuranceNumber: insuranceNumber.trim(), }; - // The timezone is owned by a dedicated route (v1.4.25 W7) so the - // resolver cache can be invalidated without piping the flag - // through the bigger profile patch path. Run the two PUTs in - // parallel — they're independent. - const [profileRes, tzRes] = await Promise.all([ - apiFetchRaw("/api/auth/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: savedProfile.email || null, - // An emptied field clears the name; the greeting falls back to - // the username, as it does for an account that never set one. - displayName: savedProfile.displayName || null, - ...(emailChanged && emailPassword - ? { currentPassword: emailPassword } - : {}), - heightCm: heightAdapter.toCanonicalCm(savedProfile.height), - dateOfBirth: savedProfile.dateOfBirth || null, - gender: savedProfile.gender || null, - fullName: savedProfile.fullName || null, - insurerName: savedProfile.insurerName || null, - insuranceNumber: savedProfile.insuranceNumber || null, - }), + // v1.44 — the timezone left this form: it saves on change in the + // Display card (`TimezoneSelect`), like the other display preferences. + const profileRes = await apiFetchRaw("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: savedProfile.email || null, + // An emptied field clears the name; the greeting falls back to + // the username, as it does for an account that never set one. + displayName: savedProfile.displayName || null, + ...(emailChanged && emailPassword + ? { currentPassword: emailPassword } + : {}), + heightCm: heightAdapter.toCanonicalCm(savedProfile.height), + dateOfBirth: savedProfile.dateOfBirth || null, + gender: savedProfile.gender || null, + fullName: savedProfile.fullName || null, + insurerName: savedProfile.insurerName || null, + insuranceNumber: savedProfile.insuranceNumber || null, }), - user && timezone && timezone !== user.timezone - ? apiFetchRaw("/api/auth/me/timezone", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ timezone }), - }) - : Promise.resolve({ ok: true } as Response), - ]); + }); setEmailPassword(""); - if (profileRes.ok && tzRes.ok) { - // Instant same-tab flip of every rendered timestamp (issue #490): - // mirror the accepted zone before the `/me` refetch confirms it — - // the same synchronous-update pattern the time/date-format selects - // use. `refetch()` → `fetchMe` re-writes the mirror authoritatively. - if (user && timezone && timezone !== user.timezone) { - storeTimezone(timezone); - } + if (profileRes.ok) { const profileJson = (await profileRes.json().catch(() => null)) as { data?: { rejectedFields?: RejectedProfileField[] }; } | null; @@ -351,22 +321,6 @@ export function AccountSection() { setProfileSeed(savedProfile); await refetch(); } - } else if (!tzRes.ok) { - // The dedicated tz endpoint owns the IANA validation error - // text. Surface its message verbatim so the user sees - // "Not a valid IANA timezone." instead of the generic save - // failure copy. - try { - const json = (await (tzRes as Response).json()) as { error?: string }; - setSaveMsg( - json.error - ? { text: json.error } - : { key: "settings.timezoneInvalid" }, - ); - } catch { - setSaveMsg({ key: "settings.timezoneInvalid" }); - } - setSaveMsgType("error"); } else { // The server's top-level `error` string is intentionally generic // (never a raw validator message); resolve the specific, localized @@ -584,10 +538,9 @@ export function AccountSection() {
- {/* Date of birth + timezone share one paired grid row. The - timezone stays on this card because it saves with the form - (its own PUT runs beside the profile PUT); the display - preferences that save on change live in their own card below. */} + {/* Date of birth shares the grid of the fields above. The timezone + moved to the Display card below in v1.44: it saves on change + there, like the other display preferences. */}
@@ -609,7 +562,6 @@ export function AccountSection() {

-
{/* v1.7.0 — optional patient-identity fields surfaced on the @@ -817,6 +769,7 @@ export function DisplayPreferencesCard({ + ); diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx deleted file mode 100644 index 1d4a5c68d..000000000 --- a/src/components/settings/ai/disable-coach-card.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -/** - * v1.4.47 — Coach activation toggle card. - * - * v1.18.0 — moved out of Settings → AI into the dedicated Coach - * section; its `
` id namespace is `settings-coach-activate-*` - * accordingly. (The file path stays under `ai/` as pre-existing drift.) - * - * v1.18.1 — polarity flip. The card now reads "Activate Coach" with the - * Switch ON by default (the Coach is on for everyone unless the user opts - * out). The persisted state is still the single `User.disableCoach` field — - * one source of truth — so the Switch's `checked` is `!disableCoach` and a - * flip writes `disableCoach: !next`. Presenting it as activate/default-on is - * a pure UI inversion over the same column. - * - * Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates - * `aiInputDependentKeys` (the account payload first) so every Coach mount point on the client - * (``, ``, the inline `` - * pill, the `/targets` page CTAs) re-renders with the new `user.disableCoach` - * value on the next React Query refetch tick — no full reload required. - * - * The optimistic-update pattern mirrors ``: the Switch - * flips immediately, the mutation rolls back on error. - * - * v1.16.4 — header follows the shared `SettingsCardHeader` contract (icon - * column + title + switch in the status slot) so the card reads like its - * siblings instead of carrying its own bespoke header row. - */ - -import { useEffect, useRef, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { MessageCircleHeart } from "lucide-react"; - -import { Switch } from "@/components/ui/switch"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiPatch } from "@/lib/api/api-fetch"; -import { useAuth } from "@/hooks/use-auth"; -import { useMounted } from "@/hooks/use-mounted"; -import { useTranslations } from "@/lib/i18n/context"; -import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; - -export function DisableCoachCard({ - isAuthenticated, -}: { - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const { user } = useAuth(); - - // Optimistic-update flag — null means "no in-flight change", - // otherwise it overrides the wire value until the mutation - // settles. Mirrors the `` pattern so the Switch - // reacts instantly to the user's tap. - const [optimistic, setOptimistic] = useState(null); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - // v1.4.48 — auto-clear the inline `

` line 3 s - // after the mutation settles so a Settings card the user scrolled - // past minutes earlier doesn't keep echoing a stale "Coach hidden" - // banner. The ref tracks the in-flight timer so we can clear it on - // unmount + on a follow-up toggle (otherwise a rapid double-tap - // could leave a stray timer pointing at a stale message). - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - - // `mounted`-gated: `user` comes from the auth query, which can resolve - // before this boundary hydrates — the Switch state must match the SSR HTML - // during hydration (React #418) and pick up the wire value on the first - // client re-render. v1.18.1 (D7) — the toggle now reads "Coach aktivieren" - // (default ON), so the Switch is checked when the Coach is ACTIVE. The - // persisted column is still `disableCoach`; `optimistic` and `checked` are - // the activated-state view of it. The SSR default is ON (checked) so the - // default-on contract holds before the wire value resolves. - const mounted = useMounted(); - const activated = mounted - ? (optimistic ?? !(user?.disableCoach ?? false)) - : true; - - const mutation = useMutation({ - // `next` is the desired ACTIVATED state; the column stores the inverse. - mutationFn: async (next: boolean) => { - await apiPatch("/api/auth/me/disable-coach", { disableCoach: !next }); - return next; - }, - onSuccess: (next) => { - setMsg( - next - ? t("settings.coach.activate.savedOn") - : t("settings.coach.activate.savedOff"), - ); - setMsgType("success"); - // Surface the new value to every Coach gate via the shared - // `useAuth()` query — the gates re-render once React Query - // re-fetches /api/auth/me with the updated `disableCoach` field. - void invalidateKeys(queryClient, aiInputDependentKeys); - setOptimistic(null); - scheduleClear(); - }, - onError: (err) => { - setOptimistic(null); - setMsg( - err instanceof Error - ? err.message - : t("settings.coach.activate.saveError"), - ); - setMsgType("error"); - scheduleClear(); - }, - }); - - function handleToggle(next: boolean) { - setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - mutation.mutate(next); - } - - return ( - - - } - /> - {msg && ( -

- {msg} -

- )} - - ); -} diff --git a/src/components/settings/anamnesis-section.tsx b/src/components/settings/anamnesis-section.tsx index b94e7b294..780a09d24 100644 --- a/src/components/settings/anamnesis-section.tsx +++ b/src/components/settings/anamnesis-section.tsx @@ -41,6 +41,7 @@ import { EmergencyProfileManager } from "@/components/records/emergency-profile- import { FamilyHistoryManager } from "@/components/records/family-history-manager"; import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager"; import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager"; +import { ScreeningHintsCard } from "@/components/records/screening-hints-card"; export function AnamnesisSection() { const { t } = useTranslations(); @@ -112,6 +113,11 @@ export function AnamnesisSection() { + {/* v1.43 (#614) — screening hints from the life circumstances, with + their source. v1.44 — beside the circumstances they come from + (they sat on the checkups arrangement page before). */} + + Modules (v1.44: the second switch that + * used to sit here is gone; kept first so a person + * who wants the surface gone finds the way at once). * 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`). - * 3. Proactive nudge — the daily-nudge opt-out + per-group frequency - * (moved here from Settings → Benachrichtigungen in - * v1.18.6: it is a Coach setting, not a generic - * notification). + * 3. Proactive nudges — a pointer to Settings > Notifications, where the + * nudge card lives with the other reminders since + * v1.44 (`#coach-nudge` forwards there). * 4. Coach memory — durable-fact review + forget controls. * * The nav entry is module-gated on `coach`. The preference + nudge cards @@ -34,19 +35,30 @@ * `` in the route; this body is the Coach cards only. */ -import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; +import Link from "next/link"; +import { BellRing, ChevronRight, MessageCircleHeart } from "lucide-react"; + import { CoachMemorySection } from "@/components/settings/coach-memory-section"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; import { CoachRemindersSection } from "@/components/settings/coach-reminders-section"; import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card"; -import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; +import { ModuleStateCard } from "@/components/settings/module-state-card"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { useModuleEnabledState } from "@/hooks/use-module-toggle"; +import { useTranslations } from "@/lib/i18n/context"; import { isSurfaceVisible } from "@/lib/modules/surface"; export function CoachSection() { const { isAuthenticated, user } = useAuth(); + const { t } = useTranslations(); + // The same reading the Modules switch shows, so both say one thing. + const moduleSwitchState = useModuleEnabledState(); // Mirror the AI section's hydration-safe gating: the auth query can // resolve before this boundary hydrates, so gate the child props on // `useMounted()` to keep the hydration render matching the SSR HTML. @@ -62,16 +74,38 @@ export function CoachSection() { return (
- + {/* Before hydration the state reads on, the default, so the server + render and the first client paint agree. */} + {coachEnabled && } - {/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6). - Keeps the `#coach-nudge` anchor so existing deep links resolve. */} + {/* The proactive nudges are a reminder: set under Notifications with + the others since v1.44. */} {coachEnabled && ( -
- -
+ + + + + + )} diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index 21e82c34f..53cd607cd 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -74,6 +74,7 @@ export function InjectionSitesCard({ return ( Modules + * (`module-settings-pages.tsx`); `/settings/layout/mood` redirects there. + * * Each module section keeps its module-gate: a disabled module's row / subpage * does not render, read from the resolved `useAuth().user.modules` map (the * same map the nav, the Modules hub, and the Insights pills gate off). The gate @@ -76,12 +80,6 @@ export const LAYOUT_GROUPS: ReadonlyArray = [ descriptionKey: "settings.sections.layout.medications.description", Body: MedicationsSection, }, - { - id: "mood", - titleKey: "settings.sections.layout.mood.title", - descriptionKey: "settings.sections.layout.mood.description", - Body: MoodSection, - }, { id: "labs", titleKey: "settings.sections.layout.labs.title", diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index 6b448a5d6..ca1eb59fd 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -17,22 +17,12 @@ * least-privilege read scope and nothing more. */ import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; +import { ConfirmButton } from "@/components/ui/confirm-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -42,15 +32,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ModuleStateCard } from "@/components/settings/module-state-card"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { - aiInputDependentKeys, - invalidateKeys, - queryKeys, -} from "@/lib/query-keys"; -import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -169,39 +156,17 @@ function McpConnectionsCard() { ? formatDateTime(conn.lastUsedAt) : t("settings.mcp.connectionNeverUsed")}

- - - - - - - - {t("settings.mcp.connectionRevoke")} - - - {t("settings.mcp.connectionRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(conn.id)} - > - {t("settings.mcp.connectionRevoke")} - - - - + void handleRevoke(conn.id)} + slot="mcp-connection-revoke" + icon={} + destructive + /> ))} @@ -211,10 +176,16 @@ function McpConnectionsCard() { ); } +/** + * The connector's state, with a link to the one switch in Settings > Modules. + * Turning the module off pauses the endpoint: it refuses every call, while the + * tokens and connections below stay as they are and work again once it is on. + * Nothing here or in Modules revokes a token as a side effect (#1170); a token + * goes only through its own revoke. + */ function McpEnableCard() { const { t } = useTranslations(); const { user } = useAuth(); - const queryClient = useQueryClient(); // The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is // the resolved state (true only when the user turned it on). @@ -222,53 +193,29 @@ function McpEnableCard() { const endpoint = typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp"; - const toggle = useMutation({ - mutationKey: queryKeys.modulesPrefs(), - mutationFn: async (next: boolean) => - apiPatch("/api/auth/me/modules", { mcp: next }), - onSuccess: () => { - void invalidateKeys(queryClient, aiInputDependentKeys); - toast.success(t("settings.sections.modules.saved")); - }, - onError: (failure) => - showErrorToast(t("settings.sections.modules.error"), { cause: failure }), - }); - return ( - - - -
-
- - {t("settings.mcp.enableToggleLabel")} - - toggle.mutate(next)} - aria-label={t("settings.mcp.enableToggleLabel")} - /> -
- -
-

- {t("settings.mcp.endpointLabel")} -

- - {endpoint} - -

- {t("settings.mcp.connectDescription")} -

-
+ +

{t("settings.mcp.offKeepsTokens")}

+
+

+ {t("settings.mcp.endpointLabel")} +

+ + {endpoint} + +

+ {t("settings.mcp.connectDescription")} +

- +
); } @@ -508,39 +455,17 @@ function McpTokensCard() { ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

- - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevokeAction")} - - - - + void handleRevoke(tok.id)} + slot="mcp-token-revoke" + icon={} + destructive + /> ); })} diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx index ba552e221..67d897f94 100644 --- a/src/components/settings/medications-section.tsx +++ b/src/components/settings/medications-section.tsx @@ -11,8 +11,6 @@ import { MedicationViewToggle } from "@/components/medications/medication-view-t import { SettingsCard } from "@/components/settings/settings-card"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; -import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -24,7 +22,7 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou * from a Layout-hub child to its own standalone nav entry and gathered the * medication-specific preferences here. v1.18.1 (D3) made medications a * toggleable fail-open module, so this nav entry hides when the account - * turns the module off (the medication data routes stay live). Hosts three + * turns the module off (the medication data routes stay live). Hosts two * blocks: * 1. The list VIEW preference — cards vs table, written optimistically * through the same `PUT /api/medications/layout` the page header @@ -32,8 +30,9 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou * 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv) * with drag + arrow reordering, flushed by an explicit Save, * persisted as active-ids-then-inactive-ids on the same layout row. - * 3. Injection-site exclusions — moved here from the account profile so - * every medication-specific preference lives on one screen. + * + * v1.44 — Arrangement holds order and view only; the injection-site + * exclusions moved to the medications page under Modules. * * The list view + order both write through the same `/api/medications/layout` * contract the /medications page reads, so a save here repaints both views. @@ -49,7 +48,6 @@ interface MedicationListEntry { export function MedicationsSection() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); const { layout, isLayoutLoading, setView } = useMedicationListLayout(); const { data: medications, isLoading } = useQuery({ @@ -120,11 +118,6 @@ export function MedicationsSection() { )} - - {/* v1.18.0 — injection-site exclusions are a medication setting; - they moved here from the account profile so all medication-specific - preferences live in one place. */} -
); } diff --git a/src/components/settings/module-settings-page-ids.ts b/src/components/settings/module-settings-page-ids.ts new file mode 100644 index 000000000..622a72d57 --- /dev/null +++ b/src/components/settings/module-settings-page-ids.ts @@ -0,0 +1,40 @@ +import type { ModuleKey } from "@/lib/modules/registry"; +import { surfaceModule } from "@/lib/modules/surface"; + +/** + * Server-safe ids and copy keys of the module pages under Settings > Modules + * (`/settings/modules/`). The route reads them in + * `generateStaticParams()`, so they live apart from the client bodies in + * `module-settings-pages.tsx`. + */ +export const MODULE_SETTINGS_PAGE_IDS = [ + "medications", + "mood", + "labs", +] as const; + +export type ModuleSettingsPageId = (typeof MODULE_SETTINGS_PAGE_IDS)[number]; + +export function isModuleSettingsPageId( + value: string, +): value is ModuleSettingsPageId { + return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(value); +} + +/** i18n keys of a page's heading, under `settings.sections.modules.pages`. */ +export function moduleSettingsPageKeys(id: ModuleSettingsPageId): { + titleKey: string; + descriptionKey: string; +} { + return { + titleKey: `settings.sections.modules.pages.${id}.title`, + descriptionKey: `settings.sections.modules.pages.${id}.description`, + }; +} + +/** The module a page belongs to, from the surface map. */ +export function moduleSettingsPageModule( + id: ModuleSettingsPageId, +): ModuleKey | undefined { + return surfaceModule(`settings-module:${id}`); +} diff --git a/src/components/settings/module-settings-pages.tsx b/src/components/settings/module-settings-pages.tsx new file mode 100644 index 000000000..4447a344c --- /dev/null +++ b/src/components/settings/module-settings-pages.tsx @@ -0,0 +1,117 @@ +"use client"; + +/** + * The pages a module's own settings live on: `/settings/modules/`. + * + * Settings > Arrangement holds order and view only. What a module needs + * beyond that (the biomarker catalog and on-device text recognition for + * labs, the mood tags, the injection sites a medication rotation leaves out) + * lives on the module's page under Modules, reached from the module's row in + * Settings > Modules and from the wrench on the module's own page. Each page + * links to the module's arrangement page and back. + * + * Every page is gated on its module through the surface map + * (`settings-module:`), fail-open like the rest of Settings. + */ + +import Link from "next/link"; +import { ArrowUpDown, ChevronRight, SlidersHorizontal } from "lucide-react"; + +import { LabsModuleSettings } from "@/components/labs/labs-settings"; +import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; +import { MoodSection } from "@/components/settings/mood-section"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleSettingsPageId } from "./module-settings-page-ids"; + +/** A card pointing at the module's order-and-view page under Arrangement. */ +function ArrangementLinkCard({ id }: { id: ModuleSettingsPageId }) { + const { t } = useTranslations(); + return ( + + + + + + + ); +} + +function MedicationsModulePage() { + const { isAuthenticated } = useAuth(); + return ( +
+ + +
+ ); +} + +function LabsModulePage() { + return ( +
+ + +
+ ); +} + +function MoodModulePage() { + return ; +} + +export const MODULE_SETTINGS_PAGE_BODIES: Record< + ModuleSettingsPageId, + () => React.JSX.Element +> = { + medications: MedicationsModulePage, + mood: MoodModulePage, + labs: LabsModulePage, +}; + +export function ModuleSettingsPageBody({ id }: { id: ModuleSettingsPageId }) { + const Body = MODULE_SETTINGS_PAGE_BODIES[id]; + return ; +} + +/** + * The way from a module's arrangement page to its own page under Modules, + * for the modules that have one. + */ +export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) { + const { t } = useTranslations(); + return ( + + + + + + + ); +} diff --git a/src/components/settings/module-state-card.tsx b/src/components/settings/module-state-card.tsx new file mode 100644 index 000000000..410a46578 --- /dev/null +++ b/src/components/settings/module-state-card.tsx @@ -0,0 +1,72 @@ +"use client"; + +/** + * A module's on/off state, shown where the module is configured, with a link + * to the one switch in Settings > Modules. + * + * The Coach and the MCP connector each had a second switch on their own page + * beside the one in Modules: two controls for one state, and a person who + * flipped one never knew the other existed. The switch stays in Modules (the + * row carries `id="module-"`); the module's page says what the state is + * and where to change it. + */ + +import Link from "next/link"; +import { ChevronRight, type LucideIcon } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleKey } from "@/lib/modules/registry"; + +export function ModuleStateCard({ + moduleKey, + enabled, + icon, + title, + description, + anchor, + slot, + children, +}: { + moduleKey: ModuleKey; + enabled: boolean; + icon: LucideIcon; + title: string; + description: string; + anchor: string; + slot: string; + /** Body copy between the header and the link, e.g. what "off" keeps. */ + children?: React.ReactNode; +}) { + const { t } = useTranslations(); + return ( + + + {enabled + ? t("settings.moduleState.on") + : t("settings.moduleState.off")} + + } + /> + {children} + + + + + ); +} diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx index 6fc032a41..9c7e7cd5c 100644 --- a/src/components/settings/module-toggle-row.tsx +++ b/src/components/settings/module-toggle-row.tsx @@ -92,7 +92,12 @@ export function ModuleToggleRow({ // is in flight. const interactive = !locked && disabledReason == null; return ( -
+ // `id="module-"` is the anchor other pages link to when they show a + // module's state and send the person here to change it. +
` — Settings → Notifications ("Benachrichtigungen"). * - * v1.18.0 — the single module-gated home for reminder TYPES. Earlier the - * settings carried two near-identical entries: this Notifications screen and a - * separate "Erinnerungen" hub that only deep-linked into the canonical editors - * ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the - * reminder TYPES now live centrally on this one screen. + * The one home for every reminder kind. A reminder whose settings belong to + * one place is set here; a reminder that is set where its subject lives (a + * medication's dose times on the medication, the cycle reminders in the + * per-event matrix) gets a row here that says where, so nothing has to be + * found by luck. * - * Each reminder-type card is shown ONLY when its module is enabled, read from - * the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and - * Insights pills gate off). Mood → `mood`, low-stock runway → `medications` - * (a toggleable fail-open module since D3, so a missing key reads as enabled - * and a stale `/me` payload never blanks a card). + * Each card is shown only while its module is on, read from the resolved + * `useAuth().user.modules` map and failing open, so a stale `/me` payload + * never blanks a card. * - * v1.25.7 — delivery channels move to Settings → Integrationen (channels are - * delivery providers, the same family as the connected services). This screen - * keeps ONLY the reminder-TYPE content; `/settings/channels` 301-redirects to - * `/settings/integrations#channels`. - * - * v1.18.6 — the page heading + subtitle come from the shared - * ``. The proactive-Coach nudge card lives under - * Settings → Coach (it is a Coach setting, not a generic notification). + * v1.18.0 — the separate "Erinnerungen" hub is gone; `/settings/reminders` + * redirects here. v1.25.7 — the delivery channels live under Integrations + * (they are delivery providers); `/settings/channels` redirects there and this + * page links to them. v1.44 — the Coach nudges came back from Settings > Coach + * and the checkup reminder switches from the checkups arrangement page; both + * old places link here (`/settings/coach#coach-nudge` forwards). */ export function NotificationsSection() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); + // The Coach gate reads the surface map like the Coach section does, after + // hydration, so the server render and the first client paint agree. + const mounted = useMounted(); - // v1.18.0 — module-gated per-type visibility. Fail OPEN (`!== false`) - // so a stale `/me` payload without the module map keeps every card visible. const moodEnabled = user?.modules?.mood !== false; const medsEnabled = user?.modules?.medications !== false; - const anyReminder = moodEnabled || medsEnabled; + const cycleEnabled = user?.modules?.cycle !== false; + const coachEnabled = + !mounted || isSurfaceVisible("settings:coach", user?.modules); return (
- {/* Group 1 — Reminders ("what you receive"). Rendered only when at - least one reminder-type card is visible, so a fully-disabled - account never shows an empty heading. */} - {anyReminder ? ( -
- + {/* Group 1 — Reminders ("what you receive"). The checkup reminders are + not a module and always show, so the group always has content. */} +
+ - {/* Mood check-in reminder — only when the mood module is enabled. */} - {moodEnabled ? ( -
- -
- ) : null} + {moodEnabled ? ( +
+ +
+ ) : null} - {/* Medication low-stock runway — only when the medications module - is enabled (toggleable, fail-open, since D3). */} - {medsEnabled ? ( -
- -
- ) : null} -
- ) : null} + {medsEnabled ? ( +
+ +
+ ) : null} + + {coachEnabled ? ( +
+ +
+ ) : null} + +
+ +
+ + {/* Reminders set where their subject lives. One row each, saying + where; nothing here writes. */} + {medsEnabled || cycleEnabled ? ( + + +
    + {medsEnabled ? ( +
  • +

    + {t("settings.sections.notifications.elsewhere.doses.title")} +

    +

    + {t( + "settings.sections.notifications.elsewhere.doses.description", + )} +

    + + + {t( + "settings.sections.notifications.elsewhere.doses.link", + )} + +
  • + ) : null} + {cycleEnabled ? ( +
  • +

    + {t("settings.sections.notifications.elsewhere.cycle.title")} +

    +

    + {t( + "settings.sections.notifications.elsewhere.cycle.description", + )} +

    + + + {t( + "settings.sections.notifications.elsewhere.cycle.link", + )} + +
  • + ) : null} +
+
+ ) : null} +
{/* Group 2 — How notifications look on the phone. Not module-gated: it applies to every notification the account receives. */} diff --git a/src/components/settings/preview-section.tsx b/src/components/settings/preview-section.tsx new file mode 100644 index 000000000..9df4789e5 --- /dev/null +++ b/src/components/settings/preview-section.tsx @@ -0,0 +1,116 @@ +"use client"; + +/** + * Settings > System > Preview features. + * + * One switch per preview feature, each off until the person turns it on, each + * saved the moment it changes (`PATCH /api/settings/preview-features`). A + * preview adds something beside what is there and never replaces a page. + * + * The `topics` flag is stored and shown here; the topic overview pages that + * read it arrive in a later release, and the card says so rather than + * promising a change the switch does not make yet. + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { LayoutDashboard } from "lucide-react"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { InlineAction } from "@/components/ui/inline-action"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Switch } from "@/components/ui/switch"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + DEFAULT_PREVIEW_FEATURES, + type PreviewFeatures, +} from "@/lib/settings/preview-features"; + +const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions"; + +export function PreviewSection() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const key = queryKeys.previewFeatures(); + + const query = useQuery({ + queryKey: key, + queryFn: () => apiGet("/api/settings/preview-features"), + }); + const features = query.data ?? DEFAULT_PREVIEW_FEATURES; + + const toggle = useMutation({ + mutationKey: key, + mutationFn: (topics: boolean) => + apiPatch("/api/settings/preview-features", { topics }), + onMutate: async (topics) => { + await queryClient.cancelQueries({ queryKey: key }); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, { + ...(previous ?? DEFAULT_PREVIEW_FEATURES), + topics, + }); + return { previous }; + }, + onSuccess: (saved, topics) => { + if (saved) queryClient.setQueryData(key, saved); + toastWrittenOutcome( + "success", + topics + ? t("settings.preview.topics.savedOn") + : t("settings.preview.topics.savedOff"), + ); + }, + onError: (failure, _topics, context) => { + queryClient.setQueryData(key, context?.previous); + showErrorToast(t("settings.savingError"), { cause: failure }); + }, + }); + + return ( + + toggle.mutate(next)} + aria-labelledby="settings-preview-topics-title" + /> + } + /> + {query.isError ? ( + void query.refetch()} + /> + ) : null} +

{t("settings.preview.topics.detail")}

+

+ + + {t("settings.preview.feedbackLink")} + + +

+
+ ); +} diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 193017392..2bb0cbb25 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -4,18 +4,20 @@ * v1.23 — "Data & Privacy" dashboard (P7). * * One coherent pane that ASSEMBLES already-shipped machinery — it does not - * rebuild any of it. Each block either embeds an existing card (active - * sessions, security activity, trusted devices) or links to the existing - * surface that owns the action (export incl. the passphrase option, account - * deletion / data reset, AI privacy mode, research mode). The retention + - * encryption facts are read from the server so the copy stays truthful, and - * the backup↔deletion lag is disclosed honestly. + * rebuild any of it. Each block states a fact or links to the surface that + * owns the action (account deletion / data reset, AI privacy mode). The + * retention + encryption facts are read from the server so the copy stays + * truthful, and the backup↔deletion lag is disclosed honestly. + * + * v1.44 — the active sessions, trusted devices and security activity moved + * to Settings > Security beside the sign-in controls, and the export card + * left: Export & import and the Health record sit next to this page in the + * Data group. The old anchors forward (`settings-anchor-forward.tsx`). */ import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Database, - Download, ShieldCheck, Trash2, Clock, @@ -29,9 +31,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; -import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; -import { SecurityActivityCard } from "@/components/settings/security-activity-card"; -import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; @@ -177,28 +176,6 @@ export function PrivacySection() { )} - {/* Export */} - - - - - - - - {/* Delete / reset — with the honest backup lag disclosure */} - - {/* Active sessions + trusted devices + security activity (embedded) */} - - -
); } diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 843203284..89d05e243 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -114,6 +114,9 @@ export const SETTINGS_SECTION_SLUGS = [ // pieces into one coherent privacy pane. Sits next to `advanced` (the // destructive-action home) and before `about`. "privacy", + // v1.44 — "Preview features": per-user opt-in to things finished enough to + // try and not yet the default. Sits in the System group before About. + "preview", "about", ] as const; diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index ed7e8dbfe..6008b7d75 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -7,6 +7,9 @@ import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { PasswordCard } from "@/components/settings/password-card"; +import { SecurityActivityCard } from "@/components/settings/security-activity-card"; +import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; +import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; import { TotpCard } from "./totp-card"; import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card"; import { PasskeyListSection } from "./passkey-list-section"; @@ -79,6 +82,15 @@ export function SecuritySection() { `/api/auth/me/mfa` that never answers must not take it down with the cards that do need the status. */} + + {/* v1.44 — who is signed in and what happened, beside the controls + that decide how one signs in. These lived on the privacy page; + `/settings/privacy#sessions` (and the other two anchors) forward + here. Outside the status conditional for the same reason as the + password: they read their own routes. */} + + +
); } diff --git a/src/components/settings/settings-anchor-forward.tsx b/src/components/settings/settings-anchor-forward.tsx new file mode 100644 index 000000000..247c4213b --- /dev/null +++ b/src/components/settings/settings-anchor-forward.tsx @@ -0,0 +1,70 @@ +"use client"; + +/** + * Old deep links into cards that moved to another Settings page. + * + * A path that moved is a server redirect (`next.config.ts`), but a card that + * moved while its page stayed cannot be: the `#anchor` never reaches the + * server. Bookmarks, cross-links in older notifications and the docs point at + * `/settings/privacy#sessions` and the like, so the shell forwards a known + * anchor on a known page to the card's new home, once, on arrival. + * + * `SETTINGS_ANCHOR_FORWARDS` is also read by the legacy-routes guard, which + * proves every target lands on a page that exists. + */ + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +export const SETTINGS_ANCHOR_FORWARDS: Readonly< + Record>> +> = { + // v1.44 — sessions, trusted devices and the security activity joined the + // other sign-in controls under Security; the duplicate export card left. + "/settings/privacy": { + sessions: "/settings/security#sessions", + "trusted-devices": "/settings/security#trusted-devices", + "security-activity": "/settings/security#security-activity", + "your-data-export": "/settings/export", + }, + // v1.44 — the proactive nudges are a reminder and live with the others. + "/settings/coach": { + "coach-nudge": "/settings/notifications#coach-nudge", + }, + // v1.44 — Arrangement holds order and view only; each module's own + // settings moved to its page under Modules. + "/settings/layout/labs": { + "labs-local-ocr": "/settings/modules/labs#labs-local-ocr", + "labs-biomarkers": "/settings/modules/labs#labs-biomarkers", + }, + "/settings/layout/medications": { + "injection-sites": "/settings/modules/medications#injection-sites", + }, + "/settings/layout/vorsorge": { + "screening-hints": "/settings/anamnesis#screening-hints", + "vorsorge-reminders": "/settings/notifications#vorsorge-reminders", + }, +}; + +/** Where an anchor on a page now lives, or null when it did not move. */ +export function forwardedAnchorTarget( + pathname: string, + hash: string, +): string | null { + const anchor = hash.startsWith("#") ? hash.slice(1) : hash; + if (!anchor) return null; + return SETTINGS_ANCHOR_FORWARDS[pathname]?.[anchor] ?? null; +} + +export function SettingsAnchorForward() { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + if (!pathname) return; + const target = forwardedAnchorTarget(pathname, window.location.hash); + if (target) router.replace(target); + }, [pathname, router]); + + return null; +} diff --git a/src/components/settings/settings-mobile-nav.tsx b/src/components/settings/settings-mobile-nav.tsx new file mode 100644 index 000000000..0eddd5fb3 --- /dev/null +++ b/src/components/settings/settings-mobile-nav.tsx @@ -0,0 +1,131 @@ +"use client"; + +/** + * Settings navigation below `md`: one button naming where the person is, and a + * bottom sheet listing every section under its group. + * + * The phone used to carry every section as a chip in one horizontal strip, + * more than twenty of them, so most of Settings sat off screen and the groups + * the desktop sidebar shows were reduced to thin dividers. The sheet shows the + * same groups and order as the sidebar, one 44 px row per section, and closes + * on choice. Both read the visible-section list the shell computes, so the + * module and record filters apply to both alike. + */ + +import * as React from "react"; +import Link from "next/link"; +import { ChevronsUpDown } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +export interface SettingsNavEntry { + slug: string; + title: string; + groupLabel: string; + icon: LucideIcon; +} + +export function SettingsMobileNav({ + entries, + activeSlug, +}: { + entries: readonly SettingsNavEntry[]; + activeSlug: string; +}) { + const { t } = useTranslations(); + const [open, setOpen] = React.useState(false); + const active = entries.find((entry) => entry.slug === activeSlug); + const ActiveIcon = active?.icon; + + // Group the flat, pre-ordered list for rendering; the order of first + // appearance is the group order. + const groups: { label: string; entries: SettingsNavEntry[] }[] = []; + for (const entry of entries) { + const last = groups.at(-1); + if (last && last.label === entry.groupLabel) last.entries.push(entry); + else groups.push({ label: entry.groupLabel, entries: [entry] }); + } + + return ( + + ); +} diff --git a/src/components/settings/settings-section-loader.tsx b/src/components/settings/settings-section-loader.tsx index 447a8b387..252c75905 100644 --- a/src/components/settings/settings-section-loader.tsx +++ b/src/components/settings/settings-section-loader.tsx @@ -11,8 +11,8 @@ import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; /** * One settings section, loaded on its own. * - * The section route renders exactly one of twenty-one sections, but importing - * them statically put all twenty-one (and everything under them: the import + * The section route renders exactly one of the sections, but importing + * them statically put all of them (and everything under them: the import * panel, the integration cards, the MCP and API token tables, the score * editor) into the route's entry bundle, so opening "About" downloaded the * whole settings tree. Each section is now its own chunk. A first load still @@ -124,6 +124,10 @@ const LOADERS: Record< import("@/components/settings/privacy-section").then( (m) => m.PrivacySection, ), + preview: () => + import("@/components/settings/preview-section").then( + (m) => m.PreviewSection, + ), }; const SECTIONS: Record> = { @@ -158,6 +162,7 @@ const SECTIONS: Record> = { export: dynamic(LOADERS.export, { loading: () => }), advanced: dynamic(LOADERS.advanced, { loading: () => }), privacy: dynamic(LOADERS.privacy, { loading: () => }), + preview: dynamic(LOADERS.preview, { loading: () => }), }; /** diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 1dbb1e32e..b260fa6d6 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -9,9 +9,10 @@ * by the page itself. * * Mobile-first per `docs/ui-guidelines.md`: - * - =md: sticky 220px sidebar + content column with `max-w-screen-xl`. + * - =md: sticky 220px sidebar, grouped the same way, beside a content + * column with `max-w-screen-xl`. */ import * as React from "react"; @@ -25,6 +26,7 @@ import { CloudSun, Download, FileHeart, + FlaskConical, Gauge, Info, KeyRound, @@ -56,6 +58,8 @@ import { type SettingsSectionSlug, } from "./section-slugs"; import { SettingsHubBackLink } from "./settings-hub-back-link"; +import { SettingsMobileNav } from "./settings-mobile-nav"; +import { SettingsAnchorForward } from "./settings-anchor-forward"; // Re-export so existing call-sites importing from this module keep working. // New server-side call-sites (e.g. `generateStaticParams()`) should import @@ -71,18 +75,24 @@ export { }; /** - * The sidebar's 19 entries render under one - * of these seven non-interactive group headers instead of as one flat - * list. Presentation only: no slug, route, or redirect changes. Order - * below is the render order of the groups themselves. + * The nine groups every section belongs to, in render order. The sidebar + * paints a non-interactive header above the first visible entry of each, and + * the phone's section sheet uses the same headers. + * + * Account: who you are and who can get in. Tracking: what the app records. + * Arrangement: order and view only. Notifications: every reminder. Health + * profile: what describes you. AI and Connections: what talks to the outside. + * Data: export, import, privacy and deletion. System: previews and about. */ export type SettingsSectionGroup = | "account" - | "yourData" - | "display" + | "tracking" + | "arrangement" + | "notifications" | "healthProfile" | "ai" - | "connectivity" + | "connections" + | "data" | "system"; /** i18n key for each group's header label, under `settings.shell.groups.*`. */ @@ -91,11 +101,13 @@ export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record< string > = { account: "settings.shell.groups.account", - yourData: "settings.shell.groups.yourData", - display: "settings.shell.groups.display", + tracking: "settings.shell.groups.tracking", + arrangement: "settings.shell.groups.arrangement", + notifications: "settings.shell.groups.notifications", healthProfile: "settings.shell.groups.healthProfile", ai: "settings.shell.groups.ai", - connectivity: "settings.shell.groups.connectivity", + connections: "settings.shell.groups.connections", + data: "settings.shell.groups.data", system: "settings.shell.groups.system", }; @@ -109,159 +121,84 @@ interface SettingsSection { } /** - * Source of truth for the section list that the settings shell renders - * in its sidebar + mobile chip-strip. + * Source of truth for the section list that the settings shell renders in + * its sidebar and in the phone's section sheet, pre-ordered by group. * - * The list used to render in a single flat - * order with no visual structure (19 entries deep, the AI cluster and the - * programmatic-access cluster interleaved, the health-profile pair split - * across the list). It is now clustered into the seven - * `SettingsSectionGroup`s, each entry carrying its `group`; the list is - * pre-ordered by group and the shell inserts a non-interactive header - * before the first VISIBLE entry of each group. This is a presentation-only - * reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS` - * (`section-slugs.ts`, which also carries the page-only slugs and drives - * `generateStaticParams()`) is untouched. + * `SETTINGS_SECTION_SLUGS` (`section-slugs.ts`) is the route registry and + * keeps its own order (it drives `generateStaticParams()` and the record + * landing order); this list decides only where each entry is shown. Every + * slug a person ever reached keeps its URL: a section that moved between + * groups moved here, not in the route. * - * "About" sits at the end of the list (System group). v1.4.33 IW7 had - * folded it into the sidebar user-card dropdown only, which left - * `/settings/about` an orphaned route — reachable by URL but discoverable - * nowhere in the settings navigation. It is now both a regular shell entry - * and a user-card dropdown item ("Über HealthLog" / "About HealthLog"). + * The card titles people cite stay as they are: "Export & import", + * "Integrations", "API tokens", "Shared access", "Modules". */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ - // ── Account ── + // ── Account ── profile and display, sign-in and sessions, who else may open + // this record. { slug: "account", titleKey: "settings.sections.account.title", icon: User, group: "account", }, - // v1.23 — the account-security home: second factors, recovery codes, - // passkey management. + // Second factors, passkeys, password, and (since v1.44) the active + // sessions, trusted devices and security activity that used to sit on the + // privacy page. { slug: "security", titleKey: "settings.sections.security.title", icon: ShieldCheck, group: "account", }, - // v1.36.0 — who else may open this record, and whose this account may open. - // In the Account group beside Security: both answer "who can get at my - // account and what have they done", and a person looking for one will look - // where the other is. { slug: "access", titleKey: "settings.sections.access.title", icon: UsersRound, group: "account", }, - // v1.23 — "Data & Privacy" assembles the export / deletion / retention / - // encryption / session / activity pieces into one coherent privacy pane. - { - slug: "privacy", - titleKey: "settings.sections.privacy.title", - icon: Lock, - group: "account", - }, - // ── Your data ── - // v1.18.0 — the "Was du trackst" hub: the front door for enabling / - // disabling the secondary tracking domains. + // ── Tracking ── which areas are recorded; each module's own settings + // (biomarkers, mood tags, injection sites) open from its row. { slug: "modules", titleKey: "settings.sections.modules.title", icon: Blocks, - group: "yourData", + group: "tracking", }, - { - slug: "integrations", - titleKey: "settings.sections.integrations.title", - icon: Link2, - group: "yourData", - }, - // v1.18.1 — Quellen (source weighting): which connection wins when - // two report the same metric. v1.25.3 — Kanäle (delivery channels) folded - // back into Notifications as an in-page group; - // `/settings/channels` 301-redirects to `/settings/notifications#channels`. - { - slug: "sources", - titleKey: "settings.sections.sources.title", - icon: Layers, - group: "yourData", - }, - // v1.18.0 — the full health record (PDF + FHIR R4 + zip package). - // v1.18.6.1 — NOT module-gated: the health record is a flagship export - // capability; gating its only entry-point on the `doctorReport` opt-out - // meant a stray toggle in the Modules hub made the whole section vanish - // with no in-context way back. The `doctorReport` module still governs - // the data layer server-side (403 `module.disabled` over Bearer). - { - slug: "gesundheitsakte", - titleKey: "settings.sections.gesundheitsakte.title", - icon: FileHeart, - group: "yourData", - }, - // v1.25.7 — clinician share links fold into the Gesundheitsakte section as - // a labelled "Sharing" group; `/settings/sharing` 301-redirects there. - { - slug: "export", - titleKey: "settings.sections.export.title", - icon: Download, - group: "yourData", - }, - // ── Display ── - // v1.17.1 — one nav entry replaces the four scattered Dashboard / - // Insights / Medications / Mood "arrange" entries; those routes still - // resolve, reached through this hub. v1.25.3 — renamed to "Appearance" / - // "Darstellung" and widened into the index for every module's - // view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) — - // each module lives on its own subpage at `/settings/layout/`; - // the old per-module routes 301-redirect there. + // ── Arrangement ── order and view, nothing else. The slug stays `layout`. { slug: "layout", titleKey: "settings.sections.layout.title", icon: Palette, - group: "display", + group: "arrangement", }, - // v1.25.7 — Vorsorge (preventive-care reminders) folded into - // "Darstellung"; `/settings/vorsorge` 301-redirects to - // `/settings/layout/vorsorge`. - { - slug: "thresholds", - titleKey: "settings.sections.thresholds.title", - icon: SlidersHorizontal, - group: "display", - }, - // v1.18.0 — "Benachrichtigungen" is the single module-gated - // reminder-types home; the standalone "Erinnerungen" hub is gone. + // ── Notifications ── every reminder kind, or the link to where it lives. { slug: "notifications", titleKey: "settings.sections.notifications.title", icon: Bell, - group: "display", + group: "notifications", }, // ── Health profile ── - // v1.35.0 — which pillars count toward the Health Score. Sits at the top - // of the health-profile cluster: it is a statement about the person's own - // health picture, not about how the app looks or what it records. { slug: "score", titleKey: "settings.sections.score.title", icon: Gauge, group: "healthProfile", }, - // v1.25 — Anamnese (medical history): the structured-record - // home for allergies + family history. NOT module-gated (like Vorsorge) — - // these are foundational health-profile records, always available. + { + slug: "thresholds", + titleKey: "settings.sections.thresholds.title", + icon: SlidersHorizontal, + group: "healthProfile", + }, { slug: "anamnesis", titleKey: "settings.sections.anamnesis.title", icon: ClipboardList, group: "healthProfile", }, - // v1.25 — Environmental context: home location, travel overrides, - // and the weather/daylight backfill. Module-gated on the opt-in - // `environment` module (`settings:environment` in the surface map), so the - // entry only appears once the user turns it on. + // Module-gated on the opt-in `environment` module. { slug: "environment", titleKey: "settings.sections.environment.title", @@ -275,37 +212,73 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: Sparkles, group: "ai", }, - // v1.18.0 — Coach: the Coach preference cards get their own entry, - // shown only when the coach module is enabled (`settings:coach` in the - // surface map). The AI entry above keeps - // provider / model / BYOK configuration. + // Module-gated on the coach module (`settings:coach`). { slug: "coach", titleKey: "settings.sections.coach.title", icon: Bot, group: "ai", }, - // ── Connectivity ── + // ── Connections ── data coming in (integrations, which source wins) and + // programmatic access going out (tokens, the MCP connector). + { + slug: "integrations", + titleKey: "settings.sections.integrations.title", + icon: Link2, + group: "connections", + }, + { + slug: "sources", + titleKey: "settings.sections.sources.title", + icon: Layers, + group: "connections", + }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound, - group: "connectivity", + group: "connections", }, - // v1.22.0 — remote MCP connector. Not module-gated: the card carries its - // own enable toggle (mirroring `gesundheitsakte`), so the entry-point - // stays reachable even with the opt-in module off. + // Not module-gated: the page shows the connector's state and links to the + // one switch in Modules. { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug, - group: "connectivity", + group: "connections", + }, + // ── Data ── getting data out and in, what is stored, and deleting it. + { + slug: "export", + titleKey: "settings.sections.export.title", + icon: Download, + group: "data", + }, + // Not module-gated: the health record is a flagship export, and its only + // entry point must not vanish behind the `doctorReport` opt-out. + { + slug: "gesundheitsakte", + titleKey: "settings.sections.gesundheitsakte.title", + icon: FileHeart, + group: "data", + }, + { + slug: "privacy", + titleKey: "settings.sections.privacy.title", + icon: Lock, + group: "data", }, - // ── System ── { slug: "advanced", titleKey: "settings.sections.advanced.title", icon: Settings2, + group: "data", + }, + // ── System ── + { + slug: "preview", + titleKey: "settings.sections.preview.title", + icon: FlaskConical, group: "system", }, { @@ -453,40 +426,6 @@ export function SettingsShell({ (section) => section.slug === activeSlug, ); - // v1.4.33 — keep the active chip in view inside the horizontal - // mobile strip. On a 393 CSS px viewport the strip is wider than the - // viewport and the rightmost chips sit off-screen; a user who tapped - // one of those chips lands on the new route with the strip still - // scrolled to the leftmost chip, which reads as if the navigation is - // broken. We pin the active chip to the left edge of the strip. - // - // v1.7.0 — adjust only the strip's own horizontal scroll offset - // (via `scrollTo({ left })`) instead of `Element.scrollIntoView()`. - // `scrollIntoView` walks every scrollable ancestor and adjusts both - // axes, so on mobile it also nudged the whole document vertically on - // each route change — a dizzy auto-scroll that fired on every settings - // sub-page tap. Scrolling the strip's own `scrollLeft` confines the - // motion to its horizontal axis and never touches the page scroll - // position. - const mobileStripRef = React.useRef(null); - React.useEffect(() => { - if (typeof window === "undefined") return; - const strip = mobileStripRef.current; - if (!strip) return; - const active = strip.querySelector('[aria-current="page"]'); - if (!active) return; - // Land the active chip at the strip's left edge, clamped to the - // scrollable range. `offsetLeft` is relative to the strip's padding - // box, which is the origin for `scrollLeft`. - const maxScroll = strip.scrollWidth - strip.clientWidth; - const target = Math.max(0, Math.min(active.offsetLeft, maxScroll)); - // Position the active chip instantly. A smooth behaviour here animates - // the strip from its reset `scrollLeft: 0` to the target on every tap, - // which reads as an unwanted "scroll from the start" sweep when swapping - // sections. An instant jump keeps the chip in view without the sweep. - strip.scrollTo({ left: target, behavior: "auto" }); - }, [activeSlug]); - // v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading` // (the canonical path); the slug fallback keeps the shell renderable on // its own (and in tests that mount it without a heading) by deriving the @@ -549,64 +488,18 @@ export function SettingsShell({
{headingBlock(true)}
) : null} - {/* Mobile section strip — horizontal scroll, hidden on md+. - `no-scrollbar` (defined in `globals.css`) suppresses the - painted scrollbar; the horizontal swipe + keyboard arrow - scrolling still work. Without it the 10-section strip - renders an always-on scrollbar at the top of every settings - page, which makes the page feel like every section card has - an overflow problem. - - v1.4.33 — `snap-x snap-mandatory` lets a swipe-flick land - on the next chip's leading edge instead of the in-between - dead zone. The auto `scrollIntoView({inline: "center"})` - effect above stays the canonical positioner for the active - chip; snap is the polish layer when the user manually flicks - through the strip without tapping. */} - + + {/* Below `md`: one button naming the current section, opening the + grouped section sheet. Hidden on md+, where the sidebar shows. */} + ({ + slug: section.slug, + title: t(section.titleKey), + groupLabel: t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group]), + icon: section.icon, + }))} + /> {/* v1.18.6.1 — two-row grid on desktop: row 1 / col 2 = heading (content column only) diff --git a/src/components/settings/timezone-select.tsx b/src/components/settings/timezone-select.tsx new file mode 100644 index 000000000..e7b481304 --- /dev/null +++ b/src/components/settings/timezone-select.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { resolveInitialTimezone } from "@/components/settings/account-section/account-section-utils"; +import { TimezonePicker } from "@/components/settings/timezone-picker"; +import { InlineAction } from "@/components/ui/inline-action"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { storeTimezone } from "@/lib/timezone-mirror"; +import { DEFAULT_TIMEZONE, detectBrowserTimezone } from "@/lib/tz/format"; + +/** + * The profile timezone as a Display preference: it saves the moment it + * changes, like the language, units and formats beside it. + * + * Until v1.44 it sat in the profile form and waited for that form's Save, + * which made it the one display preference that did not take effect on + * change. Persistence is unchanged (`PUT /api/auth/me/timezone`, the route + * that owns the IANA validation). The local mirror is written on success so + * every rendered timestamp repaints in the same tab before the `/me` + * refetch confirms it (issue #490). + * + * The select shows the stored zone: a value on screen is a value that is + * saved. The form used to pre-fill the device's zone over the untouched + * default and save it with the next profile save; that suggestion stays, + * as a one-tap offer under the select (`resolveInitialTimezone` decides + * when), instead of an unsaved value dressed as the current one. + */ +export function TimezoneSelect({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [optimistic, setOptimistic] = useState(null); + + const value = optimistic ?? user?.timezone ?? DEFAULT_TIMEZONE; + + const mutation = useMutation({ + mutationFn: async (next: string) => { + const res = await apiFetchRaw("/api/auth/me/timezone", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timezone: next }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return next; + }, + onSuccess: (next) => { + storeTimezone(next); + toastWrittenOutcome("success", t("settings.timezoneSaved")); + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + setOptimistic(null); + }, + onError: (err) => { + setOptimistic(null); + showErrorToast(t("settings.timezoneInvalid"), { cause: err }); + }, + }); + + function handleChange(next: string) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + mutation.mutate(next); + } + + // The device zone is read after hydration so both renders agree. + const mounted = useMounted(); + const suggestion = mounted + ? resolveInitialTimezone(user?.timezone, detectBrowserTimezone()) + : value; + + return ( +
+ + {suggestion !== value && optimistic === null ? ( + handleChange(suggestion)} + > + {t("settings.timezoneUseDevice", { zone: suggestion })} + + ) : null} +
+ ); +} diff --git a/src/components/settings/vorsorge-section.tsx b/src/components/settings/vorsorge-section.tsx index c6413b905..f6376bba1 100644 --- a/src/components/settings/vorsorge-section.tsx +++ b/src/components/settings/vorsorge-section.tsx @@ -9,17 +9,18 @@ * The shell supplies the page chrome (left nav, heading, subtitle) so this * body is the preventive-care cards only. Preventive-care reminders are not * a toggleable module, so this entry carries no `moduleGate` (always shown). + * + * v1.44 — order and view only. The reminder switches moved to Settings > + * Notifications, the screening hints to Health profile > Medical history + * beside the life circumstances they come from. */ import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings"; -import { ScreeningHintsCard } from "@/components/records/screening-hints-card"; export function VorsorgeSection() { return (
- {/* v1.43 (#614) — hints from the life circumstances, with their source. */} -
); } diff --git a/src/lib/command-palette/__tests__/settings-cards.test.ts b/src/lib/command-palette/__tests__/settings-cards.test.ts index 959e1bb04..8ea529b6f 100644 --- a/src/lib/command-palette/__tests__/settings-cards.test.ts +++ b/src/lib/command-palette/__tests__/settings-cards.test.ts @@ -7,6 +7,7 @@ import en from "../../../../messages/en.json"; import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES, + SETTINGS_MODULE_PAGES, } from "@/lib/command-palette/settings-cards"; /** @@ -63,7 +64,11 @@ describe("SETTINGS_CARDS", () => { ); it("every title key resolves", () => { - const missing = [...SETTINGS_CARDS, ...SETTINGS_LAYOUT_PAGES] + const missing = [ + ...SETTINGS_CARDS, + ...SETTINGS_LAYOUT_PAGES, + ...SETTINGS_MODULE_PAGES, + ] .map((c) => c.titleKey) .filter((key) => !hasKey(key)); expect(missing).toEqual([]); diff --git a/src/lib/command-palette/build-index.ts b/src/lib/command-palette/build-index.ts index 6f6578593..68e2bf471 100644 --- a/src/lib/command-palette/build-index.ts +++ b/src/lib/command-palette/build-index.ts @@ -20,7 +20,11 @@ import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry"; import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; import type { ShareDomain } from "@/lib/sharing/scope"; -import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES } from "./settings-cards"; +import { + SETTINGS_CARDS, + SETTINGS_LAYOUT_PAGES, + SETTINGS_MODULE_PAGES, +} from "./settings-cards"; import { PALETTE_SYNONYMS } from "./synonyms"; /** @@ -244,6 +248,22 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] { }); } } + if (sectionVisible.has("modules")) { + const modulesTitle = sectionTitle.get("modules") ?? ""; + for (const page of SETTINGS_MODULE_PAGES) { + if (modules?.[page.module] === false) continue; + const id = `settings:modules/${page.slug}`; + entries.push({ + id, + group: "settings", + title: t(page.titleKey), + hint: modulesTitle, + keywords: keywordsFor(id, [modulesTitle]), + icon: MODULE_ICONS[page.module], + run: { kind: "href", href: `/settings/modules/${page.slug}` }, + }); + } + } } // ── Module switches. ── diff --git a/src/lib/command-palette/settings-cards.ts b/src/lib/command-palette/settings-cards.ts index 0a6a248b0..3dd3a4191 100644 --- a/src/lib/command-palette/settings-cards.ts +++ b/src/lib/command-palette/settings-cards.ts @@ -95,11 +95,6 @@ export const SETTINGS_CARDS: ReadonlyArray = [ anchor: "retention", titleKey: "settings.privacy.retention.title", }, - { - section: "privacy", - anchor: "your-data-export", - titleKey: "settings.privacy.export.title", - }, { section: "privacy", anchor: "delete-data", @@ -111,17 +106,17 @@ export const SETTINGS_CARDS: ReadonlyArray = [ titleKey: "settings.privacy.posture.title", }, { - section: "privacy", + section: "security", anchor: "sessions", titleKey: "settings.security.sessionsTitle", }, { - section: "privacy", + section: "security", anchor: "trusted-devices", titleKey: "settings.security.trustedDevices.title", }, { - section: "privacy", + section: "security", anchor: "security-activity", titleKey: "settings.security.activityTitle", }, @@ -260,6 +255,11 @@ export const SETTINGS_CARDS: ReadonlyArray = [ titleKey: "notifications.lowStock.title", modules: ["medications"], }, + { + section: "notifications", + anchor: "vorsorge-reminders", + titleKey: "notifications.checkupReminders.title", + }, // ── Anamnesis ── { section: "anamnesis", @@ -355,7 +355,7 @@ export const SETTINGS_CARDS: ReadonlyArray = [ { section: "coach", anchor: "coach-activate", - titleKey: "settings.coach.activate.title", + titleKey: "settings.coach.state.title", }, { section: "coach", @@ -363,7 +363,7 @@ export const SETTINGS_CARDS: ReadonlyArray = [ titleKey: "insights.coach.settingsTitle", }, { - section: "coach", + section: "notifications", anchor: "coach-nudge", titleKey: "notifications.coachNudge.title", }, @@ -429,7 +429,7 @@ export const SETTINGS_CARDS: ReadonlyArray = [ ]; /** - * The Appearance hub's sub-pages, each its own entry. A module's page shows + * The Arrangement hub's sub-pages, each its own entry. A module's page shows * only while the module is on. */ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{ @@ -444,11 +444,6 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{ titleKey: "settings.sections.layout.medications.title", module: "medications", }, - { - slug: "mood", - titleKey: "settings.sections.layout.mood.title", - module: "mood", - }, { slug: "labs", titleKey: "settings.sections.layout.labs.title", @@ -466,3 +461,29 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{ }, { slug: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title" }, ]; + +/** + * The module pages under Settings > Modules (`/settings/modules/`), + * each its own entry while its module is on. + */ +export const SETTINGS_MODULE_PAGES: ReadonlyArray<{ + slug: string; + titleKey: string; + module: ModuleKey; +}> = [ + { + slug: "medications", + titleKey: "settings.sections.modules.pages.medications.title", + module: "medications", + }, + { + slug: "mood", + titleKey: "settings.sections.modules.pages.mood.title", + module: "mood", + }, + { + slug: "labs", + titleKey: "settings.sections.modules.pages.labs.title", + module: "labs", + }, +]; diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 71e13e454..7bbf00579 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -48,6 +48,7 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model"; import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker"; import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell"; import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups"; +import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids"; import { DASHBOARD_WIDGET_CATALOGUE_IDS, SCORE_RING_IDS, @@ -160,6 +161,13 @@ describe("every surface id names a surface that exists", () => { } }); + it("settings-module: a module page under Settings > Modules", () => { + const ids = new Set(MODULE_SETTINGS_PAGE_IDS); + for (const id of localIds("settings-module")) { + expect(ids.has(id), id).toBe(true); + } + }); + it("day-section: a section the day view renders", () => { const keys = new Set(DAY_SECTION_KEYS); for (const id of localIds("day-section")) { diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index a11e848a2..8d0c9b029 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -46,6 +46,7 @@ import type { ModuleKey } from "@/lib/modules/registry"; * - `derived`: a derived score, by its `DerivedMetricId`. * - `settings`: a Settings section, by its slug. * - `settings-layout`: a Settings, Layout group, by its id. + * - `settings-module`: a module's own Settings page under Modules, by its id. * - `correlation`: a correlation-discovery channel, by its channel key. * - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`. * - `day-section`: a section of the day view, by its `DaySectionKey`. The @@ -66,6 +67,7 @@ export const SURFACE_KINDS = [ "derived", "settings", "settings-layout", + "settings-module", "correlation", "score-ring", "day-section", @@ -200,11 +202,15 @@ const STATIC_SURFACE_MODULE = { // ── Settings, Layout groups ── "settings-layout:medications": "medications", - "settings-layout:mood": "mood", "settings-layout:labs": "labs", "settings-layout:illness": "illness", "settings-layout:documents": "inboundDocuments", + // ── Settings, a module's own page under Modules ── + "settings-module:medications": "medications", + "settings-module:mood": "mood", + "settings-module:labs": "labs", + // ── Correlation channels ── // Rated mood factors (`FACTOR:`) are mood's too; see // `correlationChannelSurfaceId`. diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index fbeda64b2..8d54e3eb1 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -98,6 +98,7 @@ export const SETTINGS_DESTINATION_INVENTORY = { export: PERSONAL, advanced: PERSONAL, privacy: PERSONAL, + preview: PERSONAL, about: PERSONAL, } as const satisfies Record< SettingsSectionSlug, diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts index 2783db463..d7fa5fc7c 100644 --- a/src/lib/settings/module-list-layout.ts +++ b/src/lib/settings/module-list-layout.ts @@ -76,13 +76,6 @@ export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs { return { ...defaults, order: [...defaults.order] }; } -export function isModuleListKey(value: unknown): value is ModuleListKey { - return ( - typeof value === "string" && - (MODULE_LIST_KEYS as readonly string[]).includes(value) - ); -} - export function isModuleListView(value: unknown): value is ModuleListView { return ( typeof value === "string" && From ec240bc07a42b47777ec1bb1491f97bf6997edbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:44:07 +0200 Subject: [PATCH 17/98] test(settings): follow the moved pages in e2e and cover the phone navigation Point the header, overscroll and timezone specs at the pages that hold those cards now, add the module pages and Preview features to the one-piece sweep, and add a 390 px journey through the grouped section sheet. Unit coverage for the Preview features card. --- e2e/settings-admin-one-piece.spec.ts | 8 ++- e2e/settings-mobile-nav.spec.ts | 47 +++++++++++++++++ e2e/settings-overscroll.spec.ts | 2 +- e2e/timezone-profile.spec.ts | 4 +- .../__tests__/preview-section.test.tsx | 51 +++++++++++++++++++ 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 e2e/settings-mobile-nav.spec.ts create mode 100644 src/components/settings/__tests__/preview-section.test.tsx diff --git a/e2e/settings-admin-one-piece.spec.ts b/e2e/settings-admin-one-piece.spec.ts index 4fc1b69d2..3f10f6540 100644 --- a/e2e/settings-admin-one-piece.spec.ts +++ b/e2e/settings-admin-one-piece.spec.ts @@ -58,11 +58,13 @@ const SETTINGS_ROUTES = [ "/settings/api", "/settings/mcp", "/settings/advanced", + "/settings/preview", "/settings/about", ] as const; /** - * The Layout hub's seven children. They are the routes whose header block + * The Arrangement hub's children, plus (v1.44) the module pages under + * Modules, which use the same back-link header. They are the routes whose header block * changed MOST — the shell used to hand-roll a `space-y-6` between the * back-link and the title, and `PageHeader`'s documented contract is * `space-y-1.5`. Nothing measured them before, so the step went from 24 px @@ -73,10 +75,12 @@ const LAYOUT_CHILD_ROUTES = [ "/settings/layout/dashboard", "/settings/layout/insights", "/settings/layout/medications", - "/settings/layout/mood", "/settings/layout/labs", "/settings/layout/illness", "/settings/layout/vorsorge", + "/settings/modules/medications", + "/settings/modules/mood", + "/settings/modules/labs", ] as const; /** The `space-y-1.5` between a back-link and the title it sits above. */ diff --git a/e2e/settings-mobile-nav.spec.ts b/e2e/settings-mobile-nav.spec.ts new file mode 100644 index 000000000..e8a115c68 --- /dev/null +++ b/e2e/settings-mobile-nav.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Settings navigation at 390 px: one button names the current section and + * opens a sheet with every section under its group. It replaced a strip of + * every section as chips, where most of Settings sat off screen. + */ +test.describe("settings navigation on a phone", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("names the section, opens the grouped list and moves on a tap", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/settings/account", { waitUntil: "domcontentloaded" }); + + const trigger = page.locator('[data-slot="settings-mobile-nav-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await expect(trigger).toContainText("Account"); + + // The click can land before hydration; retry until the sheet opens. + const list = page.locator('[data-slot="settings-mobile-nav-list"]'); + await expect(async () => { + await trigger.click(); + await expect(list).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 15_000 }); + + for (const group of ["Account", "Tracking", "Notifications", "System"]) { + await expect( + list.getByRole("heading", { name: group, exact: true }), + ).toBeVisible(); + } + + await list.getByRole("link", { name: "Security" }).click(); + await expect(page).toHaveURL(/\/settings\/security$/); + await expect(list).toHaveCount(0); + await expect(trigger).toContainText("Security"); + + // Nothing scrolls sideways. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); + }); +}); diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 59f858dfc..0df30bde2 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -33,7 +33,7 @@ const ROUTES = [ // one-scroll-floor assertion below). "/settings/layout/insights", "/settings/layout/medications", - "/settings/layout/mood", + "/settings/modules/mood", "/settings/modules", "/settings/account", "/settings/notifications", diff --git a/e2e/timezone-profile.spec.ts b/e2e/timezone-profile.spec.ts index f4865aa1d..7f4177b9a 100644 --- a/e2e/timezone-profile.spec.ts +++ b/e2e/timezone-profile.spec.ts @@ -6,7 +6,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * Issue #490 — client-rendered timestamps follow the PROFILE timezone. * * `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions - * card on /settings/privacy renders `lastActiveAt` through + * card on /settings/security renders `lastActiveAt` through * `useFormatters()`, which reads the localStorage timezone mirror that * `fetchMe` fills from the mocked payload. A fixed UTC instant must * therefore paint the MANILA wall clock (14:30), never the Berlin @@ -75,7 +75,7 @@ test.describe("profile-timezone display (#490)", () => { }), ); - await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" }); + await page.goto("/settings/security", { waitUntil: "domcontentloaded" }); // The sessions card is collapsed by default — expand it first. The // toggle's onClick can be dropped if Playwright clicks before React has diff --git a/src/components/settings/__tests__/preview-section.test.tsx b/src/components/settings/__tests__/preview-section.test.tsx new file mode 100644 index 000000000..7dc3e11ba --- /dev/null +++ b/src/components/settings/__tests__/preview-section.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { PreviewSection } from "../preview-section"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +primeLocales("de"); + +function render(topics: boolean | undefined, locale: "en" | "de" = "en") { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnMount: false } }, + }); + if (topics !== undefined) { + client.setQueryData(queryKeys.previewFeatures(), { topics }); + } + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("shows the Topics switch off by default, saved on change", () => { + const html = render(false); + expect(html).toContain('data-slot="settings-preview-topics-switch"'); + expect(html).toMatch( + /data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/, + ); + expect(html).toContain("Topics (beta)"); + // A switch, no Save: one save model per card. + expect(html).not.toContain('type="submit"'); + }); + + it("reads on once the person switched it on", () => { + expect(render(true)).toMatch( + /data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/, + ); + }); + + it("says the existing pages stay as they are and nothing raw leaks", () => { + const html = render(false, "de"); + expect(html).toContain("Themen (Beta)"); + expect(html).not.toContain("settings.preview."); + }); +}); From 131b48cc49c89f03cf64e33a695acee02f5d3e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:48:29 +0200 Subject: [PATCH 18/98] fix(i18n): one German word per thing across Settings Anbieter instead of Provider, Standard instead of Default, Gesundheitsscore for every spelling of the score, and Insights where the area was called Auswertungen. The Coach's rate-limit notice no longer shows English in the German interface. A guard reads the Settings namespaces of the German catalog and fails on the retired spellings. --- messages/de.json | 44 +++++++-------- messages/en.json | 2 +- src/__tests__/settings-glossary-de.test.ts | 55 +++++++++++++++++++ .../settings/__tests__/ai-section.test.tsx | 2 +- 4 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/settings-glossary-de.test.ts diff --git a/messages/de.json b/messages/de.json index 8b03cdf9b..a21d25a7e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -677,7 +677,7 @@ "same_time_baseline": "Aktivität zur gleichen Tageszeit", "upcoming_visit": "Termine" }, - "layoutReset": "Auf Defaults zurücksetzen", + "layoutReset": "Auf Standard zurücksetzen", "layoutResetTitle": "Dashboard-Layout zurücksetzen?", "layoutResetBody": "Dein Kachel-Layout und deine Auswahl für die Highlights für heute werden auf die Standardwerte zurückgesetzt.", "layoutResetConfirm": "Layout zurücksetzen", @@ -1827,7 +1827,7 @@ "phaseRed": "Rot", "phaseMinutes": "Min", "phasePercent": "%", - "phaseResetDefaults": "Auf Defaults zurücksetzen", + "phaseResetDefaults": "Auf Standard zurücksetzen", "phaseSaved": "Erinnerungsphasen gespeichert", "sourceWeb": "Web", "sourceApi": "iOS-App", @@ -2745,7 +2745,7 @@ "tooltip": "Persönlicher Rekord: {value} {unit} am {date}", "benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})" }, - "loadError": "Auswertungen konnten nicht geladen werden", + "loadError": "Insights konnten nicht geladen werden", "heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen", "heroRegenerating": "Wird generiert", "regenerateAnalysis": "Einschätzung neu erstellen", @@ -3088,8 +3088,8 @@ "settingsCancel": "Abbrechen", "dailyLimitTitle": "Heute-Limit erreicht", "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", - "providerRateLimitTitle": "Provider rate-limited", - "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", + "providerRateLimitTitle": "Anbieter ausgelastet", + "providerRateLimitBody": "Der Anbieter ist gerade überlastet. In etwa 5 Minuten geht es wieder.", "cluster": { "cardio": { "label": "Herz-Kreislauf", @@ -3211,7 +3211,7 @@ "workouts": "Geh mit mir meine letzten Workouts durch: Belastung, Häufigkeit und wie ich mich dazwischen erhole.", "recoveryPage": "Warum steht meine Erholung gerade dort, wo sie steht, und was treibt das?", "briefing": "Geh mit mir das heutige Briefing durch. Worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?", - "healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?", + "healthScore": "Warum liegt mein Gesundheitsscore bei {score} von 100, und was würde ihn am stärksten bewegen?", "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief. Was sticht heraus?", "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief. Was sticht heraus?", "correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", @@ -4857,10 +4857,10 @@ } }, "thresholds": { - "defaultLabel": "Default", + "defaultLabel": "Standard", "overrideToggleLabel": "Eigene Werte", - "resetAction": "Auf Default zurücksetzen", - "resetAllAction": "Alle auf Default zurücksetzen", + "resetAction": "Auf Standard zurücksetzen", + "resetAllAction": "Alle auf Standard zurücksetzen", "resetAllTitle": "Alle eigenen Bereiche zurücksetzen?", "resetAllBody": "Jede Messgröße kehrt zu ihrem Standardbereich zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.", "resetAllConfirm": "Alle zurücksetzen", @@ -4874,7 +4874,7 @@ "overrideWarning": "Werte außerhalb der Leitlinien können zu ungesunden Zielen führen. Nur nach ärztlicher Rücksprache überschreiben.", "saveSuccess": "Zielwerte aktualisiert", "saveError": "Zielwerte konnten nicht gespeichert werden", - "resetSuccess": "Auf Default zurückgesetzt", + "resetSuccess": "Auf Standard zurückgesetzt", "sourceOverride": "Eigener Wert", "metricWeight": "Gewicht", "metricBpSys": "Blutdruck (systolisch)", @@ -4890,7 +4890,7 @@ "metricBodyWater": "Körperwasser", "metricBoneMass": "Knochenmasse", "metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)", - "unsetExplanation": "Kein eigener Wert, berechneter Default aktiv.", + "unsetExplanation": "Kein eigener Wert, berechneter Standard aktiv.", "cardTitle": "Bereiche je Messgröße" }, "onboarding": { @@ -4908,7 +4908,7 @@ }, "quickAdd": { "title": "In Sekunden erfasst", - "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein: Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights." + "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein: Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Gesundheitsscore und die KI-Insights." }, "measurements": { "title": "Messwerte & Zielbereiche", @@ -5860,7 +5860,7 @@ "error": "Konnte nicht gespeichert werden. Bitte erneut versuchen.", "toggleable": { "title": "Was du trackst", - "description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen, deine Daten bleiben erhalten." + "description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Insights, deine Daten bleiben erhalten." }, "manageIn": "In {section} verwalten", "operatorDisabled": "Serverweit deaktiviert", @@ -6004,12 +6004,12 @@ "omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.", "omittedLink": "Module öffnen", "refusal": { - "empty": "Ein Health Score braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt. Der Score nennt selbst, worauf er beruht." + "empty": "Ein Gesundheitsscore braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt. Der Score nennt selbst, worauf er beruht." }, "notice": { "upgrade": { "title": "Was zählt, entscheidest jetzt du", - "body": "Welche Bereiche in deinen Health Score einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat." + "body": "Welche Bereiche in deinen Gesundheitsscore einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat." }, "changed": { "title": "Du hast geändert, was zählt", @@ -6195,7 +6195,7 @@ "lastGeneratedAt": "Zuletzt generiert", "ai": { "chatgptConnectedBadge": "ChatGPT verbunden", - "adminAiActiveBadge": "Admin-Provider aktiv", + "adminAiActiveBadge": "Betreiber-Anbieter aktiv", "connectionExpiredBadge": "Verbindung abgelaufen", "connectedSince": "Verbunden seit {when}.", "deviceCodeHeading": "Verbindung auf chatgpt.com abschließen", @@ -6233,8 +6233,8 @@ "moveUp": "Nach oben", "moveDown": "Nach unten", "removeFromChain": "Aus Kette entfernen", - "addProvider": "Provider hinzufügen", - "addNoneAvailable": "Alle Provider sind bereits in der Kette", + "addProvider": "Anbieter hinzufügen", + "addNoneAvailable": "Alle Anbieter sind bereits in der Kette", "saveOrder": "Reihenfolge speichern", "saved": "Kette gespeichert", "saveFailed": "Speichern der Kette fehlgeschlagen", @@ -6242,7 +6242,7 @@ "resetConfirmTitle": "Kette auf Standard zurücksetzen?", "resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten." }, - "activeProviderHeading": "Aktiver Provider", + "activeProviderHeading": "Aktiver Anbieter", "activeProviderLabel": "Anbieter einrichten", "providerSelect": { "codex": "ChatGPT-Account (Codex)", @@ -6285,10 +6285,10 @@ }, "adminOpenai": { "title": "Admin-OpenAI", - "body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Provider. Hier ist nichts einzustellen.", + "body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Anbieter. Hier ist nichts einzustellen.", "notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert." }, - "testProvider": "Aktiven Provider testen", + "testProvider": "Aktiven Anbieter testen", "testSuccess": "OK: {provider} ({model})", "testFailedShort": "Test fehlgeschlagen: {message}", "testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen: unerwartete Antwort vom Server.", @@ -12334,7 +12334,7 @@ "scopeSomeCapability": "Alles, was du nicht auswählst, bleibt für sie verschlossen.", "scopeManageNote": "Verwaltender Zugriff umfasst immer die gesamte Akte.", "scopeLeakNote": "Ein Bereich kann in seinen Notizen und Bezeichnungen auch den Rest deiner Gesundheit erwähnen. Wer einen Bereich bekommt, bekommt alles, was darin steht.", - "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", + "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Gesundheitsscore und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", "scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.", "scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.", "scopeChooseHint": "Wähle, ob die gesamte Akte oder nur einzelne Bereiche geteilt werden.", diff --git a/messages/en.json b/messages/en.json index 1a795ba9f..c2ff4e024 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5508,7 +5508,7 @@ "deviceTypeToggle": "Device-type priority ({count})", "deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.", "deviceTypeDefault": "Default device-type ladder", - "resetDeviceTypes": "Reset device-types", + "resetDeviceTypes": "Reset device types", "metrics": { "steps": "Steps", "activeEnergy": "Active energy", diff --git a/src/__tests__/settings-glossary-de.test.ts b/src/__tests__/settings-glossary-de.test.ts new file mode 100644 index 000000000..57f031d5d --- /dev/null +++ b/src/__tests__/settings-glossary-de.test.ts @@ -0,0 +1,55 @@ +/** + * The German Settings glossary: one word per thing. + * + * Settings used "Provider" beside "Anbieter", "Default" beside "Standard", + * and the score as "Health Score", "Health-Score", "Gesundheits-Score" and + * "Gesundheitsscore", sometimes on one screen. The settled words are + * Anbieter, Standard and Gesundheitsscore; the Insights area is "Insights" + * (the name already dominant in the catalog). This reads the German catalog + * under the Settings namespaces and fails on the retired spellings. + * + * Product names are not glossary words: "ChatGPT", "OpenAI" and the like are + * left alone, and so is "Provider" inside a code-shaped token. + */ +import { describe, expect, it } from "vitest"; + +import de from "../../messages/de.json"; + +const NAMESPACES = ["settings", "thresholds"] as const; + +const RETIRED: ReadonlyArray<{ pattern: RegExp; use: string }> = [ + { pattern: /\bProvider\b/, use: "Anbieter" }, + { pattern: /\bDefaults?\b/, use: "Standard" }, + { pattern: /\bHealth[- ]?Score\b/i, use: "Gesundheitsscore" }, + { pattern: /\bGesundheits-Score\b/, use: "Gesundheitsscore" }, +]; + +function walk(node: unknown, path: string, out: Array<[string, string]>) { + if (typeof node === "string") out.push([path, node]); + else if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + walk(value, path ? `${path}.${key}` : key, out); + } + } +} + +describe("German Settings glossary", () => { + const strings: Array<[string, string]> = []; + for (const ns of NAMESPACES) { + walk((de as Record)[ns], ns, strings); + } + + it("reads the catalog (an empty read is a broken walker)", () => { + expect(strings.length).toBeGreaterThan(500); + }); + + it("uses the settled words", () => { + const offenders: string[] = []; + for (const [key, value] of strings) { + for (const { pattern, use } of RETIRED) { + if (pattern.test(value)) offenders.push(`${key}: "${value}" → ${use}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index 24a3327e7..1d4df55bf 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -218,7 +218,7 @@ describe(" — dropdown-driven UX (B2)", () => { // both ship without the "KI"/"AI" prefix per the project-voice rule // ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is // labelled for what it does: it chooses the form, not the order. - expect(html).toContain("Aktiver Provider"); + expect(html).toContain("Aktiver Anbieter"); expect(html).toContain("Anbieter einrichten"); }); From 65e0cba0bfda1db0e43a4ed624619d7bb02449f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:49:06 +0200 Subject: [PATCH 19/98] refactor(integrations): start and end every OAuth connection through the kit Minting the state, building the consent URL and dropping a grant each had a copy per provider route. The routes keep their auth, rate limits and redirects and hand the rest to one implementation, which the managed-profile routes reuse. --- src/app/api/google-health/connect/route.ts | 63 ++--- src/app/api/google-health/disconnect/route.ts | 20 +- src/app/api/oura/connect/route.ts | 34 +-- src/app/api/oura/disconnect/route.ts | 23 +- src/app/api/polar/connect/route.ts | 34 +-- src/app/api/polar/disconnect/route.ts | 21 +- src/app/api/strava/connect/route.ts | 34 +-- src/app/api/strava/disconnect/route.ts | 30 +-- src/app/api/whoop/connect/route.ts | 63 ++--- src/app/api/whoop/disconnect/route.ts | 20 +- src/app/api/withings/connect/route.ts | 58 ++--- src/app/api/withings/disconnect/route.ts | 43 +--- .../integrations/oauth-kit/connect-start.ts | 240 ++++++++++++++++++ src/lib/integrations/oauth-kit/disconnect.ts | 144 +++++++++++ 14 files changed, 483 insertions(+), 344 deletions(-) create mode 100644 src/lib/integrations/oauth-kit/connect-start.ts create mode 100644 src/lib/integrations/oauth-kit/disconnect.ts diff --git a/src/app/api/google-health/connect/route.ts b/src/app/api/google-health/connect/route.ts index f22cf1e73..a4c638c3d 100644 --- a/src/app/api/google-health/connect/route.ts +++ b/src/app/api/google-health/connect/route.ts @@ -1,20 +1,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; import { apiError } from "@/lib/api-response"; -import { prisma } from "@/lib/db"; -import { annotate, getEvent } from "@/lib/logging/context"; +import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { - generatePkcePair, - getAuthorizationUrl, -} from "@/lib/google-health/client"; -import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials"; -import { - GOOGLE_HEALTH_OAUTH_STATE_COOKIE, - GOOGLE_HEALTH_OAUTH_STATE_TTL_MS, - mintGoogleHealthOAuthStateNonce, -} from "@/lib/google-health/oauth-state"; import { NextRequest, NextResponse } from "next/server"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** * Redirect the user to the Google OAuth consent page for the Google Health @@ -55,27 +47,14 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const creds = await getUserGoogleHealthCredentials(user.id); - if (!creds) { - return apiError( - "Please configure your Google OAuth Client ID and Client Secret in Settings first.", - 400, - ); - } - - const nonce = mintGoogleHealthOAuthStateNonce(); - const { verifier, challenge } = generatePkcePair(); - try { - await prisma.googleHealthOAuthState.create({ - data: { - nonce, - userId: user.id, - codeVerifier: verifier, - expiresAt: new Date(Date.now() + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS), - }, - }); - } catch (err) { - getEvent()?.setError(err); + const started = await startOAuthConnect("google-health", user.id); + if (!started.ok) { + if (started.reason === "nocreds") { + return apiError( + "Please configure your Google OAuth Client ID and Client Secret in Settings first.", + 400, + ); + } annotate({ action: { name: "google_health.connect.create_failed" } }); return NextResponse.redirect( new URL( @@ -85,16 +64,8 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const url = getAuthorizationUrl(nonce, creds, challenge); - - const response = NextResponse.redirect(url); - response.cookies.set(GOOGLE_HEALTH_OAUTH_STATE_COOKIE, nonce, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/google-health/disconnect/route.ts b/src/app/api/google-health/disconnect/route.ts index 41c5fbba5..0d9120fe4 100644 --- a/src/app/api/google-health/disconnect/route.ts +++ b/src/app/api/google-health/disconnect/route.ts @@ -1,9 +1,7 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { markDisconnected } from "@/lib/integrations/status"; /** * Disconnect the Google Health integration for the current user (v1.26.0). @@ -20,23 +18,9 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "google_health.disconnect" } }); - const connection = await prisma.googleHealthConnection.findUnique({ - where: { userId: user.id }, - }); - - if (!connection) { + if ((await disconnectOAuth("google-health", user.id)) === "not_connected") { return apiError("No Google Health connection", 404); } - await prisma.googleHealthConnection.delete({ - where: { userId: user.id }, - }); - - await auditLog("google_health.disconnect", { - userId: user.id, - }); - - await markDisconnected(user.id, "google-health"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index f56807831..4c7ce25d9 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -1,14 +1,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/oura/client"; -import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { - OAUTH_STATE_TTL_MS, - mintSignedState, - oauthStateCookieName, -} from "@/lib/oauth/signed-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; /** @@ -45,8 +41,8 @@ export const GET = apiHandler(async () => { ); } - const creds = await getOuraClientCredentials(user.id); - if (!creds) { + const started = await startOAuthConnect("oura", user.id); + if (!started.ok) { annotate({ action: { name: "oura.connect.no_credentials" } }); return NextResponse.redirect( new URL( @@ -56,16 +52,8 @@ export const GET = apiHandler(async () => { ); } - const state = mintSignedState("oura", user.id); - const url = getAuthorizationUrl(state, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(oauthStateCookieName("oura"), state, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/oura/disconnect/route.ts b/src/app/api/oura/disconnect/route.ts index 91ae0804b..e2aca6855 100644 --- a/src/app/api/oura/disconnect/route.ts +++ b/src/app/api/oura/disconnect/route.ts @@ -1,11 +1,8 @@ -import { prisma } from "@/lib/db"; -import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { markDisconnected } from "@/lib/integrations/status"; /** * v1.17.0 — disconnect Oura for the current user. @@ -25,25 +22,9 @@ export const POST = apiHandler(async () => { }); } - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { ouraAccessTokenEncrypted: true }, - }); - if (!dbUser?.ouraAccessTokenEncrypted) { + if ((await disconnectOAuth("oura", user.id)) === "not_connected") { return apiError("No Oura connection", 404); } - await prisma.user.update({ - where: { id: user.id }, - data: { - ouraAccessTokenEncrypted: null, - ouraRefreshTokenEncrypted: null, - ouraScopeDenied: Prisma.DbNull, - }, - }); - - await auditLog("oura.disconnect", { userId: user.id }); - await markDisconnected(user.id, "oura"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 84b205804..5bd3fd7b4 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -1,14 +1,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/polar/client"; -import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { - OAUTH_STATE_TTL_MS, - mintSignedState, - oauthStateCookieName, -} from "@/lib/oauth/signed-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; /** @@ -48,8 +44,8 @@ export const GET = apiHandler(async () => { ); } - const creds = await getPolarClientCredentials(user.id); - if (!creds) { + const started = await startOAuthConnect("polar", user.id); + if (!started.ok) { annotate({ action: { name: "polar.connect.no_credentials" } }); return NextResponse.redirect( new URL( @@ -59,16 +55,8 @@ export const GET = apiHandler(async () => { ); } - const state = mintSignedState("polar", user.id); - const url = getAuthorizationUrl(state, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(oauthStateCookieName("polar"), state, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/polar/disconnect/route.ts b/src/app/api/polar/disconnect/route.ts index 76b41e1a9..e1f8eb510 100644 --- a/src/app/api/polar/disconnect/route.ts +++ b/src/app/api/polar/disconnect/route.ts @@ -1,10 +1,8 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { markDisconnected } from "@/lib/integrations/status"; /** * v1.17.0 — disconnect Polar for the current user. @@ -24,24 +22,9 @@ export const POST = apiHandler(async () => { }); } - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { polarAccessTokenEncrypted: true }, - }); - if (!dbUser?.polarAccessTokenEncrypted) { + if ((await disconnectOAuth("polar", user.id)) === "not_connected") { return apiError("No Polar connection", 404); } - await prisma.user.update({ - where: { id: user.id }, - data: { - polarAccessTokenEncrypted: null, - polarUserIdEncrypted: null, - }, - }); - - await auditLog("polar.disconnect", { userId: user.id }); - await markDisconnected(user.id, "polar"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/strava/connect/route.ts b/src/app/api/strava/connect/route.ts index c76a61786..1badb402f 100644 --- a/src/app/api/strava/connect/route.ts +++ b/src/app/api/strava/connect/route.ts @@ -1,14 +1,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/strava/client"; -import { getStravaClientCredentials } from "@/lib/strava/credentials"; import { - OAUTH_STATE_TTL_MS, - mintSignedState, - oauthStateCookieName, -} from "@/lib/oauth/signed-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; /** @@ -45,8 +41,8 @@ export const GET = apiHandler(async () => { ); } - const creds = await getStravaClientCredentials(user.id); - if (!creds) { + const started = await startOAuthConnect("strava", user.id); + if (!started.ok) { annotate({ action: { name: "strava.connect.no_credentials" } }); return NextResponse.redirect( new URL( @@ -56,16 +52,8 @@ export const GET = apiHandler(async () => { ); } - const state = mintSignedState("strava", user.id); - const url = getAuthorizationUrl(state, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(oauthStateCookieName("strava"), state, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/strava/disconnect/route.ts b/src/app/api/strava/disconnect/route.ts index e3634b330..e52813323 100644 --- a/src/app/api/strava/disconnect/route.ts +++ b/src/app/api/strava/disconnect/route.ts @@ -1,15 +1,8 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; -import { annotate, getEvent } from "@/lib/logging/context"; +import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { markDisconnected } from "@/lib/integrations/status"; -import { deauthorize } from "@/lib/strava/client"; -import { - getStravaConnection, - STRAVA_DISCONNECTED, -} from "@/lib/strava/credentials"; /** * v1.28.x — disconnect Strava for the current user. @@ -30,26 +23,9 @@ export const POST = apiHandler(async () => { }); } - const connection = await getStravaConnection(user.id); - if (!connection) { + if ((await disconnectOAuth("strava", user.id)) === "not_connected") { return apiError("No Strava connection", 404); } - // Best-effort revoke at Strava; a Strava-side outage must never block the - // local disconnect. - try { - await deauthorize(connection.accessToken); - } catch (err) { - getEvent()?.addWarning(`strava: deauthorize failed on disconnect: ${err}`); - } - - await prisma.user.update({ - where: { id: user.id }, - data: STRAVA_DISCONNECTED, - }); - - await auditLog("strava.disconnect", { userId: user.id }); - await markDisconnected(user.id, "strava"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/whoop/connect/route.ts b/src/app/api/whoop/connect/route.ts index 975ecad7e..6f8643d05 100644 --- a/src/app/api/whoop/connect/route.ts +++ b/src/app/api/whoop/connect/route.ts @@ -1,20 +1,15 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; -import { prisma } from "@/lib/db"; -import { annotate, getEvent } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/whoop/client"; -import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; -import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import { - WHOOP_OAUTH_STATE_COOKIE, - WHOOP_OAUTH_STATE_TTL_MS, - mintWhoopOAuthStateNonce, -} from "@/lib/whoop/oauth-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; import { validateReturnScheme } from "@/lib/whoop/return-scheme"; import { NextRequest, NextResponse } from "next/server"; import { relativeRedirect } from "@/lib/http/relative-redirect"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** * Redirect the user to the WHOOP OAuth authorization page (v1.11.0; native @@ -91,47 +86,27 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const creds = await getUserWhoopCredentials(userId); - if (!creds) { - annotate({ action: { name: "whoop.connect.no_credentials" } }); - return relativeRedirect( - "/settings/integrations?whoop=error&reason=nocreds", - ); - } - // Validate the optional native return scheme against the strict allowlist. // An invalid/absent value resolves to null → the callback uses the web // redirect (never an arbitrary reflected scheme). const returnScheme = validateReturnScheme(searchParams.get("return_scheme")); - const nonce = mintWhoopOAuthStateNonce(); - try { - await prisma.whoopOAuthState.create({ - data: { - nonce, - userId, - expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS), - returnScheme, - }, - }); - } catch (err) { - getEvent()?.setError(err); + const started = await startOAuthConnect("whoop", userId, { returnScheme }); + if (!started.ok) { + if (started.reason === "nocreds") { + annotate({ action: { name: "whoop.connect.no_credentials" } }); + return relativeRedirect( + "/settings/integrations?whoop=error&reason=nocreds", + ); + } annotate({ action: { name: "whoop.connect.create_failed" } }); return relativeRedirect( "/settings/integrations?whoop=error&reason=connect", ); } - const url = getAuthorizationUrl(nonce, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(WHOOP_OAUTH_STATE_COOKIE, nonce, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/whoop/disconnect/route.ts b/src/app/api/whoop/disconnect/route.ts index 36f37ea8e..2be5845ac 100644 --- a/src/app/api/whoop/disconnect/route.ts +++ b/src/app/api/whoop/disconnect/route.ts @@ -1,9 +1,7 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { markDisconnected } from "@/lib/integrations/status"; /** * Disconnect the WHOOP integration for the current user (v1.11.0). @@ -20,23 +18,9 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "whoop.disconnect" } }); - const connection = await prisma.whoopConnection.findUnique({ - where: { userId: user.id }, - }); - - if (!connection) { + if ((await disconnectOAuth("whoop", user.id)) === "not_connected") { return apiError("No WHOOP connection", 404); } - await prisma.whoopConnection.delete({ - where: { userId: user.id }, - }); - - await auditLog("whoop.disconnect", { - userId: user.id, - }); - - await markDisconnected(user.id, "whoop"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts index 689c2184f..ee2b7b0c0 100644 --- a/src/app/api/withings/connect/route.ts +++ b/src/app/api/withings/connect/route.ts @@ -1,17 +1,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { prisma } from "@/lib/db"; -import { annotate, getEvent } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/withings/client"; -import { getUserWithingsCredentials } from "@/lib/withings/credentials"; import { - WITHINGS_OAUTH_STATE_COOKIE, - WITHINGS_OAUTH_STATE_TTL_MS, - mintWithingsOAuthStateNonce, -} from "@/lib/withings/oauth-state"; + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest, NextResponse } from "next/server"; import { relativeRedirect } from "@/lib/http/relative-redirect"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** * Redirects the user to Withings OAuth authorization page. @@ -62,41 +57,22 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const creds = await getUserWithingsCredentials(user.id); - if (!creds) { - annotate({ action: { name: "withings.connect.no_credentials" } }); - return relativeRedirect( - "/settings/integrations?withings=error&reason=nocreds", - ); - } - - const nonce = mintWithingsOAuthStateNonce(); - try { - await prisma.withingsOAuthState.create({ - data: { - nonce, - userId: user.id, - expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS), - }, - }); - } catch (err) { - getEvent()?.setError(err); + const started = await startOAuthConnect("withings", user.id); + if (!started.ok) { + if (started.reason === "nocreds") { + annotate({ action: { name: "withings.connect.no_credentials" } }); + return relativeRedirect( + "/settings/integrations?withings=error&reason=nocreds", + ); + } annotate({ action: { name: "withings.connect.create_failed" } }); return relativeRedirect( "/settings/integrations?withings=error&reason=connect", ); } - const url = getAuthorizationUrl(nonce, creds); - - const response = NextResponse.redirect(url); - response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000), - path: "/", - }); - - return response; + return setConnectStateCookie( + NextResponse.redirect(started.authorizationUrl), + started.cookie, + ); }); diff --git a/src/app/api/withings/disconnect/route.ts b/src/app/api/withings/disconnect/route.ts index 109fa6c65..e8dd58e18 100644 --- a/src/app/api/withings/disconnect/route.ts +++ b/src/app/api/withings/disconnect/route.ts @@ -1,15 +1,7 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { auditLog } from "@/lib/auth/audit"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { decrypt } from "@/lib/crypto"; -import { unsubscribeWebhook } from "@/lib/withings/client"; -import { - WITHINGS_NOTIFY_APPLIS, - getWithingsWebhookCallbackUrl, -} from "@/lib/withings/sync"; -import { markDisconnected } from "@/lib/integrations/status"; /** * Disconnect Withings integration for the current user. @@ -18,40 +10,9 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "withings.disconnect" } }); - const connection = await prisma.withingsConnection.findUnique({ - where: { userId: user.id }, - }); - - if (!connection) { + if ((await disconnectOAuth("withings", user.id)) === "not_connected") { return apiError("No Withings connection", 404); } - // Best-effort unsubscribe across every appli category we subscribed - // to in setupWebhook. A token-expired failure on one category should - // not stop us from cleaning up the others. - try { - const accessToken = decrypt(connection.accessToken); - const callbackUrl = getWithingsWebhookCallbackUrl(); - for (const appli of WITHINGS_NOTIFY_APPLIS) { - try { - await unsubscribeWebhook(accessToken, callbackUrl, appli); - } catch { - // Ignore — token might be expired or the appli was never subscribed. - } - } - } catch { - // Ignore — token might be undecryptable. - } - - await prisma.withingsConnection.delete({ - where: { userId: user.id }, - }); - - await auditLog("withings.disconnect", { - userId: user.id, - }); - - await markDisconnected(user.id, "withings"); - return apiSuccess({ disconnected: true }); }); diff --git a/src/lib/integrations/oauth-kit/connect-start.ts b/src/lib/integrations/oauth-kit/connect-start.ts new file mode 100644 index 000000000..068c9e764 --- /dev/null +++ b/src/lib/integrations/oauth-kit/connect-start.ts @@ -0,0 +1,240 @@ +/** + * Starting an OAuth connect: resolve the record's provider app, mint the + * state, and build the consent URL. One implementation for every way a + * connect starts, the person's own browser navigation (`GET + * /api//connect`) and a guardian's request for a managed profile + * (`POST /api/record-settings/integrations//connect`). + * + * The state names exactly one record, `userId`, and is either HMAC-signed + * (Oura, Polar, Strava; `src/lib/oauth/signed-state.ts`) or a ten-minute + * server-side ledger row (Withings, WHOOP, Google Health). It is set twice: + * as the `state` URL parameter and as an httpOnly cookie, and the callback + * requires both to agree before anything else. + */ +import type { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { getEvent } from "@/lib/logging/context"; +import { + OAUTH_STATE_TTL_MS, + mintSignedState, + oauthStateCookieName, +} from "@/lib/oauth/signed-state"; +import { getAuthorizationUrl as withingsAuthorizationUrl } from "@/lib/withings/client"; +import { getUserWithingsCredentials } from "@/lib/withings/credentials"; +import { + WITHINGS_OAUTH_STATE_COOKIE, + WITHINGS_OAUTH_STATE_TTL_MS, + mintWithingsOAuthStateNonce, +} from "@/lib/withings/oauth-state"; +import { getAuthorizationUrl as whoopAuthorizationUrl } from "@/lib/whoop/client"; +import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; +import { + WHOOP_OAUTH_STATE_COOKIE, + WHOOP_OAUTH_STATE_TTL_MS, + mintWhoopOAuthStateNonce, +} from "@/lib/whoop/oauth-state"; +import { + generatePkcePair, + getAuthorizationUrl as googleHealthAuthorizationUrl, +} from "@/lib/google-health/client"; +import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials"; +import { + GOOGLE_HEALTH_OAUTH_STATE_COOKIE, + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS, + mintGoogleHealthOAuthStateNonce, +} from "@/lib/google-health/oauth-state"; +import { getAuthorizationUrl as ouraAuthorizationUrl } from "@/lib/oura/client"; +import { getOuraClientCredentials } from "@/lib/oura/credentials"; +import { getAuthorizationUrl as polarAuthorizationUrl } from "@/lib/polar/client"; +import { getPolarClientCredentials } from "@/lib/polar/credentials"; +import { getAuthorizationUrl as stravaAuthorizationUrl } from "@/lib/strava/client"; +import { getStravaClientCredentials } from "@/lib/strava/credentials"; +import type { OAuthKitProvider } from "./token"; + +export interface ConnectStateCookie { + readonly name: string; + readonly value: string; + readonly maxAgeSeconds: number; +} + +export type ConnectStartResult = + | { + readonly ok: true; + readonly authorizationUrl: string; + readonly cookie: ConnectStateCookie; + } + | { + readonly ok: false; + /** `nocreds`: no provider app; `connect`: the state could not be stored. */ + readonly reason: "nocreds" | "connect"; + }; + +export interface ConnectStartOptions { + /** WHOOP only: a validated native return scheme, carried on the ledger row. */ + readonly returnScheme?: string | null; +} + +/** Can the record resolve a provider app (its own or the deployment's)? */ +export async function recordHasClientApp( + provider: OAuthKitProvider, + userId: string, +): Promise { + switch (provider) { + case "withings": + return (await getUserWithingsCredentials(userId)) !== null; + case "whoop": + return (await getUserWhoopCredentials(userId)) !== null; + case "google-health": + return (await getUserGoogleHealthCredentials(userId)) !== null; + case "oura": + return (await getOuraClientCredentials(userId)) !== null; + case "polar": + return (await getPolarClientCredentials(userId)) !== null; + case "strava": + return (await getStravaClientCredentials(userId)) !== null; + } +} + +function signedStart( + provider: "oura" | "polar" | "strava", + userId: string, + url: (state: string) => string, +): ConnectStartResult { + const state = mintSignedState(provider, userId); + return { + ok: true, + authorizationUrl: url(state), + cookie: { + name: oauthStateCookieName(provider), + value: state, + maxAgeSeconds: Math.floor(OAUTH_STATE_TTL_MS / 1000), + }, + }; +} + +async function ledgerStart( + write: () => Promise, + cookie: ConnectStateCookie, + url: () => string, +): Promise { + try { + await write(); + } catch (err) { + getEvent()?.setError(err); + return { ok: false, reason: "connect" }; + } + return { ok: true, authorizationUrl: url(), cookie }; +} + +/** Mint the state for `userId` and build the provider's consent URL. */ +export async function startOAuthConnect( + provider: OAuthKitProvider, + userId: string, + opts: ConnectStartOptions = {}, +): Promise { + switch (provider) { + case "withings": { + const creds = await getUserWithingsCredentials(userId); + if (!creds) return { ok: false, reason: "nocreds" }; + const nonce = mintWithingsOAuthStateNonce(); + return ledgerStart( + () => + prisma.withingsOAuthState.create({ + data: { + nonce, + userId, + expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS), + }, + }), + { + name: WITHINGS_OAUTH_STATE_COOKIE, + value: nonce, + maxAgeSeconds: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000), + }, + () => withingsAuthorizationUrl(nonce, creds), + ); + } + case "whoop": { + const creds = await getUserWhoopCredentials(userId); + if (!creds) return { ok: false, reason: "nocreds" }; + const nonce = mintWhoopOAuthStateNonce(); + return ledgerStart( + () => + prisma.whoopOAuthState.create({ + data: { + nonce, + userId, + expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS), + returnScheme: opts.returnScheme ?? null, + }, + }), + { + name: WHOOP_OAUTH_STATE_COOKIE, + value: nonce, + maxAgeSeconds: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000), + }, + () => whoopAuthorizationUrl(nonce, creds), + ); + } + case "google-health": { + const creds = await getUserGoogleHealthCredentials(userId); + if (!creds) return { ok: false, reason: "nocreds" }; + const nonce = mintGoogleHealthOAuthStateNonce(); + const { verifier, challenge } = generatePkcePair(); + return ledgerStart( + () => + prisma.googleHealthOAuthState.create({ + data: { + nonce, + userId, + codeVerifier: verifier, + expiresAt: new Date( + Date.now() + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS, + ), + }, + }), + { + name: GOOGLE_HEALTH_OAUTH_STATE_COOKIE, + value: nonce, + maxAgeSeconds: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000), + }, + () => googleHealthAuthorizationUrl(nonce, creds, challenge), + ); + } + case "oura": { + const creds = await getOuraClientCredentials(userId); + if (!creds) return { ok: false, reason: "nocreds" }; + return signedStart("oura", userId, (s) => ouraAuthorizationUrl(s, creds)); + } + case "polar": { + const creds = await getPolarClientCredentials(userId); + if (!creds) return { ok: false, reason: "nocreds" }; + return signedStart("polar", userId, (s) => + polarAuthorizationUrl(s, creds), + ); + } + case "strava": { + const creds = await getStravaClientCredentials(userId); + if (!creds) return { ok: false, reason: "nocreds" }; + return signedStart("strava", userId, (s) => + stravaAuthorizationUrl(s, creds), + ); + } + } +} + +/** Set the state cookie a started connect needs on its response. */ +export function setConnectStateCookie( + response: NextResponse, + cookie: ConnectStateCookie, +): NextResponse { + response.cookies.set(cookie.name, cookie.value, { + httpOnly: true, + secure: shouldEmitSecureCookie(), + sameSite: "lax", + maxAge: cookie.maxAgeSeconds, + path: "/", + }); + return response; +} diff --git a/src/lib/integrations/oauth-kit/disconnect.ts b/src/lib/integrations/oauth-kit/disconnect.ts new file mode 100644 index 000000000..b4393f9eb --- /dev/null +++ b/src/lib/integrations/oauth-kit/disconnect.ts @@ -0,0 +1,144 @@ +/** + * Ending an OAuth connection: tell the provider where it listens, drop the + * grant, audit it, and park the integration at `disconnected`. One + * implementation for the person's own disconnect (`POST + * /api//disconnect`) and a guardian's for a managed profile + * (`POST /api/record-settings/integrations//disconnect`). Imported + * data stays; a reconnect resumes the sync. + * + * The audit row is filed under the record; when a guardian acts, `auditLog` + * reads the guardian off the request and records them as the actor. + */ +import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; +import { auditLog } from "@/lib/auth/audit"; +import { decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; +import { markDisconnected } from "@/lib/integrations/status"; +import { unsubscribeWebhook } from "@/lib/withings/client"; +import { + WITHINGS_NOTIFY_APPLIS, + getWithingsWebhookCallbackUrl, +} from "@/lib/withings/sync"; +import { deauthorize } from "@/lib/strava/client"; +import { + STRAVA_DISCONNECTED, + getStravaConnection, +} from "@/lib/strava/credentials"; +import type { OAuthKitProvider } from "./token"; + +export type DisconnectOutcome = "disconnected" | "not_connected"; + +/** The audit action each provider has always filed a disconnect under. */ +const AUDIT_ACTION: Record = { + withings: "withings.disconnect", + whoop: "whoop.disconnect", + "google-health": "google_health.disconnect", + oura: "oura.disconnect", + polar: "polar.disconnect", + strava: "strava.disconnect", +}; + +async function dropGrant( + provider: OAuthKitProvider, + userId: string, +): Promise { + switch (provider) { + case "withings": { + const connection = await prisma.withingsConnection.findUnique({ + where: { userId }, + }); + if (!connection) return false; + // Withings keeps calling a subscribed URL after the grant is gone, so + // the subscriptions go first. Best effort: a refused unsubscribe must + // not keep the person connected. + try { + const accessToken = decrypt(connection.accessToken); + const callbackUrl = getWithingsWebhookCallbackUrl(); + for (const appli of WITHINGS_NOTIFY_APPLIS) { + try { + await unsubscribeWebhook(accessToken, callbackUrl, appli); + } catch { + // Already gone, or the token expired; the delete below stands. + } + } + } catch { + // An undecryptable token cannot unsubscribe anything. + } + await prisma.withingsConnection.delete({ where: { userId } }); + return true; + } + case "whoop": { + const connection = await prisma.whoopConnection.findUnique({ + where: { userId }, + }); + if (!connection) return false; + await prisma.whoopConnection.delete({ where: { userId } }); + return true; + } + case "google-health": { + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId }, + }); + if (!connection) return false; + await prisma.googleHealthConnection.delete({ where: { userId } }); + return true; + } + case "oura": { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { ouraAccessTokenEncrypted: true }, + }); + if (!row?.ouraAccessTokenEncrypted) return false; + await prisma.user.update({ + where: { id: userId }, + data: { + ouraAccessTokenEncrypted: null, + ouraRefreshTokenEncrypted: null, + ouraScopeDenied: Prisma.DbNull, + }, + }); + return true; + } + case "polar": { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { polarAccessTokenEncrypted: true }, + }); + if (!row?.polarAccessTokenEncrypted) return false; + await prisma.user.update({ + where: { id: userId }, + data: { polarAccessTokenEncrypted: null, polarUserIdEncrypted: null }, + }); + return true; + } + case "strava": { + const connection = await getStravaConnection(userId); + if (!connection) return false; + // Strava keeps the app authorised until it is told otherwise. + try { + await deauthorize(connection.accessToken); + } catch (err) { + getEvent()?.addWarning( + `strava: deauthorize failed on disconnect: ${err}`, + ); + } + await prisma.user.update({ + where: { id: userId }, + data: STRAVA_DISCONNECTED, + }); + return true; + } + } +} + +/** Disconnect `provider` from the record `userId`. */ +export async function disconnectOAuth( + provider: OAuthKitProvider, + userId: string, +): Promise { + if (!(await dropGrant(provider, userId))) return "not_connected"; + await auditLog(AUDIT_ACTION[provider], { userId }); + await markDisconnected(userId, provider); + return "disconnected"; +} From 74ce17b7de27e6e7614b971ccf612a2f1bbd22ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:49:07 +0200 Subject: [PATCH 20/98] feat(integrations): let a guardian connect a managed profile's providers A guardian acting on a managed profile can now connect and disconnect its Withings, WHOOP, Oura, Polar, Strava and Google Health from the profile's integration cards, and each card says whose record it connects for. The connect starts with a fenced fetch that mints a state naming the profile; the provider's callback lands tokens and data there only after checking that the browser belongs to a guardian with a live MANAGE grant on it. Without a provider app of its own, the profile borrows the guardian's, with an audit row. Withings and Google Health callbacks read the session directly, since a provider redirect cannot carry the record-session header. --- docs/api/openapi.yaml | 298 +++++++++++++----- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/nl.json | 11 +- messages/pl.json | 11 +- .../destructive-control-guard.test.ts | 8 + .../record-settings-route-guard.test.ts | 6 +- src/__tests__/record-settings-ui.test.tsx | 15 +- src/__tests__/session-surface-guard.test.ts | 8 + src/__tests__/sharing-surface-guard.test.ts | 9 +- src/app/api/google-health/callback/route.ts | 37 ++- .../api/oura/callback/__tests__/route.test.ts | 14 +- src/app/api/oura/callback/route.ts | 20 +- .../polar/callback/__tests__/route.test.ts | 9 + src/app/api/polar/callback/route.ts | 20 +- .../[provider]/__tests__/route.test.ts | 145 +++++++++ .../integrations/[provider]/connect/route.ts | 97 ++++++ .../[provider]/disconnect/route.ts | 61 ++++ .../api/record-settings/integrations/route.ts | 8 +- .../strava/callback/__tests__/route.test.ts | 9 + src/app/api/strava/callback/route.ts | 20 +- .../whoop/callback/__tests__/route.test.ts | 9 + src/app/api/whoop/callback/route.ts | 17 +- .../withings/callback/__tests__/route.test.ts | 21 +- src/app/api/withings/callback/route.ts | 35 +- .../managed-integration-status.test.tsx | 36 ++- .../settings/managed-integration-status.tsx | 160 +++++++++- .../__tests__/connect-target.test.ts | 148 +++++++++ .../integrations/oauth-kit/connect-target.ts | 119 +++++++ .../oauth-kit/managed-provider.ts | 26 ++ src/lib/openapi/routes/record-settings.ts | 107 ++++++- 35 files changed, 1392 insertions(+), 158 deletions(-) create mode 100644 src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts create mode 100644 src/app/api/record-settings/integrations/[provider]/connect/route.ts create mode 100644 src/app/api/record-settings/integrations/[provider]/disconnect/route.ts create mode 100644 src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts create mode 100644 src/lib/integrations/oauth-kit/connect-target.ts create mode 100644 src/lib/integrations/oauth-kit/managed-provider.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..6ee366015 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -22648,11 +22648,9 @@ paths: tags: - Account sharing summary: Read the managed record's integration connection states - description: "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its - server-resolved health. There is deliberately no control arm and no credential projection anywhere under this - path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow - cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller - guards." + description: Whether each of the record's integrations is connected, when it last succeeded, and its server-resolved + health. No credential projection. Connecting and disconnecting an OAuth provider are the two POST routes below + this path. Cookie session or Bearer, while acting on a managed record the caller guards. responses: "200": description: Connection state per provider. @@ -22664,6 +22662,84 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/record-settings/integrations/{provider}/connect: + post: + tags: + - Account sharing + summary: Start connecting an OAuth provider for the managed record + description: "Mints the OAuth state for the record the session is acting on, sets it as an httpOnly cookie, and answers + with the provider's consent URL; the client then sends the browser there. A fetch rather than a navigation, so + the record-session fence applies. The state names the managed record: the provider's callback lands the tokens + on it after checking the browser still belongs to a Guardian holding a live MANAGE grant on that record. When + the record has no provider app of its own and the deployment has none, the Guardian's own app is copied onto the + record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed + record the caller guards." + parameters: + - &a54 + name: provider + in: path + required: true + schema: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + description: The OAuth provider. An unrecognised value is a 404, answered before authentication. + responses: + "200": + description: The consent URL to send the browser to. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedConnectStartEnvelope" + "401": *a1 + "403": *a11 + "404": + description: The path named a provider outside the closed set. Answered before the caller is authenticated. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: Neither the record, the Guardian, nor the deployment has an OAuth app for this provider. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/record-settings/integrations/{provider}/disconnect: + post: + tags: + - Account sharing + summary: Disconnect an OAuth provider from the managed record + description: "Runs the same disconnect the owner of a record runs: the grant is dropped, the provider told where it can + (Withings webhooks, Strava deauthorisation), and the integration parked at `disconnected`. Imported data stays. + Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed + record the caller guards." + parameters: + - *a54 + responses: + "200": + description: The provider is disconnected. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedDisconnectEnvelope" + "401": *a1 + "403": *a11 + "404": + description: The path named a provider outside the closed set, or the record has no connection to it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/record-settings/{family}: get: tags: @@ -22672,7 +22748,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a54 + - &a55 name: family in: path required: true @@ -22716,7 +22792,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a54 + - *a55 requestBody: required: true content: @@ -23342,13 +23418,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a55 + "403": &a56 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a56 + "404": &a57 description: No stored copy with this id. content: application/json: @@ -23422,8 +23498,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a55 - "404": *a56 + "403": *a56 + "404": *a57 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -23467,7 +23543,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a55 + "403": *a56 /api/admin/backups/restores/{jobId}: get: tags: @@ -23490,7 +23566,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a55 + "403": *a56 "404": description: No restore job with this id. content: @@ -23509,7 +23585,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a57 + security: &a58 - cookieAuth: [] responses: "200": @@ -23519,7 +23595,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a58 + "403": &a59 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -23542,9 +23618,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - &a59 + - &a60 name: system in: path required: true @@ -23583,8 +23659,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a58 - "404": &a60 + "403": *a59 + "404": &a61 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -23603,7 +23679,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a61 + "502": &a62 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -23622,9 +23698,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: Removed, or there was nothing to remove. @@ -23633,7 +23709,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -23644,9 +23720,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The source answered. @@ -23655,9 +23731,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": &a62 + "403": *a59 + "404": *a61 + "422": &a63 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -23666,7 +23742,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/tags: get: tags: @@ -23674,9 +23750,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The tags. @@ -23685,11 +23761,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/search: get: tags: @@ -23703,9 +23779,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 - name: q in: query required: false @@ -23746,11 +23822,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/import: post: tags: @@ -23769,9 +23845,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 requestBody: required: true content: @@ -23829,7 +23905,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -23860,7 +23936,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a62 + "422": *a63 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -23868,7 +23944,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a61 + "502": *a62 /api/symptoms/definitions: get: tags: @@ -23892,7 +23968,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a63 + "403": &a64 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -23935,7 +24011,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "409": *a7 "422": *a4 "429": *a5 @@ -23965,8 +24041,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": &a64 + "403": *a64 + "404": &a65 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -24000,8 +24076,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "422": *a4 "429": *a5 /api/symptoms/events: @@ -24050,7 +24126,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "422": *a4 "429": *a5 post: @@ -24076,8 +24152,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -24107,8 +24183,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": &a65 + "403": *a64 + "404": &a66 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -24135,8 +24211,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a65 + "403": *a64 + "404": *a66 "422": *a4 "429": *a5 /api/day/index: @@ -24287,7 +24363,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a66 + "403": &a67 description: >- `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide. @@ -24324,7 +24400,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a66 + "403": *a67 "422": *a4 "429": *a5 /api/life-events: @@ -24342,7 +24418,7 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a67 + "403": &a68 description: >- Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a managed profile. @@ -24382,7 +24458,7 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a67 + "403": *a68 "409": *a7 "422": *a4 "429": *a12 @@ -24413,8 +24489,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a67 - "404": &a68 + "403": *a68 + "404": &a69 description: No such life event on this account. content: application/json: @@ -24441,8 +24517,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a67 - "404": *a68 + "403": *a68 + "404": *a69 "422": *a4 "429": *a5 /api/search: @@ -83528,10 +83604,90 @@ components: - integrations additionalProperties: false description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. - Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record - selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. - A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a - synthesised healthy ledger is not proof of a connection. + No credential projection. A Guardian connects or disconnects an OAuth provider for the record through `POST + /api/record-settings/integrations/{provider}/connect` and `/disconnect`. A provider with no real credential + reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof + of a connection. + ManagedConnectStartEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedConnectStartResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedConnectStartResponse: + type: object + properties: + recordId: + type: string + provider: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + authorizationUrl: + type: string + description: The provider's consent URL, with the state the record owns. + required: + - recordId + - provider + - authorizationUrl + additionalProperties: false + description: Where to send the browser to connect a provider for the managed record. + ManagedDisconnectEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedDisconnectResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedDisconnectResponse: + type: object + properties: + recordId: + type: string + provider: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + disconnected: + type: boolean + const: true + required: + - recordId + - provider + - disconnected + additionalProperties: false + description: The provider is no longer connected to the managed record. GetRecordSettingsFamilyEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index b8afe311c..5096b7c90 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar", "unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.", "integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.", - "integrationStatusDetail": "Ein betreutes Profil zeigt seine verbundenen Dienste nur als Status. Verbinden, neu verbinden, synchronisieren und trennen ist für Betreuende nicht möglich.", - "integrationStatusDocs": "So funktioniert das Teilen" + "integrationStatusDetail": "Dienste, die du hier verbindest, schicken ihre Daten in das Profil von {name}, nicht in deins. Ohne eigene Anbieter-App nutzt das Profil deine.", + "integrationStatusDocs": "So funktioniert das Teilen", + "integrationConnectsFor": "Verbindet für {name}", + "integrationConnectFor": "Für {name} verbinden", + "integrationDisconnect": "Trennen", + "integrationDisconnectTitle": "{provider} von {name} trennen?", + "integrationDisconnectBody": "Neue Daten kommen dann nicht mehr an. Was schon importiert ist, bleibt im Profil von {name}.", + "integrationNoClientApp": "Weder {name} noch dein eigenes Konto hat bisher eine {provider}-App. Richte zuerst bei deinen eigenen Integrationen eine ein.", + "integrationConnectFailed": "{provider} konnte nicht verbunden werden." }, "sections": { "account": { diff --git a/messages/en.json b/messages/en.json index 8ec8c0e61..37ff91d18 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Unavailable in this shared record", "unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.", "integrationStatusDescription": "Connection status for this managed record.", - "integrationStatusDetail": "A managed profile shows its connected services as a status only. Connecting, reconnecting, syncing and disconnecting are not available to guardians.", - "integrationStatusDocs": "How sharing works" + "integrationStatusDetail": "Services you connect here send their data to {name}'s record, not to yours. Without a provider app of its own, the profile uses yours.", + "integrationStatusDocs": "How sharing works", + "integrationConnectsFor": "Connects for {name}", + "integrationConnectFor": "Connect for {name}", + "integrationDisconnect": "Disconnect", + "integrationDisconnectTitle": "Disconnect {provider} from {name}?", + "integrationDisconnectBody": "New data stops arriving. Everything already imported stays in {name}'s record.", + "integrationNoClientApp": "Neither {name} nor your own account has a {provider} app yet. Set one up under your own integrations first.", + "integrationConnectFailed": "Could not start connecting {provider}." }, "sections": { "account": { diff --git a/messages/es.json b/messages/es.json index 80928dc2d..4b1a9e65b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "No disponible en este registro compartido", "unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.", "integrationStatusDescription": "Estado de conexión de este registro gestionado.", - "integrationStatusDetail": "Un perfil gestionado muestra sus servicios conectados solo como estado. Conectar, volver a conectar, sincronizar y desconectar no está disponible para quienes lo gestionan.", - "integrationStatusDocs": "Cómo funciona compartir" + "integrationStatusDetail": "Los servicios que conectes aquí envían sus datos al registro de {name}, no al tuyo. Si el perfil no tiene una app propia del proveedor, usa la tuya.", + "integrationStatusDocs": "Cómo funciona compartir", + "integrationConnectsFor": "Conecta para {name}", + "integrationConnectFor": "Conectar para {name}", + "integrationDisconnect": "Desconectar", + "integrationDisconnectTitle": "¿Desconectar {provider} de {name}?", + "integrationDisconnectBody": "Dejarán de llegar datos nuevos. Todo lo ya importado se queda en el registro de {name}.", + "integrationNoClientApp": "Ni {name} ni tu propia cuenta tienen todavía una app de {provider}. Configura una primero en tus propias integraciones.", + "integrationConnectFailed": "No se pudo empezar a conectar {provider}." }, "sections": { "account": { diff --git a/messages/fr.json b/messages/fr.json index 4f7058a57..ea046e3a4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Indisponible dans ce dossier partagé", "unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.", "integrationStatusDescription": "État de connexion pour ce dossier géré.", - "integrationStatusDetail": "Un profil géré affiche ses services connectés uniquement sous forme d’état. Les personnes qui le gèrent ne peuvent ni connecter, ni reconnecter, ni synchroniser, ni déconnecter.", - "integrationStatusDocs": "Comment fonctionne le partage" + "integrationStatusDetail": "Les services que vous connectez ici envoient leurs données dans le dossier de {name}, pas dans le vôtre. Sans application propre chez le fournisseur, le profil utilise la vôtre.", + "integrationStatusDocs": "Comment fonctionne le partage", + "integrationConnectsFor": "Connecte pour {name}", + "integrationConnectFor": "Connecter pour {name}", + "integrationDisconnect": "Déconnecter", + "integrationDisconnectTitle": "Déconnecter {provider} de {name} ?", + "integrationDisconnectBody": "Les nouvelles données n’arriveront plus. Tout ce qui a déjà été importé reste dans le dossier de {name}.", + "integrationNoClientApp": "Ni {name} ni votre propre compte n’ont encore d’application {provider}. Configurez-en d’abord une dans vos propres intégrations.", + "integrationConnectFailed": "Impossible de lancer la connexion à {provider}." }, "sections": { "account": { diff --git a/messages/it.json b/messages/it.json index a1dc08274..47b448231 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Non disponibile in questo record condiviso", "unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.", "integrationStatusDescription": "Stato di connessione per questo record gestito.", - "integrationStatusDetail": "Un profilo gestito mostra i servizi collegati solo come stato. Chi lo gestisce non può collegare, ricollegare, sincronizzare o scollegare.", - "integrationStatusDocs": "Come funziona la condivisione" + "integrationStatusDetail": "I servizi che colleghi qui inviano i dati al profilo di {name}, non al tuo. Se il profilo non ha un'app propria del fornitore, usa la tua.", + "integrationStatusDocs": "Come funziona la condivisione", + "integrationConnectsFor": "Collega per {name}", + "integrationConnectFor": "Collega per {name}", + "integrationDisconnect": "Scollega", + "integrationDisconnectTitle": "Scollegare {provider} da {name}?", + "integrationDisconnectBody": "I nuovi dati smettono di arrivare. Quanto già importato resta nel profilo di {name}.", + "integrationNoClientApp": "Né {name} né il tuo account hanno ancora un'app {provider}. Configurane prima una nelle tue integrazioni.", + "integrationConnectFailed": "Impossibile avviare il collegamento a {provider}." }, "sections": { "account": { diff --git a/messages/ko.json b/messages/ko.json index 8712f3f1a..a0515cf73 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "이 공유 기록에서는 사용할 수 없어요", "unavailableDescription": "다른 사람의 기록을 보는 동안에는 이 설정 영역을 사용할 수 없어요.", "integrationStatusDescription": "이 관리 기록의 연결 상태예요.", - "integrationStatusDetail": "관리 프로필은 연결된 서비스를 상태로만 보여 줘요. 보호자는 연결, 다시 연결, 동기화, 연결 해제를 할 수 없어요.", - "integrationStatusDocs": "공유 방식 알아보기" + "integrationStatusDetail": "여기서 연결한 서비스의 데이터는 내 기록이 아니라 {name}의 기록으로 들어가요. 프로필에 자체 앱이 없으면 내 앱을 사용해요.", + "integrationStatusDocs": "공유 방식 알아보기", + "integrationConnectsFor": "{name}을(를) 위해 연결", + "integrationConnectFor": "{name}을(를) 위해 연결하기", + "integrationDisconnect": "연결 해제", + "integrationDisconnectTitle": "{name}에서 {provider} 연결을 해제할까요?", + "integrationDisconnectBody": "새 데이터가 더 이상 들어오지 않아요. 이미 가져온 데이터는 {name}의 기록에 남아요.", + "integrationNoClientApp": "{name}과(와) 내 계정 모두 아직 {provider} 앱이 없어요. 먼저 내 통합 설정에서 앱을 설정해 주세요.", + "integrationConnectFailed": "{provider} 연결을 시작하지 못했어요." }, "sections": { "account": { diff --git a/messages/nl.json b/messages/nl.json index dc0c5e5f0..a5e20c7d3 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Niet beschikbaar in dit gedeelde dossier", "unavailableDescription": "Dit deel van Instellingen is bewust niet beschikbaar terwijl je het dossier van iemand anders bekijkt.", "integrationStatusDescription": "Koppelingsstatus voor dit beheerde dossier.", - "integrationStatusDetail": "Een beheerd profiel toont de gekoppelde diensten alleen als status. Koppelen, opnieuw koppelen, synchroniseren en ontkoppelen zijn niet beschikbaar voor verzorgers.", - "integrationStatusDocs": "Hoe delen werkt" + "integrationStatusDetail": "Diensten die je hier koppelt, sturen hun gegevens naar het dossier van {name}, niet naar het jouwe. Zonder eigen app bij de aanbieder gebruikt het profiel de jouwe.", + "integrationStatusDocs": "Hoe delen werkt", + "integrationConnectsFor": "Koppelt voor {name}", + "integrationConnectFor": "Koppelen voor {name}", + "integrationDisconnect": "Ontkoppelen", + "integrationDisconnectTitle": "{provider} ontkoppelen van {name}?", + "integrationDisconnectBody": "Er komen geen nieuwe gegevens meer binnen. Wat al geïmporteerd is, blijft in het dossier van {name}.", + "integrationNoClientApp": "Noch {name} noch je eigen account heeft al een {provider}-app. Stel er eerst een in bij je eigen integraties.", + "integrationConnectFailed": "Koppelen met {provider} kon niet starten." }, "sections": { "account": { diff --git a/messages/pl.json b/messages/pl.json index 625abe2a4..394f3ccbf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Niedostępne w tym udostępnionym rekordzie", "unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.", "integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.", - "integrationStatusDetail": "Profil zarządzany pokazuje połączone usługi tylko jako stan. Opiekunowie nie mogą łączyć, ponownie łączyć, synchronizować ani rozłączać.", - "integrationStatusDocs": "Jak działa udostępnianie" + "integrationStatusDetail": "Usługi połączone tutaj wysyłają dane do profilu {name}, a nie do Twojego. Jeśli profil nie ma własnej aplikacji u dostawcy, używa Twojej.", + "integrationStatusDocs": "Jak działa udostępnianie", + "integrationConnectsFor": "Łączy dla: {name}", + "integrationConnectFor": "Połącz dla: {name}", + "integrationDisconnect": "Rozłącz", + "integrationDisconnectTitle": "Rozłączyć {provider} od profilu {name}?", + "integrationDisconnectBody": "Nowe dane przestaną napływać. Wszystko, co już zaimportowano, zostaje w profilu {name}.", + "integrationNoClientApp": "Ani {name}, ani Twoje konto nie ma jeszcze aplikacji {provider}. Najpierw skonfiguruj ją we własnych integracjach.", + "integrationConnectFailed": "Nie udało się rozpocząć łączenia z {provider}." }, "sections": { "account": { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 6fe80d772..e183eb18c 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -672,6 +672,14 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["ConfirmDialog"], }, + { + file: "components/settings/managed-integration-status.tsx", + destroys: + "a managed profile's stored Withings, WHOOP, Google Health, Polar, Oura or Strava grant, disconnected by its guardian — reconnecting means authorising again", + triggers: ["components/settings/integrations/integration-card.tsx"], + recovery: "permanent", + confirm: ["ConfirmDialog"], + }, { file: "components/settings/integrations/nightscout-card.tsx", destroys: diff --git a/src/__tests__/record-settings-route-guard.test.ts b/src/__tests__/record-settings-route-guard.test.ts index 6435fadb1..7c93b193b 100644 --- a/src/__tests__/record-settings-route-guard.test.ts +++ b/src/__tests__/record-settings-route-guard.test.ts @@ -20,9 +20,11 @@ describe("record settings route guard", () => { .map((entry) => `app/api/record-settings/${entry}`) .sort(); - expect(discovered).toHaveLength(3); + expect(discovered).toHaveLength(5); expect(discovered).toEqual([ "app/api/record-settings/[family]/route.ts", + "app/api/record-settings/integrations/[provider]/connect/route.ts", + "app/api/record-settings/integrations/[provider]/disconnect/route.ts", "app/api/record-settings/integrations/route.ts", "app/api/record-settings/route.ts", ]); @@ -32,6 +34,8 @@ describe("record settings route guard", () => { const routes = [ "route.ts", "integrations/route.ts", + "integrations/[provider]/connect/route.ts", + "integrations/[provider]/disconnect/route.ts", "[family]/route.ts", ] as const; const sources = await Promise.all( diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index caa025978..780d1f2f6 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -27,7 +27,7 @@ function expectMatchingMessageKeys(expected: unknown, actual: unknown): void { } describe("managed record settings UI", () => { - it("gates shared deep links and renders integration status without controls", async () => { + it("gates shared deep links and controls integrations only through the managed-record routes", async () => { const [ statusView, managedSettingsView, @@ -58,8 +58,17 @@ describe("managed record settings UI", () => { expect(statusView).toContain("recordSettingsIntegrations"); expect(statusView).toContain("/api/record-settings/integrations"); expect(statusView).toContain("assertRecordSettingsResponseForRecord"); - expect(statusView).not.toContain("]/); expect(settingsPage).toContain("RecordSettingsSectionGate"); expect(sectionGate).toContain("if (!inSharedRecord) return children"); expect(sectionGate).toContain("ManagedIntegrationStatus"); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 6fdcd8534..4916b1cee 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -115,6 +115,14 @@ describe("S1 — the direct session-resolution set is frozen", () => { "consent decision on a route outside apiHandler", // Provider OAuth callbacks. The browser arrives as a top-level // navigation, so the code is bound to the session user and redirected. + // v1.44 — Withings and Google Health join: a navigation cannot carry the + // record-session header a record resolver needs, and the session user may + // be a guardian finishing a managed profile's connect, which + // `verifyCallbackActor` checks against a live MANAGE grant. + "app/api/google-health/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/withings/callback/route.ts": + "binds the OAuth code to the session user", "app/api/oura/callback/route.ts": "binds the OAuth code to the session user", "app/api/polar/callback/route.ts": diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 5f851c312..d6ec5452e 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2044,6 +2044,10 @@ const GUARDIAN_ROUTES: Record = { "Verified integration connection status for the managed record, without any provider control surface.", "app/api/record-settings/[family]/route.ts": "The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.", + "app/api/record-settings/integrations/[provider]/connect/route.ts": + "Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.", + "app/api/record-settings/integrations/[provider]/disconnect/route.ts": + "Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.", }; /** @@ -2164,8 +2168,11 @@ const ACTOR_ROUTES: Record = { * v1.43 -- AUDIT-C's create and soft delete join the record list and the * manage literal under `profile`, the level allergies are written at. * 262 -> 266. + * + * v1.44 -- a guardian connects and disconnects a managed profile's OAuth + * providers: two guardian-only entries. 266 -> 268. */ -const FROZEN_ENTRY_COUNT = 266; +const FROZEN_ENTRY_COUNT = 268; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 9f87a2fcf..71d6cf67b 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; +import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -57,7 +59,15 @@ const ERR = (reason: string) => ); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // The person in the browser, read off the session itself rather than + // through a record resolver: a provider redirect is a navigation, it cannot + // carry the record-session header, and the record is the one the state row + // names, checked against this person below. A session is still required. + const sessionData = await getSession(); + if (!sessionData) { + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + } + const actor = sessionData.user; annotate({ action: { name: "google_health.callback" } }); const { searchParams } = new URL(request.url); @@ -125,8 +135,12 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ meta: { reason: "expired" } }); return ERR("expired"); } - if (consumed.userId !== user.id) { - annotate({ meta: { reason: "cross_user" } }); + // The record the connect started for; a guardian may finish it for a + // managed profile it holds a live MANAGE grant on. + const recordUserId = consumed.userId; + const actorCheck = await verifyCallbackActor(recordUserId, actor.id); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } if (!consumed.codeVerifier) { @@ -139,7 +153,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } try { - const creds = await getUserGoogleHealthCredentials(user.id); + const creds = await getUserGoogleHealthCredentials(recordUserId); if (!creds) { return ERR("nocreds"); } @@ -154,7 +168,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // so the user re-consents rather than landing a dead connection. if (!tokens.refresh_token) { getEvent()?.addWarning( - `google-health callback: token response carried no refresh_token for user ${user.id}`, + `google-health callback: token response carried no refresh_token for user ${recordUserId}`, ); annotate({ meta: { reason: "no_refresh_token" } }); return ERR("no_refresh_token"); @@ -166,7 +180,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const scope = tokens.scope ?? getGoogleHealthScopeString(); await prisma.googleHealthConnection.upsert({ - where: { userId: user.id }, + where: { userId: recordUserId }, update: { googleUserId, accessToken: encrypt(tokens.access_token), @@ -182,7 +196,7 @@ export const GET = apiHandler(async (request: NextRequest) => { backfillProgress: Prisma.DbNull, }, create: { - userId: user.id, + userId: recordUserId, googleUserId, accessToken: encrypt(tokens.access_token), refreshToken: encrypt(tokens.refresh_token), @@ -193,14 +207,15 @@ export const GET = apiHandler(async (request: NextRequest) => { }); await auditLog("google_health.connect", { - userId: user.id, + userId: recordUserId, + actorUserId: actorCheck.guardianUserId, details: { googleUserId }, }); // Re-completing OAuth clears any prior reauth-required state on the status // ledger too (the `needsReauth` column above is the connection-local flag; // this resets the integration-status streak). - await markReconnected(user.id, "google-health"); + await markReconnected(recordUserId, "google-health"); // Enqueue the self-converging history backfill. Best-effort: the boot-time // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a @@ -209,7 +224,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (boss) { await boss .send(GOOGLE_HEALTH_BACKFILL_QUEUE, { - userId: user.id, + userId: recordUserId, enqueuedAt: new Date().toISOString(), }) .catch((err) => diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 2806299e4..23e5b7b5c 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -24,6 +24,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); @@ -166,7 +175,10 @@ describe("GET /api/oura/callback", () => { const data = userUpdate.mock.calls[0]![0].data; expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); - expect(auditLog).toHaveBeenCalledWith("oura.connect", { userId: "u1" }); + expect(auditLog).toHaveBeenCalledWith("oura.connect", { + userId: "u1", + actorUserId: null, + }); expect(res.headers.get("location")).toContain("oura=connected"); }); }); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 6e7b7ed20..21d3c60d6 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -80,11 +81,18 @@ export const GET = apiHandler(async (request: NextRequest) => { const evt = getEvent(); if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); + // The browser finishing the flow must be the record's own, or, for a + // managed profile, a guardian's with a live MANAGE grant on it. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) return ERR("nocode"); @@ -104,12 +112,13 @@ export const GET = apiHandler(async (request: NextRequest) => { fallbackUrl: SETTINGS_URL(), stateCookie: oauthStateCookieName("oura"), }, - () => completeCallback(userId, code, iss), + () => completeCallback(userId, guardianUserId, code, iss), ); }); async function completeCallback( userId: string, + guardianUserId: string | null, code: string, iss: string | null, ): Promise { @@ -137,7 +146,10 @@ async function completeCallback( }, }); - await auditLog("oura.connect", { userId }); + await auditLog("oura.connect", { + userId, + actorUserId: guardianUserId, + }); await markReconnected(userId, "oura"); // Start the first sync now instead of leaving the user staring at an empty diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index 9addbc134..21d925ad3 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -46,6 +46,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index cca2ff691..f67765b43 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -70,11 +71,18 @@ export const GET = apiHandler(async (request: NextRequest) => { if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); // Optional session cross-check when a web session is present. + // The browser finishing the flow must be the record's own, or, for a + // managed profile, a guardian's with a live MANAGE grant on it. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) return ERR("nocode"); @@ -90,12 +98,13 @@ export const GET = apiHandler(async (request: NextRequest) => { ).toString(), stateCookie: oauthStateCookieName("polar"), }, - () => completeCallback(userId, code), + () => completeCallback(userId, guardianUserId, code), ); }); async function completeCallback( userId: string, + guardianUserId: string | null, code: string, ): Promise { try { @@ -126,7 +135,10 @@ async function completeCallback( }, }); - await auditLog("polar.connect", { userId }); + await auditLog("polar.connect", { + userId, + actorUserId: guardianUserId, + }); await markReconnected(userId, "polar"); // Start the first sync now instead of leaving the user staring at an empty diff --git a/src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts b/src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts new file mode 100644 index 000000000..91a2176ae --- /dev/null +++ b/src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +const m = vi.hoisted(() => ({ + requireGuardianAuth: vi.fn(), + access: vi.fn(), + share: vi.fn(async () => undefined), + hasApp: vi.fn(async () => true), + start: vi.fn(), + disconnect: vi.fn(), + rate: vi.fn(async () => ({ allowed: true })), + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireGuardianAuth: m.requireGuardianAuth, + SharingAccessDeniedError: class SharingAccessDeniedError extends Error {}, +})); +vi.mock("@/lib/record-settings", () => ({ + resolveGuardianRecordSettingsAccess: m.access, +})); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + shareGuardianClientApp: m.share, +})); +vi.mock("@/lib/integrations/oauth-kit/connect-start", async (orig) => ({ + ...(await orig< + typeof import("@/lib/integrations/oauth-kit/connect-start") + >()), + recordHasClientApp: m.hasApp, + startOAuthConnect: m.start, +})); +vi.mock("@/lib/integrations/oauth-kit/disconnect", () => ({ + disconnectOAuth: m.disconnect, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: m.rate, + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: m.audit })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); + +import { POST as connect } from "../connect/route"; +import { POST as disconnect } from "../disconnect/route"; + +const params = (provider: string) => ({ + params: Promise.resolve({ provider }), +}); +const req = {} as NextRequest; +const GUARDED = { + actorId: "guardian", + recordId: "profile", + recordKind: "managed", + relationship: "guardian", +}; + +beforeEach(() => { + vi.clearAllMocks(); + m.requireGuardianAuth.mockResolvedValue({}); + m.access.mockReturnValue(GUARDED); + m.rate.mockResolvedValue({ allowed: true }); +}); + +describe("POST /api/record-settings/integrations/[provider]/connect", () => { + it("refuses a provider outside the set before authenticating", async () => { + const res = await connect(req, params("fitbit")); + expect(res.status).toBe(404); + expect(m.requireGuardianAuth).not.toHaveBeenCalled(); + }); + + it("refuses a caller who is not a guardian of a managed record", async () => { + m.access.mockReturnValue(null); + await expect(connect(req, params("oura"))).rejects.toThrow(); + expect(m.start).not.toHaveBeenCalled(); + }); + + it("mints the state for the managed record and answers with the consent URL", async () => { + m.start.mockResolvedValue({ + ok: true, + authorizationUrl: "https://cloud.ouraring.com/oauth/authorize?state=s", + cookie: { name: "oura_oauth_state", value: "s", maxAgeSeconds: 600 }, + }); + const res = await connect(req, params("oura")); + expect(m.share).toHaveBeenCalledWith( + "oura", + { recordUserId: "profile", actorUserId: "guardian" }, + expect.any(Function), + ); + expect(m.start).toHaveBeenCalledWith("oura", "profile"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + recordId: "profile", + provider: "oura", + authorizationUrl: "https://cloud.ouraring.com/oauth/authorize?state=s", + }); + expect(res.headers.get("set-cookie")).toContain("oura_oauth_state=s"); + expect(res.headers.get("set-cookie")?.toLowerCase()).toContain("httponly"); + expect(m.audit).toHaveBeenCalledWith("integration.connect.started", { + userId: "profile", + details: { provider: "oura" }, + }); + }); + + it("answers 409 when no provider app can be found", async () => { + m.start.mockResolvedValue({ ok: false, reason: "nocreds" }); + const res = await connect(req, params("strava")); + expect(res.status).toBe(409); + }); + + it("is rate limited per record", async () => { + m.rate.mockResolvedValue({ allowed: false }); + const res = await connect(req, params("withings")); + expect(res.status).toBe(429); + expect(m.rate).toHaveBeenCalledWith( + "withings:connect:profile", + expect.any(Number), + expect.any(Number), + ); + }); +}); + +describe("POST /api/record-settings/integrations/[provider]/disconnect", () => { + it("disconnects the managed record", async () => { + m.disconnect.mockResolvedValue("disconnected"); + const res = await disconnect(req, params("google-health")); + expect(m.disconnect).toHaveBeenCalledWith("google-health", "profile"); + expect(res.status).toBe(200); + }); + + it("answers 404 when the record is not connected", async () => { + m.disconnect.mockResolvedValue("not_connected"); + const res = await disconnect(req, params("whoop")); + expect(res.status).toBe(404); + }); + + it("refuses a provider outside the set", async () => { + const res = await disconnect(req, params("nightscout")); + expect(res.status).toBe(404); + expect(m.disconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/record-settings/integrations/[provider]/connect/route.ts b/src/app/api/record-settings/integrations/[provider]/connect/route.ts new file mode 100644 index 000000000..e251643f7 --- /dev/null +++ b/src/app/api/record-settings/integrations/[provider]/connect/route.ts @@ -0,0 +1,97 @@ +import type { NextRequest } from "next/server"; + +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { + recordHasClientApp, + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { shareGuardianClientApp } from "@/lib/integrations/oauth-kit/connect-target"; +import { isManagedOAuthProvider } from "@/lib/integrations/oauth-kit/managed-provider"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ provider: string }> }; + +const CONNECT_RATE_LIMIT = 10; +const CONNECT_WINDOW_MS = 60_000; + +/** + * A guardian starts connecting a provider for the managed profile they are + * acting on. + * + * A fetch, not a navigation: the record-session fence needs the headers the + * app transport sends, which a browser navigation cannot carry. So this + * answers with the consent URL and sets the state cookie, and the page then + * sends the browser to the provider. The state names the profile; the + * provider's callback lands the tokens there after checking the browser is + * still a guardian of it (`verifyCallbackActor`). + * + * Without a provider app of its own, the profile borrows the guardian's. + */ +export const POST = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { provider } = await params; + if (!isManagedOAuthProvider(provider)) { + return apiError("Integration not found", 404); + } + + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + annotate({ + action: { name: "record-settings.integration.connect.start" }, + meta: { provider }, + }); + + const rl = await checkRateLimit( + `${provider}:connect:${access.recordId}`, + CONNECT_RATE_LIMIT, + CONNECT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + await shareGuardianClientApp( + provider, + { recordUserId: access.recordId, actorUserId: access.actorId }, + () => recordHasClientApp(provider, access.recordId), + ); + + const started = await startOAuthConnect(provider, access.recordId); + if (!started.ok) { + return started.reason === "nocreds" + ? apiError( + "Neither this profile nor your own account has an app for this provider.", + 409, + ) + : apiError("The connect could not be started.", 500); + } + + await auditLog("integration.connect.started", { + userId: access.recordId, + details: { provider }, + }); + + return setConnectStateCookie( + apiSuccess({ + recordId: access.recordId, + provider, + authorizationUrl: started.authorizationUrl, + }), + started.cookie, + ); + }, +); diff --git a/src/app/api/record-settings/integrations/[provider]/disconnect/route.ts b/src/app/api/record-settings/integrations/[provider]/disconnect/route.ts new file mode 100644 index 000000000..eb12ed277 --- /dev/null +++ b/src/app/api/record-settings/integrations/[provider]/disconnect/route.ts @@ -0,0 +1,61 @@ +import type { NextRequest } from "next/server"; + +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; +import { isManagedOAuthProvider } from "@/lib/integrations/oauth-kit/managed-provider"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ provider: string }> }; + +/** + * A guardian disconnects a provider from the managed profile they are acting + * on, through the same disconnect a person runs for their own account + * (`disconnectOAuth`). The profile's imported data stays. + */ +export const POST = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { provider } = await params; + if (!isManagedOAuthProvider(provider)) { + return apiError("Integration not found", 404); + } + + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + annotate({ + action: { name: "record-settings.integration.disconnect" }, + meta: { provider }, + }); + + const rl = await checkRateLimit( + `${provider}-disconnect:${access.recordId}`, + 20, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + if ( + (await disconnectOAuth(provider, access.recordId)) === "not_connected" + ) { + return apiError("No connection", 404); + } + return apiSuccess({ + recordId: access.recordId, + provider, + disconnected: true, + }); + }, +); diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts index 027e26b17..e87ebba78 100644 --- a/src/app/api/record-settings/integrations/route.ts +++ b/src/app/api/record-settings/integrations/route.ts @@ -30,9 +30,11 @@ const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [ ]; /** - * A managed profile may disclose whether its existing integrations are - * connected, but this endpoint deliberately has no control or credential - * projection. OAuth and provider state cannot safely carry a record selector. + * Whether a managed profile's integrations are connected and how each is + * doing. No credential projection. A guardian connects and disconnects the + * profile's OAuth providers through `./[provider]/connect` and + * `./[provider]/disconnect`, whose state names the profile itself rather than + * carrying a record selector. */ export const GET = apiHandler(async () => { const context = await requireGuardianAuth(); diff --git a/src/app/api/strava/callback/__tests__/route.test.ts b/src/app/api/strava/callback/__tests__/route.test.ts index c967bce51..2594d1adb 100644 --- a/src/app/api/strava/callback/__tests__/route.test.ts +++ b/src/app/api/strava/callback/__tests__/route.test.ts @@ -49,6 +49,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index 1fc7ddc2b..b6d184849 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -69,11 +70,18 @@ export const GET = apiHandler(async (request: NextRequest) => { const evt = getEvent(); if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); + // The browser finishing the flow must be the record's own, or, for a + // managed profile, a guardian's with a live MANAGE grant on it. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) return ERR("nocode"); @@ -89,12 +97,13 @@ export const GET = apiHandler(async (request: NextRequest) => { ).toString(), stateCookie: oauthStateCookieName("strava"), }, - () => completeCallback(userId, code), + () => completeCallback(userId, guardianUserId, code), ); }); async function completeCallback( userId: string, + guardianUserId: string | null, code: string, ): Promise { try { @@ -131,7 +140,10 @@ async function completeCallback( }, }); - await auditLog("strava.connect", { userId }); + await auditLog("strava.connect", { + userId, + actorUserId: guardianUserId, + }); await markReconnected(userId, "strava"); // Enqueue the self-converging history backfill at connect time so deep diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index 2f8d632fb..c7750844f 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -11,6 +11,15 @@ vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 63d190764..89bc61364 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -180,11 +181,18 @@ export const GET = apiHandler(async (request: NextRequest) => { // Optional session cross-check: only when a web session cookie is present. // The native ticket path carries no session — the nonce cookie + atomic // single-use delete already bind the row to its originator. + // A guardian finishing a managed profile's connect needs a live MANAGE + // grant on it; a managed profile has no session of its own. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return outcomeRedirect(returnScheme, "error", "cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) { return outcomeRedirect(returnScheme, "error", "nocode"); @@ -237,7 +245,10 @@ export const GET = apiHandler(async (request: NextRequest) => { throw err; } - await auditLog("whoop.connect", { userId }); + await auditLog("whoop.connect", { + userId, + actorUserId: guardianUserId, + }); // Re-completing OAuth clears any prior reauth-required state. await markReconnected(userId, "whoop"); diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts index 12a5da528..89ff7a13e 100644 --- a/src/app/api/withings/callback/__tests__/route.test.ts +++ b/src/app/api/withings/callback/__tests__/route.test.ts @@ -31,12 +31,18 @@ vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ - user: { id: "user-1" }, - session: { id: "sess-1" }, - })), + AUTH_ERROR_CODES: { missing: "auth.missing" }, HttpError: class HttpError extends Error { constructor( public status: number, @@ -47,6 +53,13 @@ vi.mock("@/lib/api-handler", () => ({ }, })); +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(async () => ({ + user: { id: "user-1" }, + session: { id: "sess-1" }, + })), +})); + vi.mock("@/lib/db", () => { const prisma = { withingsOAuthState: { diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index f1c02e33c..a151b2250 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; +import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -52,7 +54,15 @@ import { timingSafeEqual } from "node:crypto"; * lands on `replay`. */ export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // The person in the browser, read off the session itself rather than + // through a record resolver: a provider redirect is a navigation, it cannot + // carry the record-session header, and the record is the one the state row + // names, checked against this person below. A session is still required. + const sessionData = await getSession(); + if (!sessionData) { + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + } + const actor = sessionData.user; annotate({ action: { name: "withings.callback" } }); const { searchParams } = new URL(request.url); @@ -161,8 +171,12 @@ export const GET = apiHandler(async (request: NextRequest) => { ), ); } - if (consumed.userId !== user.id) { - annotate({ meta: { reason: "cross_user" } }); + // The record the connect started for; a guardian may finish it for a + // managed profile it holds a live MANAGE grant on. + const recordUserId = consumed.userId; + const actorCheck = await verifyCallbackActor(recordUserId, actor.id); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return NextResponse.redirect( new URL( "/settings/integrations?withings=error&reason=cross_user", @@ -181,7 +195,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } try { - const creds = await getUserWithingsCredentials(user.id); + const creds = await getUserWithingsCredentials(recordUserId); if (!creds) { return NextResponse.redirect( new URL( @@ -204,7 +218,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // entirely. Storing the requested set is therefore a safe // representation of what the connection holds. await prisma.withingsConnection.upsert({ - where: { userId: user.id }, + where: { userId: recordUserId }, update: { withingsUserId: tokens.userid, accessToken: encrypt(tokens.access_token), @@ -221,7 +235,7 @@ export const GET = apiHandler(async (request: NextRequest) => { sleepStageReads: Prisma.DbNull, }, create: { - userId: user.id, + userId: recordUserId, withingsUserId: tokens.userid, accessToken: encrypt(tokens.access_token), refreshToken: encrypt(tokens.refresh_token), @@ -233,18 +247,19 @@ export const GET = apiHandler(async (request: NextRequest) => { }); // Reconcile every category from a clean state after connect/reconnect. - setupWebhook(user.id).catch(() => + setupWebhook(recordUserId).catch(() => getEvent()?.addWarning("Webhook setup failed"), ); await auditLog("withings.connect", { - userId: user.id, + userId: recordUserId, + actorUserId: actorCheck.guardianUserId, details: { withingsUserId: tokens.userid }, }); // Re-completing OAuth clears any prior `error_reauth` state. We // don't write a fresh success-time — that's the next sync's job. - await markReconnected(user.id, "withings"); + await markReconnected(recordUserId, "withings"); const response = NextResponse.redirect( new URL( diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx index 8cc091ea6..f782d0d55 100644 --- a/src/components/settings/__tests__/managed-integration-status.test.tsx +++ b/src/components/settings/__tests__/managed-integration-status.test.tsx @@ -1,5 +1,6 @@ /** - * A managed record's connections on the shared integration card, read-only. + * A managed record's connections on the shared integration card: a guardian + * connects and disconnects the record's OAuth providers, the rest is status. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -32,6 +33,8 @@ vi.mock("@tanstack/react-query", () => ({ isError: false, refetch: vi.fn(), }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); import { I18nProvider } from "@/lib/i18n/context"; @@ -77,7 +80,7 @@ describe("", () => { loading = false; }); - it("renders one shared card per provider from health, with no control on any", () => { + it("renders one shared card per provider from health, with controls on the OAuth ones", () => { payload = withHealth({ oura: healthFixture({ state: "action_required", @@ -100,20 +103,37 @@ describe("", () => { expect(html).toMatch( /data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/, ); - // Read-only even where the state asks for an action. - expect(html).not.toContain('data-slot="integration-primary-action"'); - expect(html).not.toContain('data-slot="integration-menu-trigger"'); + // The reconnect a broken grant asks for, on the card that asks for it. + expect(html).toContain('data-testid="oura-reconnect-button"'); + // A connected OAuth card carries the disconnect menu, and every OAuth card + // says whose record it connects for. + expect(html).toContain('data-testid="withings-menu"'); + expect(html.match(/Connects for Managed record/g)).toHaveLength(6); + // Each unconnected OAuth provider offers one connect for the record; + // Fitbit (retired) and Nightscout (no OAuth) stay status only. + for (const key of ["whoop", "polar", "google-health", "strava"]) { + expect(html).toContain( + `data-testid="managed-integration-${key}-connect"`, + ); + } + expect(html).not.toContain( + 'data-testid="managed-integration-fitbit-connect"', + ); + expect(html).not.toContain( + 'data-testid="managed-integration-nightscout-connect"', + ); + expect(html.match(/>Connect for Managed recordNot connected\. { + it("says once where connected data goes, and links the explanation", () => { payload = withHealth({}); const html = render(); - expect(html).toContain('data-slot="managed-integration-read-only"'); + expect(html).toContain('data-slot="managed-integration-target"'); expect(html).toContain( - "A managed profile shows its connected services as a status only.", + "Services you connect here send their data to Managed record's record, not to yours.", ); expect(html).toContain( 'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"', diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index 22f5de00e..4ce08a958 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -1,17 +1,32 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { Droplet, ExternalLink, Plug } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Droplet, ExternalLink, Link2, Plug, Unplug } from "lucide-react"; -import { IntegrationCard } from "@/components/settings/integrations/integration-card"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { + IntegrationCard, + type IntegrationMenuAction, +} from "@/components/settings/integrations/integration-card"; +import { Button } from "@/components/ui/button"; import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SectionHeading } from "@/components/ui/section-heading"; -import { apiGet } from "@/lib/api/api-fetch"; +import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; -import { queryKeys } from "@/lib/query-keys"; +import { + isManagedOAuthProvider, + type ManagedOAuthProvider, +} from "@/lib/integrations/oauth-kit/managed-provider"; +import { loadDocument } from "@/lib/navigation/load-document"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response"; type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES; @@ -49,15 +64,18 @@ function iconFor(key: ManagedIntegrationKey) { * The managed record's connections, on the same card every integration in * Settings renders, from the same server-resolved `health`. * - * Read-only by construction: a guardian cannot connect, reconnect, resume, - * sync, test or disconnect a managed record's provider, because an OAuth flow - * cannot safely carry a record selector. So the cards carry no menu and no - * button, and one sentence above them says why, with the page that explains - * the boundary. + * A guardian connects and disconnects the record's OAuth providers here. The + * connect starts with a fetch (`POST /api/record-settings/integrations/ + * /connect`, which carries the record-session headers a navigation + * cannot) and then sends the browser to the provider; tokens and data land on + * the managed record. Every card says whose record it connects for. Syncing, + * testing and provider credentials stay out: the record syncs on its own + * schedule once connected. */ export function ManagedIntegrationStatus() { const { t } = useTranslations(); const { user } = useAuth(); + const queryClient = useQueryClient(); const activeRecord = user?.accountAccess?.active ?? null; const recordId = activeRecord?.accountId ?? null; const statusQuery = useQuery({ @@ -72,6 +90,57 @@ export function ManagedIntegrationStatus() { enabled: activeRecord?.recordKind === "managed" && recordId !== null, }); + const recordName = activeRecord?.displayName ?? ""; + + const invalidateStatus = () => + queryClient.invalidateQueries({ + queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""), + }); + + const connect = useMutation({ + mutationFn: async (provider: ManagedOAuthProvider) => + apiPost<{ authorizationUrl: string }>( + `/api/record-settings/integrations/${provider}/connect`, + ), + onSuccess: (started) => loadDocument(started.authorizationUrl), + onError: (failure, provider) => + showErrorToast( + failure instanceof ApiError && failure.status === 409 + ? t("settings.sharedRecord.integrationNoClientApp", { + name: recordName, + provider: INTEGRATION_DISPLAY_NAMES[provider], + }) + : t("settings.sharedRecord.integrationConnectFailed", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + { cause: failure }, + ), + }); + + const disconnect = useMutation({ + mutationFn: async (provider: ManagedOAuthProvider) => + apiPost(`/api/record-settings/integrations/${provider}/disconnect`), + onSuccess: (_result, provider) => { + void invalidateStatus(); + // A record-settings write; the capability answer reads the record's + // connections, so it is evicted with the rest of the AI inputs. + void invalidateKeys(queryClient, aiInputDependentKeys); + toastWrittenOutcome( + "success", + t("settings.integrationDisconnected", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + ); + }, + onError: (failure, provider) => + showErrorToast( + t("settings.integrationDisconnectFailed", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + { cause: failure }, + ), + }); + if (statusQuery.isError) { return ( ({ integration, health: null }), ); + /** What a guardian can do with one OAuth provider of the record. */ + function managedControls(provider: ManagedOAuthProvider) { + const providerName = INTEGRATION_DISPLAY_NAMES[provider]; + const pending = + (connect.isPending && connect.variables === provider) || + (disconnect.isPending && disconnect.variables === provider); + const forRecord = ( +

+ {t("settings.sharedRecord.integrationConnectsFor", { + name: recordName, + })} +

+ ); + const menu: IntegrationMenuAction[] = [ + { + id: "disconnect", + label: t("settings.sharedRecord.integrationDisconnect"), + icon: Unplug, + destructive: true, + disabled: pending, + onSelect: () => disconnect.mutate(provider), + confirm: { + title: t("settings.sharedRecord.integrationDisconnectTitle", { + provider: providerName, + name: recordName, + }), + body: t("settings.sharedRecord.integrationDisconnectBody", { + name: recordName, + }), + confirmLabel: t("settings.sharedRecord.integrationDisconnect"), + }, + }, + ]; + return { + children: forRecord, + menu, + primary: { + onReconnect: () => connect.mutate(provider), + pending, + }, + connectAction: ( + + ), + }; + } + return (
-

- {t("settings.sharedRecord.integrationStatusDetail")}{" "} +

+ {t("settings.sharedRecord.integrationStatusDetail", { + name: recordName, + })}{" "} ))} diff --git a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts new file mode 100644 index 000000000..19ec6a345 --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findUnique, update, findActiveGrant, auditLog } = vi.hoisted(() => ({ + findUnique: vi.fn(), + update: vi.fn(async () => ({})), + findActiveGrant: vi.fn(), + auditLog: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique, update } } })); +vi.mock("@/lib/auth/audit", () => ({ auditLog })); +vi.mock("@/lib/sharing/grants", () => ({ + findActiveGrant, + grantAllows: ( + grant: { access: string; revokedAt: Date | null }, + need: string, + ) => + grant.revokedAt === null && + (need !== "manage" || grant.access === "MANAGE"), +})); + +import { shareGuardianClientApp, verifyCallbackActor } from "../connect-target"; + +const MANAGED = { managedProfileAt: new Date("2026-01-01T00:00:00Z") }; +const ADULT = { managedProfileAt: null }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("verifyCallbackActor", () => { + it("admits the record's own session without a lookup", async () => { + await expect(verifyCallbackActor("rec", "rec")).resolves.toEqual({ + ok: true, + guardianUserId: null, + }); + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("keeps the session-less own-account path for an ordinary account", async () => { + findUnique.mockResolvedValue(ADULT); + await expect(verifyCallbackActor("rec", null)).resolves.toEqual({ + ok: true, + guardianUserId: null, + }); + }); + + it("refuses another person's session on an ordinary account, grant or not", async () => { + findUnique.mockResolvedValue(ADULT); + findActiveGrant.mockResolvedValue({ access: "MANAGE", revokedAt: null }); + await expect(verifyCallbackActor("rec", "other")).resolves.toEqual({ + ok: false, + reason: "cross_user", + }); + expect(findActiveGrant).not.toHaveBeenCalled(); + }); + + it("admits a guardian with a live MANAGE grant on a managed profile", async () => { + findUnique.mockResolvedValue(MANAGED); + findActiveGrant.mockResolvedValue({ access: "MANAGE", revokedAt: null }); + await expect(verifyCallbackActor("rec", "guardian")).resolves.toEqual({ + ok: true, + guardianUserId: "guardian", + }); + expect(findActiveGrant).toHaveBeenCalledWith({ + grantorId: "rec", + granteeId: "guardian", + }); + }); + + it("refuses a read or write grant, and a grant revoked on the consent screen", async () => { + findUnique.mockResolvedValue(MANAGED); + findActiveGrant.mockResolvedValueOnce({ access: "WRITE", revokedAt: null }); + await expect(verifyCallbackActor("rec", "g")).resolves.toMatchObject({ + ok: false, + }); + findActiveGrant.mockResolvedValueOnce(null); + await expect(verifyCallbackActor("rec", "g")).resolves.toMatchObject({ + ok: false, + }); + }); + + it("requires a guardian's session for a managed profile", async () => { + findUnique.mockResolvedValue(MANAGED); + await expect(verifyCallbackActor("rec", null)).resolves.toEqual({ + ok: false, + reason: "guardian_session_required", + }); + }); + + it("refuses a record that does not exist", async () => { + findUnique.mockResolvedValue(null); + await expect(verifyCallbackActor("gone", null)).resolves.toMatchObject({ + ok: false, + }); + }); +}); + +describe("shareGuardianClientApp", () => { + const target = { recordUserId: "rec", actorUserId: "guardian" }; + + it("does nothing on a person's own connect", async () => { + const has = vi.fn(async () => false); + await shareGuardianClientApp( + "oura", + { recordUserId: "a", actorUserId: "a" }, + has, + ); + expect(has).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("does nothing when the profile already resolves an app", async () => { + await shareGuardianClientApp("oura", target, async () => true); + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("copies the guardian's sealed app onto the profile and audits it", async () => { + findUnique.mockResolvedValue({ + ouraClientIdEncrypted: "sealed-id", + ouraClientSecretEncrypted: "sealed-secret", + }); + await shareGuardianClientApp("oura", target, async () => false); + expect(update).toHaveBeenCalledWith({ + where: { id: "rec" }, + data: { + ouraClientIdEncrypted: "sealed-id", + ouraClientSecretEncrypted: "sealed-secret", + }, + }); + expect(auditLog).toHaveBeenCalledWith("integration.client_app.shared", { + userId: "rec", + actorUserId: "guardian", + details: { provider: "oura" }, + }); + }); + + it("copies nothing when the guardian has no app either", async () => { + findUnique.mockResolvedValue({ + stravaClientIdEncrypted: null, + stravaClientSecretEncrypted: null, + }); + await shareGuardianClientApp("strava", target, async () => false); + expect(update).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/integrations/oauth-kit/connect-target.ts b/src/lib/integrations/oauth-kit/connect-target.ts new file mode 100644 index 000000000..98dafb33b --- /dev/null +++ b/src/lib/integrations/oauth-kit/connect-target.ts @@ -0,0 +1,119 @@ +/** + * Whose record an OAuth connect lands on, and who is allowed to finish it. + * + * A guardian connects a provider for a managed profile they manage: the + * connect route resolves through `requireGuardianAuth`, so its `user` is the + * profile and its `actor` the guardian, and the state it mints names the + * profile. Tokens and data land on the profile. The state never carries a + * record selector the browser could change: it is signed, or a server-side + * ledger row, and it names exactly one record. + * + * The callback then has to answer one question the connect could not: is the + * browser completing this flow allowed to attach a provider account to that + * record? For a person's own record the answer is unchanged (their session, or + * no session at all on the flows that allow one). For a managed profile, which + * has no login of its own, the browser must hold a session of a guardian with + * a live MANAGE grant on the profile, checked again here because ten minutes + * can pass on a consent screen and a grant can be revoked in them. + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { findActiveGrant, grantAllows } from "@/lib/sharing/grants"; +import type { OAuthKitProvider } from "./token"; + +export type CallbackActorVerdict = + | { + readonly ok: true; + /** The guardian who finished it, or null for the record's own browser. */ + readonly guardianUserId: string | null; + } + | { + readonly ok: false; + readonly reason: "cross_user" | "guardian_session_required"; + }; + +/** + * May the session `sessionUserId` (null: no session) finish a connect whose + * state names `recordUserId`? On success, which guardian acted, if one did. + */ +export async function verifyCallbackActor( + recordUserId: string, + sessionUserId: string | null, +): Promise { + if (sessionUserId === recordUserId) { + return { ok: true, guardianUserId: null }; + } + const record = await prisma.user.findUnique({ + where: { id: recordUserId }, + select: { managedProfileAt: true }, + }); + if (!record) return { ok: false, reason: "cross_user" }; + if (record.managedProfileAt === null) { + // An ordinary account: its own browser without a session may finish (the + // native flows), anybody else's session may not. + return sessionUserId === null + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }; + } + if (sessionUserId === null) { + return { ok: false, reason: "guardian_session_required" }; + } + const grant = await findActiveGrant({ + grantorId: recordUserId, + granteeId: sessionUserId, + }); + if (!grant || !grantAllows(grant, "manage")) { + return { ok: false, reason: "cross_user" }; + } + return { ok: true, guardianUserId: sessionUserId }; +} + +/** The per-user columns holding a provider's own OAuth app. */ +const CLIENT_COLUMNS = { + withings: ["withingsClientIdEncrypted", "withingsClientSecretEncrypted"], + whoop: ["whoopClientIdEncrypted", "whoopClientSecretEncrypted"], + oura: ["ouraClientIdEncrypted", "ouraClientSecretEncrypted"], + strava: ["stravaClientIdEncrypted", "stravaClientSecretEncrypted"], + "google-health": [ + "googleHealthClientIdEncrypted", + "googleHealthClientSecretEncrypted", + ], + polar: ["polarClientIdEncrypted", "polarClientSecretEncrypted"], +} as const satisfies Record; + +/** + * Let a managed profile connect with its guardian's own provider app. + * + * Every provider here is bring-your-own-app: the client id and secret sit on + * the account that connects, and the background refresh reads them from that + * account. A managed profile never had a settings page to paste them into, so + * when it has no app of its own and the deployment has no shared one, the + * guardian's app is copied onto the profile, still sealed, with an audit row + * on the profile naming the guardian. Does nothing for a person's own connect, and nothing when + * the profile can already resolve an app. + */ +export async function shareGuardianClientApp( + provider: OAuthKitProvider, + target: { recordUserId: string; actorUserId: string }, + recordHasApp: () => Promise, +): Promise { + if (target.recordUserId === target.actorUserId) return; + if (await recordHasApp()) return; + const [idColumn, secretColumn] = CLIENT_COLUMNS[provider]; + const guardian = (await prisma.user.findUnique({ + where: { id: target.actorUserId }, + select: { [idColumn]: true, [secretColumn]: true }, + })) as Record | null; + const clientId = guardian?.[idColumn] ?? null; + const clientSecret = guardian?.[secretColumn] ?? null; + if (!clientId || !clientSecret) return; + await prisma.user.update({ + where: { id: target.recordUserId }, + data: { [idColumn]: clientId, [secretColumn]: clientSecret }, + }); + await auditLog("integration.client_app.shared", { + userId: target.recordUserId, + actorUserId: target.actorUserId, + details: { provider }, + }); +} diff --git a/src/lib/integrations/oauth-kit/managed-provider.ts b/src/lib/integrations/oauth-kit/managed-provider.ts new file mode 100644 index 000000000..8235cdcc9 --- /dev/null +++ b/src/lib/integrations/oauth-kit/managed-provider.ts @@ -0,0 +1,26 @@ +/** + * The OAuth providers a guardian may connect for a managed profile, and the + * route parameter that names one. + * + * Every kit provider qualifies. Fitbit does not: the classic Fitbit Web API is + * retired on 2026-10-30 and takes no new connections. Nightscout and Dawarich + * are not OAuth flows. + */ +import type { OAuthKitProvider } from "./token"; + +export const MANAGED_OAUTH_PROVIDERS = [ + "withings", + "whoop", + "oura", + "polar", + "strava", + "google-health", +] as const satisfies readonly OAuthKitProvider[]; + +export type ManagedOAuthProvider = (typeof MANAGED_OAUTH_PROVIDERS)[number]; + +export function isManagedOAuthProvider( + value: string, +): value is ManagedOAuthProvider { + return (MANAGED_OAUTH_PROVIDERS as readonly string[]).includes(value); +} diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index d332d79e3..35fcc9c2f 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -26,6 +26,7 @@ import { z } from "zod/v4"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings"; import { connectionHealth } from "./integrations"; +import { MANAGED_OAUTH_PROVIDERS } from "@/lib/integrations/oauth-kit/managed-provider"; import { dataEnvelope, errorEnvelope, @@ -112,7 +113,7 @@ const recordSettingsIntegrationsResponse = z .meta({ id: "RecordSettingsIntegrationsResponse", description: - "Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", + "Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. No credential projection. A Guardian connects or disconnects an OAuth provider for the record through `POST /api/record-settings/integrations/{provider}/connect` and `/disconnect`. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", }); // ── The six family DTOs ────────────────────────────────────────────── @@ -334,6 +335,45 @@ const familyParameter = { "Which configuration family to read or write. An unrecognised value is a 404 — and note that the family check runs BEFORE authentication, so an unknown family answers 404 to an unauthenticated caller where a known one would answer 401.", }; +const managedProviderEnum = z.enum(MANAGED_OAUTH_PROVIDERS); + +const managedProviderParameter = { + name: "provider", + in: "path" as const, + required: true, + schema: { + type: "string" as const, + enum: [...MANAGED_OAUTH_PROVIDERS], + }, + description: + "The OAuth provider. An unrecognised value is a 404, answered before authentication.", +}; + +const managedConnectStartResponse = z + .object({ + recordId: z.string(), + provider: managedProviderEnum, + authorizationUrl: z + .string() + .describe("The provider's consent URL, with the state the record owns."), + }) + .meta({ + id: "ManagedConnectStartResponse", + description: + "Where to send the browser to connect a provider for the managed record.", + }); + +const managedDisconnectResponse = z + .object({ + recordId: z.string(), + provider: managedProviderEnum, + disconnected: z.literal(true), + }) + .meta({ + id: "ManagedDisconnectResponse", + description: "The provider is no longer connected to the managed record.", + }); + export const recordSettingsPaths: NonNullable = { "/api/record-settings": { get: { @@ -363,7 +403,7 @@ export const recordSettingsPaths: NonNullable = { tags: ["Account sharing"], summary: "Read the managed record's integration connection states", description: - "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.", + "Whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. No credential projection. Connecting and disconnecting an OAuth provider are the two POST routes below this path. Cookie session or Bearer, while acting on a managed record the caller guards.", responses: { ...recordRefusal(), "200": { @@ -381,6 +421,69 @@ export const recordSettingsPaths: NonNullable = { }, }, }, + "/api/record-settings/integrations/{provider}/connect": { + post: { + tags: ["Account sharing"], + summary: "Start connecting an OAuth provider for the managed record", + description: + "Mints the OAuth state for the record the session is acting on, sets it as an httpOnly cookie, and answers with the provider's consent URL; the client then sends the browser there. A fetch rather than a navigation, so the record-session fence applies. The state names the managed record: the provider's callback lands the tokens on it after checking the browser still belongs to a Guardian holding a live MANAGE grant on that record. When the record has no provider app of its own and the deployment has none, the Guardian's own app is copied onto the record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards.", + parameters: [managedProviderParameter], + responses: { + ...recordRefusal(), + "200": { + description: "The consent URL to send the browser to.", + content: { + "application/json": { + schema: dataEnvelope( + managedConnectStartResponse, + "ManagedConnectStartEnvelope", + ), + }, + }, + }, + "404": { + description: + "The path named a provider outside the closed set. Answered before the caller is authenticated.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "Neither the record, the Guardian, nor the deployment has an OAuth app for this provider.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/record-settings/integrations/{provider}/disconnect": { + post: { + tags: ["Account sharing"], + summary: "Disconnect an OAuth provider from the managed record", + description: + "Runs the same disconnect the owner of a record runs: the grant is dropped, the provider told where it can (Withings webhooks, Strava deauthorisation), and the integration parked at `disconnected`. Imported data stays. Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards.", + parameters: [managedProviderParameter], + responses: { + ...recordRefusal(), + "200": { + description: "The provider is disconnected.", + content: { + "application/json": { + schema: dataEnvelope( + managedDisconnectResponse, + "ManagedDisconnectEnvelope", + ), + }, + }, + }, + "404": { + description: + "The path named a provider outside the closed set, or the record has no connection to it.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/record-settings/{family}": { get: { tags: ["Account sharing"], From ba2d7af40e9165ef9ac3834446b348cf4515bd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:51:38 +0200 Subject: [PATCH 21/98] test(integration): connect Oura for a managed profile against real Postgres --- .../managed-profile-oauth-connect.test.ts | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 tests/integration/managed-profile-oauth-connect.test.ts diff --git a/tests/integration/managed-profile-oauth-connect.test.ts b/tests/integration/managed-profile-oauth-connect.test.ts new file mode 100644 index 000000000..beca90d08 --- /dev/null +++ b/tests/integration/managed-profile-oauth-connect.test.ts @@ -0,0 +1,288 @@ +/** + * A guardian connects an OAuth provider for a managed profile, against real + * Postgres: the connect route mints a state naming the profile, the + * provider's callback lands the tokens on the profile, and every refusal the + * flow promises holds (an adult's record, a revoked grant, a READ grant, no + * guardian session). Oura stands for the signed-state flows; the ledger + * flows share `verifyCallbackActor`, which is exercised directly too. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +process.env.API_TOKEN_HMAC_KEY ??= + "integration-hmac-key-0123456789abcdef0123456789"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OURA_TOKEN_URL = "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; +const OURA_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; +const GUARDIAN = "guardian-oauth"; +const PROFILE = "profile-oauth"; +const ADULT = "adult-oauth"; + +function urlOf(input: unknown): string { + return typeof input === "string" ? input : (input as Request).url; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function grant( + grantorId: string, + access: "READ" | "WRITE" | "MANAGE" = "MANAGE", +) { + return getPrismaClient().accountGrant.create({ + data: { grantorId, granteeId: GUARDIAN, access, acceptedAt: new Date() }, + }); +} + +async function startConnect(): Promise { + const { POST } = + await import("@/app/api/record-settings/integrations/[provider]/connect/route"); + return POST( + new NextRequest( + "http://localhost/api/record-settings/integrations/oura/connect", + { method: "POST" }, + ), + { params: Promise.resolve({ provider: "oura" }) }, + ); +} + +async function ouraCallback(state: string) { + const { GET } = await import("@/app/api/oura/callback/route"); + const params = new URLSearchParams({ + code: "oura-code", + state, + iss: OURA_ISS, + }); + return GET( + new NextRequest(`http://localhost/api/oura/callback?${params}`, { + method: "GET", + headers: { cookie: `oura_oauth_state=${state}` }, + }), + ); +} + +function stateOf(authorizationUrl: string): string { + const state = new URL(authorizationUrl).searchParams.get("state"); + if (!state) throw new Error("no state in the consent URL"); + return state; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + delete process.env.OURA_CLIENT_ID; + delete process.env.OURA_CLIENT_SECRET; + delete process.env.OURA_REDIRECT_URI; + + const { encrypt } = await import("@/lib/crypto"); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: GUARDIAN, + username: "guardian-oauth", + email: "guardian-oauth@example.test", + role: "USER", + // The guardian's own Oura app; the profile has none. + ouraClientIdEncrypted: encrypt("guardian-oura-client"), + ouraClientSecretEncrypted: encrypt("guardian-oura-secret"), + }, + }); + await prisma.user.create({ + data: { + id: PROFILE, + username: "profile-oauth", + email: "profile-oauth@example.test", + role: "USER", + managedProfileAt: new Date(), + }, + }); + await prisma.user.create({ + data: { + id: ADULT, + username: "adult-oauth", + email: "adult-oauth@example.test", + role: "USER", + }, + }); + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (urlOf(input) !== OURA_TOKEN_URL) { + throw new Error(`Unexpected fetch: ${urlOf(input)}`); + } + return new Response( + JSON.stringify({ + access_token: "profile-access", + refresh_token: "profile-refresh", + expires_in: 86400, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("a guardian connects Oura for a managed profile", () => { + it("lands the tokens on the profile, never on the guardian, and audits both steps", async () => { + await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + + const started = await startConnect(); + expect(started.status).toBe(200); + const body = (await started.json()) as { + data: { recordId: string; authorizationUrl: string }; + }; + expect(body.data.recordId).toBe(PROFILE); + + const { verifySignedState } = await import("@/lib/oauth/signed-state"); + const state = stateOf(body.data.authorizationUrl); + expect(verifySignedState("oura", state)?.userId).toBe(PROFILE); + + const res = await ouraCallback(state); + expect(res.headers.get("location")).toContain("oura=connected"); + + const { decrypt } = await import("@/lib/crypto"); + const prisma = getPrismaClient(); + const profile = await prisma.user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { + ouraAccessTokenEncrypted: true, + ouraRefreshTokenEncrypted: true, + ouraClientIdEncrypted: true, + }, + }); + expect(decrypt(profile.ouraAccessTokenEncrypted!)).toBe("profile-access"); + expect(decrypt(profile.ouraRefreshTokenEncrypted!)).toBe("profile-refresh"); + // The guardian's app was borrowed, sealed, for the refresh to use later. + expect(decrypt(profile.ouraClientIdEncrypted!)).toBe( + "guardian-oura-client", + ); + const guardian = await prisma.user.findUniqueOrThrow({ + where: { id: GUARDIAN }, + select: { ouraAccessTokenEncrypted: true }, + }); + expect(guardian.ouraAccessTokenEncrypted).toBeNull(); + + const audits = await prisma.auditLog.findMany({ + where: { userId: PROFILE }, + select: { action: true, actorUserId: true }, + orderBy: { createdAt: "asc" }, + }); + expect(audits).toEqual( + expect.arrayContaining([ + { action: "integration.client_app.shared", actorUserId: GUARDIAN }, + { action: "integration.connect.started", actorUserId: GUARDIAN }, + { action: "oura.connect", actorUserId: GUARDIAN }, + ]), + ); + }); + + it("refuses the callback once the grant was revoked on the consent screen", async () => { + const live = await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + const started = await startConnect(); + const { data } = (await started.json()) as { + data: { authorizationUrl: string }; + }; + + await getPrismaClient().accountGrant.update({ + where: { id: live.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + + const res = await ouraCallback(stateOf(data.authorizationUrl)); + expect(res.headers.get("location")).toContain("reason=cross_user"); + const profile = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { ouraAccessTokenEncrypted: true }, + }); + expect(profile.ouraAccessTokenEncrypted).toBeNull(); + }); + + it("does not start a connect on an adult's record, even under MANAGE", async () => { + await grant(ADULT); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, ADULT); + const res = await startConnect(); + expect(res.status).toBe(403); + }); +}); + +describe("verifyCallbackActor against real grants", () => { + it("admits only a live MANAGE grant on a managed profile", async () => { + const { verifyCallbackActor } = + await import("@/lib/integrations/oauth-kit/connect-target"); + await expect(verifyCallbackActor(PROFILE, GUARDIAN)).resolves.toEqual({ + ok: false, + reason: "cross_user", + }); + + const read = await grant(PROFILE, "READ"); + await expect(verifyCallbackActor(PROFILE, GUARDIAN)).resolves.toMatchObject( + { ok: false }, + ); + await getPrismaClient().accountGrant.delete({ where: { id: read.id } }); + + await grant(PROFILE, "MANAGE"); + await expect(verifyCallbackActor(PROFILE, GUARDIAN)).resolves.toEqual({ + ok: true, + guardianUserId: GUARDIAN, + }); + // A managed profile has no session of its own to finish a connect with. + await expect(verifyCallbackActor(PROFILE, null)).resolves.toEqual({ + ok: false, + reason: "guardian_session_required", + }); + + await grant(ADULT, "MANAGE"); + await expect(verifyCallbackActor(ADULT, GUARDIAN)).resolves.toEqual({ + ok: false, + reason: "cross_user", + }); + await expect(verifyCallbackActor(ADULT, null)).resolves.toEqual({ + ok: true, + guardianUserId: null, + }); + }); +}); From dead4096c9a6c6287bc7b3bf465a1d4e86e20bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:52:05 +0200 Subject: [PATCH 22/98] test(google-health): pin that the backfill tracks every collection the sync reads --- .../__tests__/backfill-coverage.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/lib/google-health/__tests__/backfill-coverage.test.ts diff --git a/src/lib/google-health/__tests__/backfill-coverage.test.ts b/src/lib/google-health/__tests__/backfill-coverage.test.ts new file mode 100644 index 000000000..344afcfc2 --- /dev/null +++ b/src/lib/google-health/__tests__/backfill-coverage.test.ts @@ -0,0 +1,40 @@ +/** + * Every Google Health collection the sync reads is one the backfill tracks. + * + * The backfill walks `GOOGLE_HEALTH_COLLECTION_KEYS` and stamps its marker + * only when each is done or has ended with a reason. A collection the sync + * reads but the key list leaves out would never be backfilled, and the card + * would still say the history finished. Read off the sync modules' own call + * labels so a new collection cannot join the sync without joining this list. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { GOOGLE_HEALTH_COLLECTION_KEYS } from "../collection-keys"; + +/** Not time series: one profile read each, outside any history walk. */ +const NOT_A_HISTORY = new Set(["fetchProfile", "fetchHeight"]); +/** The sleep walk is tracked under its own key. */ +const ALIASES: Record = { fetchSleep: "sleep" }; + +describe("Google Health backfill coverage", () => { + it("tracks every collection the sync reads", () => { + const dir = join(process.cwd(), "src/lib/google-health"); + const labels = new Set(); + for (const file of readdirSync(dir)) { + if (!/^sync-.*\.ts$/.test(file)) continue; + const src = readFileSync(join(dir, file), "utf8"); + for (const m of src.matchAll(/"(fetch[A-Z][A-Za-z0-9]+)"/g)) { + labels.add(m[1]!); + } + } + expect(labels.size).toBeGreaterThan(10); + const keys = new Set(GOOGLE_HEALTH_COLLECTION_KEYS); + const untracked = [...labels] + .filter((l) => !NOT_A_HISTORY.has(l)) + .map((l) => ALIASES[l] ?? l) + .filter((l) => !keys.has(l)); + expect(untracked).toEqual([]); + }); +}); From bce9ad1c25345124de56d8b760e2e0bbbb59a705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:53:57 +0200 Subject: [PATCH 23/98] feat(oura): backfill the full history in resumable slices Every Oura collection, workouts and heart rate included, is now read back to the start of the account's history by a backfill that runs through the shared integration lane in five-minute slices. Each collection walks from the day the backfill began toward the past one window at a time (ninety days, thirty for sleep, seven for heart rate) and the progress note on the user row records after every window where it got to, so a slice cut short by its budget, the lane or a rate limit resumes there. A 429 ends the slice and the next one waits for the Retry-After Oura named. Activity and sleep are walked first to learn where history starts; the rest stop a week before it. Heart rate streams a page at a time and keeps only the days it touched, so memory stays flat however long the history is. A collection the grant does not cover is a scope note, not a failure; the first slice of the next chain asks again, and a connect clears the completion stamp so a newly granted scope is read without walking anything twice. The two columns arrive empty, so every existing connection is offered the backfill at the next boot without reconnecting. Before anything else the backfill retires the WALKING_RUNNING_DISTANCE rows earlier syncs wrote from the equivalent walking distance and refolds their days; an account that kept such rows after disconnecting is offered too. --- .../0424_oura_backfill_progress/migration.sql | 13 + prisma/schema.prisma | 20 +- ...ackfill-preserves-updated-at-guard.test.ts | 2 + .../measurement-write-tail-guard.test.ts | 2 + .../api/oura/callback/__tests__/route.test.ts | 9 +- src/app/api/oura/callback/route.ts | 24 + src/lib/data-wipe/wipe-plan.ts | 2 + src/lib/export/backup-plan.ts | 4 + .../integration-backfill-admission.test.ts | 6 +- .../__tests__/queue-policy-tables.test.ts | 1 + .../jobs/integration-backfill-admission.ts | 1 + src/lib/jobs/integration-backfill-drain.ts | 15 + src/lib/jobs/oura-backfill.ts | 516 ++++++++++++++++++ src/lib/jobs/queue-runtime.ts | 1 + .../reminder/register-integration-sync.ts | 53 ++ src/lib/jobs/subsystem-surface.ts | 1 + .../oura/__tests__/backfill-progress.test.ts | 83 +++ src/lib/oura/backfill-progress.ts | 141 +++++ src/lib/oura/retire-distance.ts | 72 +++ src/lib/oura/sync.ts | 4 +- tests/integration/oura-backfill.test.ts | 502 +++++++++++++++++ 21 files changed, 1462 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/0424_oura_backfill_progress/migration.sql create mode 100644 src/lib/jobs/oura-backfill.ts create mode 100644 src/lib/oura/__tests__/backfill-progress.test.ts create mode 100644 src/lib/oura/backfill-progress.ts create mode 100644 src/lib/oura/retire-distance.ts create mode 100644 tests/integration/oura-backfill.test.ts diff --git a/prisma/migrations/0424_oura_backfill_progress/migration.sql b/prisma/migrations/0424_oura_backfill_progress/migration.sql new file mode 100644 index 000000000..388a4d940 --- /dev/null +++ b/prisma/migrations/0424_oura_backfill_progress/migration.sql @@ -0,0 +1,13 @@ +-- Where the Oura history backfill has got, and when it finished. +-- +-- The backfill reads every Oura collection's history in slices through the +-- shared integration backfill lane. After each window it writes, the progress +-- note records where the collection's next window ends, so a slice that stops +-- on its budget or a rate limit resumes there instead of from the newest day. +-- The completion stamp drops the account from boot discovery. +-- +-- Both nullable and unset for every existing row: every current Oura +-- connection is offered the backfill at the next boot, with no reconnect. + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_backfill_progress" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_backfill_completed_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d79d97b1..4aa42a5d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -277,22 +277,32 @@ model User { // v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level). // Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored // encrypted. - ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted") - ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted") + ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted") + ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted") // v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app // level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a // self-hoster pastes their own Oura Cloud app's client id/secret into Settings // before the OAuth connect flow runs. Stored encrypted as a string via // `src/lib/crypto.ts`, matching every other `*Encrypted` column. - ouraClientIdEncrypted String? @map("oura_client_id_encrypted") - ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted") + ouraClientIdEncrypted String? @map("oura_client_id_encrypted") + ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted") /// v1.42.3 — Oura collections the current grant does not cover, as a JSON /// string array of collection names (`spo2`, `vo2max`, `cardio_age`, /// `resilience`). Written by the sync when a collection answers 401/403 while /// its siblings succeed with the same token, which is a scope the grant never /// carried, not a dead token. The Oura card reads it to ask for one /// reconnect. Cleared on connect, disconnect and credential removal. - ouraScopeDenied Json? @map("oura_scope_denied") + ouraScopeDenied Json? @map("oura_scope_denied") + /// v1.44 — how far the Oura history backfill has got: per collection, the + /// day its next window ends on, and whether it is done or refused for a + /// scope; plus where the account's history starts once learned. Written + /// after every window so a slice resumes where the last one stopped (see + /// `src/lib/oura/backfill-progress.ts`); kept after completion so a + /// reconnect reads only what the old grant refused. + ouraBackfillProgress Json? @map("oura_backfill_progress") + /// v1.44 — the Oura history backfill finished (self-converging discovery + /// predicate `oura_backfill_completed_at IS NULL`). Cleared on connect. + ouraBackfillCompletedAt DateTime? @map("oura_backfill_completed_at") // v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app // level). Strava caps every newly-created API app at single-player mode diff --git a/src/__tests__/backfill-preserves-updated-at-guard.test.ts b/src/__tests__/backfill-preserves-updated-at-guard.test.ts index 5f3238eb7..39fe550ab 100644 --- a/src/__tests__/backfill-preserves-updated-at-guard.test.ts +++ b/src/__tests__/backfill-preserves-updated-at-guard.test.ts @@ -106,6 +106,8 @@ const BACKFILL_STAMPS: Record = { "marks the connection's one-shot as done; not a listed record", "src/lib/jobs/strava-backfill.ts > user": "stamps the account's backfill-completed marker", + "src/lib/jobs/oura-backfill.ts > user": + "writes the account's backfill progress note and completed marker", "src/lib/jobs/document-content-index-backfill.ts > documentContentIndex": "the index row is rebuilt content; its updatedAt is the rebuild time", "src/lib/jobs/lab-biomarker-backfill.ts > biomarker": diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index 3a72f236a..06f012349 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -53,6 +53,8 @@ const WRITE_SITES: Record = { "lib/whoop/webhook-handler.ts": TAIL, // Retiring the rows of Oura sleep periods the person rejected or deleted. "lib/oura/sync.ts": TAIL, + // Retiring the equivalent-distance rows earlier Oura syncs wrote. + "lib/oura/retire-distance.ts": TAIL, // Integration syncs: each collects the (type, day) pairs it touched and // runs both legs itself at the end of the sync. "lib/fitbit/sync-core.ts": diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 2806299e4..2763e6577 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -206,12 +206,17 @@ describe("GET /api/oura/callback — issuer", () => { * card for up to an hour. */ describe("GET /api/oura/callback — connect-time first sync", () => { - it("enqueues exactly one targeted oura-sync job for the connecting user", async () => { + it("enqueues one targeted oura-sync job and the history backfill for the connecting user", async () => { const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" })); expect(res.headers.get("location")).toContain("oura=connected"); - expect(bossSend).toHaveBeenCalledTimes(1); + expect(bossSend).toHaveBeenCalledTimes(2); expect(bossSend).toHaveBeenCalledWith("oura-sync", { userId: "u1" }); + expect(bossSend).toHaveBeenCalledWith( + "oura-backfill", + expect.objectContaining({ userId: "u1" }), + expect.objectContaining({ singletonKey: "oura-backfill|u1" }), + ); }); it("still connects when no boss instance is available", async () => { diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 6e7b7ed20..7edc6172b 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -17,6 +17,11 @@ import { ouraCollectionsMissingFromGrant } from "@/lib/oura/scope"; import { markReconnected } from "@/lib/integrations/status"; import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; +import { + OURA_BACKFILL_QUEUE, + type OuraBackfillPayload, +} from "@/lib/jobs/oura-backfill"; import { NextRequest, NextResponse } from "next/server"; /** @@ -134,6 +139,10 @@ async function completeCallback( ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token), ouraScopeDenied: missing && missing.length > 0 ? missing : Prisma.DbNull, + // Open the history backfill again: a first connect has none yet, + // and a reconnect may grant what the old grant refused. What was + // already walked stays in the progress note and is not read again. + ouraBackfillCompletedAt: null, }, }); @@ -153,6 +162,21 @@ async function completeCallback( .catch((err) => getEvent()?.addWarning(`oura-sync connect enqueue failed: ${err}`), ); + const backfill: OuraBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + await boss + .send( + OURA_BACKFILL_QUEUE, + backfill, + integrationBackfillSourceOptions(`oura-backfill|${userId}`), + ) + .catch((err) => + getEvent()?.addWarning( + `oura-backfill connect enqueue failed: ${err}`, + ), + ); } const response = NextResponse.redirect( diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index f02b0d0e7..e38c3dc79 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -463,6 +463,8 @@ export const USER_RESET = { ouraClientIdEncrypted: null, ouraClientSecretEncrypted: null, ouraScopeDenied: Prisma.DbNull, + ouraBackfillProgress: Prisma.DbNull, + ouraBackfillCompletedAt: null, stravaClientIdEncrypted: null, stravaClientSecretEncrypted: null, stravaAccessTokenEncrypted: null, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index b3c9ddef3..8b17b24ee 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -959,6 +959,10 @@ export const USER_COLUMN_BACKUP_CLASS = { stravaLastActivityAt: "OPERATIONAL", stravaBackfillCompletedAt: "OPERATIONAL", stravaBackfillResumeBefore: "OPERATIONAL", + // The restored account's Oura backfill reads its history again from the + // provider; a carried note would claim history this host never imported. + ouraBackfillProgress: "OPERATIONAL", + ouraBackfillCompletedAt: "OPERATIONAL", healthKitLastSyncedAt: "OPERATIONAL", healthKitLastSyncTrigger: "OPERATIONAL", healthKitLastBackgroundSyncAt: "OPERATIONAL", diff --git a/src/lib/jobs/__tests__/integration-backfill-admission.test.ts b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts index 71eededb5..a26bd6561 100644 --- a/src/lib/jobs/__tests__/integration-backfill-admission.test.ts +++ b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts @@ -28,6 +28,7 @@ const providerSources = [ "lab-biomarker-backfill.ts", "strava-backfill.ts", "fitbit-sleep-repair.ts", + "oura-backfill.ts", ].map((file) => readFileSync(join(__dirname, "..", file), "utf8")); describe("shared full-history backfill admission", () => { @@ -41,9 +42,10 @@ describe("shared full-history backfill admission", () => { "lab-biomarker-backfill", "strava-backfill", "fitbit-sleep-repair", + "oura-backfill", ]); expect(INTEGRATION_BACKFILL_BOOT_ORDER.map(bootStaggerSecondsFor)).toEqual([ - 30, 60, 90, 120, 150, 180, 210, 240, + 30, 60, 90, 120, 150, 180, 210, 240, 270, ]); expect(INTEGRATION_BACKFILL_STAGGER_SECONDS).toBe(30); @@ -61,6 +63,7 @@ describe("shared full-history backfill admission", () => { "lab-biomarker-backfill": "enqueueBootTimeLabBiomarkerBackfill", "strava-backfill": "enqueueBootTimeStravaBackfill", "fitbit-sleep-repair": "enqueueBootTimeFitbitSleepRepair", + "oura-backfill": "enqueueBootTimeOuraBackfill", } as const; for (const kind of INTEGRATION_BACKFILL_BOOT_ORDER) { expect(registrarSource).toMatch( @@ -153,6 +156,7 @@ describe("shared full-history backfill admission", () => { "LAB_BIOMARKER_BACKFILL_QUEUE", "STRAVA_BACKFILL_QUEUE", "FITBIT_SLEEP_REPAIR_QUEUE", + "OURA_BACKFILL_QUEUE", ]) { expect(registrarSource).toMatch( new RegExp( diff --git a/src/lib/jobs/__tests__/queue-policy-tables.test.ts b/src/lib/jobs/__tests__/queue-policy-tables.test.ts index 128c73dad..bc4b5ea04 100644 --- a/src/lib/jobs/__tests__/queue-policy-tables.test.ts +++ b/src/lib/jobs/__tests__/queue-policy-tables.test.ts @@ -90,6 +90,7 @@ describe("integration-sync registrar queue policies", () => { ["FITBIT_BACKFILL_QUEUE", "exclusive"], ["GOOGLE_HEALTH_BACKFILL_QUEUE", "exclusive"], ["STRAVA_BACKFILL_QUEUE", "exclusive"], + ["OURA_BACKFILL_QUEUE", "exclusive"], ["GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE", "exclusive"], ["FITBIT_SLEEP_REPAIR_QUEUE", "exclusive"], ["SLEEP_TIMELINE_BACKFILL_QUEUE", "exclusive"], diff --git a/src/lib/jobs/integration-backfill-admission.ts b/src/lib/jobs/integration-backfill-admission.ts index 382a65374..a2f1b05a6 100644 --- a/src/lib/jobs/integration-backfill-admission.ts +++ b/src/lib/jobs/integration-backfill-admission.ts @@ -19,6 +19,7 @@ export const INTEGRATION_BACKFILL_BOOT_ORDER = [ // Appended, not slotted next to `fitbit-backfill`, so every existing // provider's boot stagger stays exactly where it was. "fitbit-sleep-repair", + "oura-backfill", ] as const; export type IntegrationBackfillKind = diff --git a/src/lib/jobs/integration-backfill-drain.ts b/src/lib/jobs/integration-backfill-drain.ts index 15ed48fdf..dbce7a548 100644 --- a/src/lib/jobs/integration-backfill-drain.ts +++ b/src/lib/jobs/integration-backfill-drain.ts @@ -22,6 +22,7 @@ import { runGoogleHealthBackfillForUser } from "@/lib/jobs/google-health-backfil import { runGoogleHealthSleepRepairForUser } from "@/lib/jobs/google-health-sleep-repair"; import { runFitbitSleepRepairForUser } from "@/lib/jobs/fitbit-sleep-repair"; import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; +import { runOuraBackfillForUser } from "@/lib/jobs/oura-backfill"; import { runSleepTimelineBackfillForUser } from "@/lib/jobs/sleep-timeline-backfill"; import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill"; import { @@ -144,6 +145,20 @@ async function runAdmitted( ); return; } + case "oura-backfill": { + const { imported, continued } = await runOuraBackfillForUser( + userId, + shouldStop, + data.slice ?? 0, + ); + totals.imported += imported; + if (continued) totals.continued += 1; + workerLog( + "info", + `[oura-backfill] user=${userId} imported=${imported}${continued ? " continued" : ""}`, + ); + return; + } } } diff --git a/src/lib/jobs/oura-backfill.ts b/src/lib/jobs/oura-backfill.ts new file mode 100644 index 000000000..6fad28bef --- /dev/null +++ b/src/lib/jobs/oura-backfill.ts @@ -0,0 +1,516 @@ +/** + * v1.44 — the Oura history backfill: every collection's history, read once, + * in resumable slices through the shared integration backfill lane. + * + * The hourly sync reads a window of days (hours for heart rate); everything + * older is this job's. Discovery finds every Oura connection whose + * `ouraBackfillCompletedAt` is unset (an existing connection included: the + * column arrives empty, so no reconnect is needed), and the slices walk each + * collection from the day the backfill began back to the start of the + * account's history, window by window, noting after every window where they + * got to (`../oura/backfill-progress.ts`). A slice ends on its own budget, on + * the job's, when another heavy job wants the lane, or on Oura's rate limit, + * and the next slice picks up at the window it stopped before. A collection + * is never read again from the anchor once it has moved. + * + * Memory: a window of a day-keyed collection is at most ninety days of + * records; heart rate is streamed a page at a time (`syncOuraHeartRateRange`) + * and keeps only the days it touched. Nothing accumulates across windows. + * + * The first thing a backfill does, once per account, is retire the + * equivalent-distance rows earlier syncs wrote (`retireOuraEquivalentDistance`). + * An account that holds such rows but no longer has an Oura connection is + * offered too: it retires them and is stamped complete, and a later connect + * reopens the backfill (the callback clears the stamp). + * + * The marker is stamped only when every collection is done or refused for a + * scope the grant lacks; the progress note stays beside it, so a reconnect + * that grants the missing scope reads only those collections: the first slice + * of every chain asks again for the refused ones. + */ +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + enqueueIntegrationBackfillAdmission, + INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + integrationBackfillSourceOptions, +} from "@/lib/jobs/integration-backfill-admission"; +import { LANE_YIELD_DELAY_SECONDS, laneWanted } from "@/lib/jobs/heavy-lane"; +import { reportJobProgress } from "@/lib/jobs/job-observer"; +import { isReauthRequired } from "@/lib/integrations/status"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { sweepStaleSleepSegments } from "@/lib/sleep/sweep-stale-segments"; +import { getOuraConnection } from "@/lib/oura/credentials"; +import { refreshOuraAfterRefusal } from "@/lib/oura/oauth-descriptor"; +import { OuraApiError } from "@/lib/oura/response-classifier"; +import { OURA_COLLECTIONS, type OuraCollectionName } from "@/lib/oura/scope"; +import { + isNamedScopeRefusal, + isTokenRefusal, + ouraCollectionReader, + ouraRunContext, + recordOuraSyncFailure, + retireOuraSleepRecords, + upsertOuraMeasurements, +} from "@/lib/oura/sync"; +import { refoldOuraTouchedDays } from "@/lib/oura/sync-heart-rate"; +import { + OURA_EQUIVALENT_DISTANCE_WHERE, + retireOuraEquivalentDistance, +} from "@/lib/oura/retire-distance"; +import { + OURA_HISTORY_FLOOR, + ouraBackfillProgressJson, + readOuraBackfillProgress, + withoutDenied, + type OuraBackfillProgress, +} from "@/lib/oura/backfill-progress"; + +export const OURA_BACKFILL_QUEUE = "oura-backfill"; + +/** One account at a time; the shared lane bounds the instance. */ +export const OURA_BACKFILL_CONCURRENCY = 1; + +/** + * One slice: five minutes of walking, then the account is let go and the + * rest is the next slice's. Short so a slice never sits long on the lane. + */ +export const OURA_BACKFILL_SLICE_MS = 5 * 60 * 1000; + +/** The pause before the next slice after a rate limit that named no wait. */ +export const OURA_BACKFILL_THROTTLED_DELAY_SECONDS = 5 * 60; + +/** + * The highest slice a chain may reach. Eleven years of history is about 600 + * heart-rate windows and a few hundred more for the rest, and a slice writes + * dozens of windows; two thousand slices bounds nothing a working backfill + * reaches, and stops one that counts progress it does not make. + */ +export const OURA_BACKFILL_MAX_SLICE = 2000; + +/** Days per window, by collection: dense or heavy ones read shorter windows. */ +const WINDOW_DAYS: Partial> = { + sleep: 30, + heartrate: 7, +}; +const DEFAULT_WINDOW_DAYS = 90; + +/** The collections that find where history starts; walked first. */ +const HISTORY_PROBES: readonly OuraCollectionName[] = ["activity", "sleep"]; + +/** How far before the learned history start the other collections reach. */ +const HISTORY_MARGIN_DAYS = 7; + +/** Walk order: the history probes first, heart rate (the dense one) last. */ +export const OURA_BACKFILL_ORDER: readonly OuraCollectionName[] = [ + ...HISTORY_PROBES, + ...OURA_COLLECTIONS.map((c) => c.name).filter( + (n) => !HISTORY_PROBES.includes(n) && n !== "heartrate", + ), + "heartrate", +]; + +export interface OuraBackfillPayload { + userId: string; + enqueuedAt: string; +} + +/** Test seam: a slice's budget and clock. */ +export interface OuraBackfillSliceOptions { + sliceMs?: number; + now?: () => number; +} + +type StopReason = "budget" | "lane_wanted" | "rate_limited"; + +function minDay(a: string | undefined, b: string): string { + return a === undefined || b < a ? b : a; +} + +/** The floor a collection walks back to, or undefined while not yet known. */ +function floorFor( + name: OuraCollectionName, + progress: OuraBackfillProgress, +): string | undefined { + if (HISTORY_PROBES.includes(name)) return OURA_HISTORY_FLOOR; + if (progress.historyStart === undefined) return undefined; + // No activity and no sleep anywhere: there is no history to read. + if (progress.historyStart === null) return progress.anchor; + const margin = shiftDateKey(progress.historyStart, -HISTORY_MARGIN_DAYS); + return margin < OURA_HISTORY_FLOOR ? OURA_HISTORY_FLOOR : margin; +} + +/** Settle `historyStart` once both probes are finished. */ +function settleHistoryStart(progress: OuraBackfillProgress): void { + if (progress.historyStart !== undefined) return; + const finished = HISTORY_PROBES.every((n) => { + const s = progress.collections[n]; + return s?.done || s?.denied; + }); + if (finished) progress.historyStart = progress.oldestSeen ?? null; +} + +async function saveProgress( + userId: string, + progress: OuraBackfillProgress, +): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { ouraBackfillProgress: ouraBackfillProgressJson(progress) }, + }); +} + +/** + * Read and write one window of one collection. Returns the oldest day a + * history probe returned, if any. + */ +async function runWindow( + userId: string, + tz: string, + name: OuraCollectionName, + window: { startDate: string; endDate: string }, + token: string, +): Promise<{ oldestDay?: string; imported: number }> { + const ctx = ouraRunContext( + userId, + tz, + window, + // The heart-rate window covers the same local days, end day included. + { + start: startOfLocalDayKey(window.startDate, tz), + end: startOfLocalDayKey(shiftDateKey(window.endDate, 1), tz), + }, + false, + ); + const readings = await ouraCollectionReader(name, ctx)(token); + await sweepStaleSleepSegments(userId, "OURA", ctx.sleepSweeps); + await retireOuraSleepRecords(userId, ctx.sleepRetirements); + const written = await upsertOuraMeasurements(userId, readings, { + emitArrivals: false, + }); + await refoldOuraTouchedDays(userId, ctx.heartRateTouched); + let oldestDay: string | undefined; + if (HISTORY_PROBES.includes(name)) { + for (const r of readings) { + oldestDay = minDay(oldestDay, userDayKey(r.measuredAt, tz)); + } + // A probe that only retired periods still saw history. + if (ctx.sleepRetirements.length > 0 || ctx.sleepSweeps.length > 0) { + oldestDay = minDay(oldestDay, window.startDate); + } + } + return { oldestDay, imported: written + ctx.sideImported }; +} + +/** + * Run one slice of the backfill for one account. Returns how many rows it + * wrote and whether it sent the next slice. + */ +export async function runOuraBackfillForUser( + userId: string, + shouldStop: () => boolean = () => false, + slice: number = 0, + options: OuraBackfillSliceOptions = {}, +): Promise<{ imported: number; continued: boolean }> { + if (slice > OURA_BACKFILL_MAX_SLICE) { + throw new Error( + `oura backfill for user ${userId} reached slice ${slice}, past the ceiling of ${OURA_BACKFILL_MAX_SLICE}`, + ); + } + const now = options.now ?? Date.now; + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { ouraBackfillProgress: true, ouraBackfillCompletedAt: true }, + }); + if (!user || user.ouraBackfillCompletedAt) { + annotate({ + action: { name: "oura.backfill.already_complete", details: { slice } }, + }); + return { imported: 0, continued: false }; + } + + const tz = await resolveUserTimezone(userId); + const stored = readOuraBackfillProgress( + user.ouraBackfillProgress, + OURA_BACKFILL_ORDER, + ); + // The first slice of a chain (a boot's, or a connect's) asks again for + // what an earlier grant refused: the grant may cover it now. One request + // each finds out; everything already walked stays walked. + const progress: OuraBackfillProgress = stored + ? slice === 0 + ? withoutDenied(stored) + : stored + : { anchor: userDayKey(new Date(now()), tz), collections: {} }; + + if (!progress.distanceRetired) { + const retired = await retireOuraEquivalentDistance(userId); + progress.distanceRetired = true; + await saveProgress(userId, progress); + annotate({ meta: { "oura.backfill.distance_retired": retired } }); + } + + const conn = await getOuraConnection(userId); + if (!conn) { + // Nothing to read: the account only had legacy rows to retire. A later + // connect reopens the backfill. + await prisma.user.update({ + where: { id: userId }, + data: { ouraBackfillCompletedAt: new Date(now()) }, + }); + return { imported: 0, continued: false }; + } + if (await isReauthRequired(userId, "oura")) { + annotate({ + action: { name: "oura.backfill.skipped_reauth", details: { slice } }, + }); + return { imported: 0, continued: false }; + } + + const deadline = now() + (options.sliceMs ?? OURA_BACKFILL_SLICE_MS); + const knownDenied = new Set(conn.scopeDenied ?? []); + let token = conn.accessToken; + let refreshed = false; + let imported = 0; + let windowsWritten = 0; + let newlySettled = 0; + let stopReason: StopReason | null = null; + let retryAfterMs: number | undefined; + let failure: unknown; + const failures: OuraCollectionName[] = []; + + walk: for (const name of OURA_BACKFILL_ORDER) { + const state = progress.collections[name] ?? { before: progress.anchor }; + progress.collections[name] = state; + if (state.done || state.denied) continue; + const floor = floorFor(name, progress); + // The other collections wait for the probes to say where history starts; + // the probes come first, so this is only a slice that ended in them. + if (floor === undefined) break; + + while (!state.done && !state.denied) { + if (state.before <= floor) { + state.done = true; + newlySettled += 1; + settleHistoryStart(progress); + await saveProgress(userId, progress); + break; + } + if (shouldStop() || now() >= deadline) { + stopReason = "budget"; + break walk; + } + if (laneWanted()) { + stopReason = "lane_wanted"; + break walk; + } + const windowDays = WINDOW_DAYS[name] ?? DEFAULT_WINDOW_DAYS; + const shifted = shiftDateKey(state.before, -windowDays); + const startDate = shifted < floor ? floor : shifted; + const window = { startDate, endDate: state.before }; + + let result: { oldestDay?: string; imported: number }; + try { + try { + result = await runWindow(userId, tz, name, window, token); + } catch (err) { + if (!isTokenRefusal(err) || refreshed) throw err; + // A refused token: refresh once for the whole slice, then retry. + refreshed = true; + let next: string | null; + try { + next = await refreshOuraAfterRefusal( + userId, + conn.refreshTokenCiphertext, + ); + } catch (refreshErr) { + await recordOuraSyncFailure(userId, refreshErr); + throw refreshErr; + } + if (!next) throw err; + token = next; + result = await runWindow(userId, tz, name, window, token); + } + } catch (err) { + if ( + isNamedScopeRefusal(err) || + (isTokenRefusal(err) && knownDenied.has(name)) + ) { + // The grant does not cover this collection: a scope note, not a + // failure. The others go on; a reconnect reopens it. + state.denied = true; + newlySettled += 1; + settleHistoryStart(progress); + await saveProgress(userId, progress); + annotate({ meta: { "oura.backfill.denied": name } }); + break; + } + if (err instanceof OuraApiError && err.httpStatus === 429) { + stopReason = "rate_limited"; + retryAfterMs = err.retryAfterMs; + break walk; + } + // One collection's failure stays with it: the slice goes on with the + // next one, and the failed window is read again next slice. + failures.push(name); + failure ??= err; + getEvent()?.addWarning( + `oura backfill: ${name} ${window.startDate}..${window.endDate} failed: ${err}`, + ); + continue walk; + } + + imported += result.imported; + windowsWritten += 1; + if (result.oldestDay) { + progress.oldestSeen = minDay(progress.oldestSeen, result.oldestDay); + } + state.before = startDate; + state.windows = (state.windows ?? 0) + 1; + await saveProgress(userId, progress); + } + } + + const settled = OURA_BACKFILL_ORDER.every((n) => { + const s = progress.collections[n]; + return s?.done || s?.denied; + }); + const report = { + slice, + stopReason, + failures, + windowsWritten, + imported, + edges: Object.fromEntries( + Object.entries(progress.collections).map(([n, s]) => [ + n, + s!.done ? "done" : s!.denied ? "denied" : s!.before, + ]), + ), + historyStart: progress.historyStart ?? null, + }; + annotate({ meta: { "oura.backfill.slice": report } }); + reportJobProgress({ + oura_backfill_slice: slice, + oura_backfill_windows: windowsWritten, + }); + + if (settled && stopReason === null && failures.length === 0) { + await prisma.user.update({ + where: { id: userId }, + data: { + ouraBackfillCompletedAt: new Date(now()), + ouraBackfillProgress: ouraBackfillProgressJson(progress), + }, + }); + annotate({ + action: { name: "oura.backfill.complete", details: { imported, slice } }, + }); + return { imported, continued: false }; + } + + const progressed = windowsWritten > 0 || newlySettled > 0; + // A rate limit passes on its own: the next slice goes after it, progress + // or not, as Strava's backfill does. Anything else needs progress to go on. + if (progressed || stopReason === "rate_limited") { + const delaySeconds = + stopReason === "rate_limited" + ? Math.max( + INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + retryAfterMs !== undefined + ? Math.ceil(retryAfterMs / 1000) + : OURA_BACKFILL_THROTTLED_DELAY_SECONDS, + ) + : stopReason === "lane_wanted" + ? LANE_YIELD_DELAY_SECONDS + : INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS; + if (await sendNextSlice(userId, slice, delaySeconds)) { + annotate({ + action: { + name: "oura.backfill.continued", + details: { slice, stopReason, windowsWritten, delaySeconds }, + }, + }); + return { imported, continued: true }; + } + } + + // Nothing moved, or no next slice could be sent: the pg-boss retry and, + // after it, the next boot's discovery take it from here. The progress + // note stands, so whatever runs next starts where this one stopped. + throw new Error( + `oura backfill incomplete for user ${userId} (slice ${slice}${stopReason ? `, stopped on ${stopReason}` : ""}${failure ? `: ${failure instanceof Error ? failure.message : String(failure)}` : ""})`, + ); +} + +async function sendNextSlice( + userId: string, + slice: number, + delaySeconds: number, +): Promise { + if (slice + 1 > OURA_BACKFILL_MAX_SLICE) return false; + const boss = getGlobalBoss(); + if (!boss) return false; + const sent = await enqueueIntegrationBackfillAdmission( + boss, + { + kind: "oura-backfill", + data: { userId, enqueuedAt: new Date().toISOString(), slice: slice + 1 }, + }, + delaySeconds, + ); + return Boolean(sent); +} + +/** + * Boot-time discovery: every account with an Oura connection whose backfill + * is not stamped, and every account still holding equivalent-distance rows + * from before the fix. Idempotent across reboots; errors come back in the + * result so the worker boot never fails on a miss. + */ +export async function enqueueBootTimeOuraBackfill( + startAfterSeconds: number = 0, +): Promise<{ enqueued: number; skipped: number; error: string | null }> { + const boss = getGlobalBoss(); + if (!boss) return { enqueued: 0, skipped: 0, error: null }; + try { + const users = await prisma.user.findMany({ + where: { + ouraBackfillCompletedAt: null, + OR: [ + { ouraAccessTokenEncrypted: { not: null } }, + { measurements: { some: OURA_EQUIVALENT_DISTANCE_WHERE } }, + ], + }, + select: { id: true }, + }); + let enqueued = 0; + let skipped = 0; + for (const { id: userId } of users) { + const payload: OuraBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send( + OURA_BACKFILL_QUEUE, + payload, + integrationBackfillSourceOptions( + `oura-backfill|${userId}`, + startAfterSeconds, + ), + ); + if (jobId) enqueued += 1; + else skipped += 1; + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 3bc085fd0..80b1feeb1 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -130,6 +130,7 @@ export const QUEUE_RUNTIME: Readonly> = { "oura-sync": cohortSync("Oura"), "strava-sync": cohortSync("Strava"), "strava-backfill": forwarder, + "oura-backfill": forwarder, // ── Status and insights ──────────────────────────────────────────── "insights-general-status": { diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index afb14ee91..d96055632 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -50,6 +50,12 @@ import { enqueueBootTimeStravaBackfill, type StravaBackfillPayload, } from "@/lib/jobs/strava-backfill"; +import { + OURA_BACKFILL_QUEUE, + OURA_BACKFILL_CONCURRENCY, + enqueueBootTimeOuraBackfill, + type OuraBackfillPayload, +} from "@/lib/jobs/oura-backfill"; import { SLEEP_TIMELINE_BACKFILL_QUEUE, SLEEP_TIMELINE_BACKFILL_CONCURRENCY, @@ -325,6 +331,8 @@ const allQueues = [ // v1.17.0 — Polar + Oura OAuth poll sync. POLAR_SYNC_QUEUE, OURA_SYNC_QUEUE, + // v1.44 — Oura history backfill, sliced through the shared lane. + OURA_BACKFILL_QUEUE, // v1.28.x — Strava OAuth poll sync + self-converging boot backfill. STRAVA_SYNC_QUEUE, STRAVA_BACKFILL_QUEUE, @@ -455,6 +463,11 @@ const queuePolicies: QueuePolicyTable = { reason: "Per-connection full-history backfill; boot-discovery driven and self-converging.", }, + [OURA_BACKFILL_QUEUE]: { + policy: "exclusive", + reason: + "Per-connection full-history backfill; boot-discovery driven and self-converging.", + }, [GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE]: { policy: "exclusive", reason: @@ -787,6 +800,23 @@ export async function registerIntegrationSyncQueues( return jobDone({ jobs: jobs.length }); }, ); + // v1.44 — Oura history backfill. The source queue keeps the per-account + // singleton and forwards admitted work to the shared drain, where it runs + // in resumable slices. + await createAndWork( + boss, + OURA_BACKFILL_QUEUE, + { localConcurrency: OURA_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + await enqueueIntegrationBackfillAdmission(boss, { + kind: "oura-backfill", + data: job.data, + }); + } + return jobDone({ jobs: jobs.length }); + }, + ); return allQueues; } @@ -943,6 +973,29 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise { ); } + // v1.44 — Oura history backfill. Finds every Oura connection not yet + // backfilled (and every account still holding the retired + // equivalent-distance rows) and enqueues one sliced backfill per account. + try { + const { enqueued, skipped, error } = await enqueueBootTimeOuraBackfill( + bootStaggerSecondsFor("oura-backfill"), + ); + if (error) { + workerLog("error", `[oura-backfill] boot discovery failed: ${error}`); + } else { + workerLog( + "info", + `[oura-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[oura-backfill] boot discovery threw an unexpected error", + err, + ); + } + // v1.18.1 — one-shot lab-biomarker backfill. Finds every user holding an // un-linked live lab reading and enqueues one job each. A completed pass // links every reading, dropping the user from the discovery set. diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index 39fa03847..d90560c84 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -46,6 +46,7 @@ const subsystemSurface = { "integration-watchdog-hourly": { audience: "system" }, "integration-watchdog-daily": { audience: "system" }, "strava-backfill": { audience: "system" }, + "oura-backfill": { audience: "system" }, "oidc-native-handoff-cleanup": { audience: "system" }, // register-status.ts diff --git a/src/lib/oura/__tests__/backfill-progress.test.ts b/src/lib/oura/__tests__/backfill-progress.test.ts new file mode 100644 index 000000000..37c6d71d6 --- /dev/null +++ b/src/lib/oura/__tests__/backfill-progress.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + ouraBackfillProgressJson, + readOuraBackfillProgress, + withoutDenied, +} from "../backfill-progress"; +import { OURA_BACKFILL_ORDER } from "@/lib/jobs/oura-backfill"; + +describe("Oura backfill progress", () => { + it("round-trips a note through the column", () => { + const progress = { + anchor: "2026-10-01", + oldestSeen: "2024-03-02", + historyStart: "2024-03-02", + distanceRetired: true, + collections: { + activity: { before: "2015-01-01", done: true, windows: 47 }, + heartrate: { before: "2025-06-10", windows: 3 }, + workout: { before: "2026-10-01", denied: true }, + }, + }; + const json = ouraBackfillProgressJson(progress); + expect(readOuraBackfillProgress(json, OURA_BACKFILL_ORDER)).toEqual( + progress, + ); + }); + + it("keeps an empty history start apart from an unknown one", () => { + const none = readOuraBackfillProgress( + { anchor: "2026-10-01", historyStart: null, collections: {} }, + OURA_BACKFILL_ORDER, + ); + expect(none?.historyStart).toBeNull(); + const unknown = readOuraBackfillProgress( + { anchor: "2026-10-01", collections: {} }, + OURA_BACKFILL_ORDER, + ); + expect(unknown).not.toHaveProperty("historyStart"); + }); + + it("drops a malformed entry on its own and keeps the rest", () => { + const read = readOuraBackfillProgress( + { + anchor: "2026-10-01", + collections: { + sleep: { before: "yesterday" }, + activity: { before: "2025-01-01" }, + not_a_collection: { before: "2025-01-01" }, + }, + }, + OURA_BACKFILL_ORDER, + ); + expect(read?.collections).toEqual({ activity: { before: "2025-01-01" } }); + }); + + it("reads no note at all without an anchor", () => { + expect(readOuraBackfillProgress(null, OURA_BACKFILL_ORDER)).toBeNull(); + expect( + readOuraBackfillProgress({ collections: {} }, OURA_BACKFILL_ORDER), + ).toBeNull(); + }); + + it("asks again for what a grant refused, and only that", () => { + const cleared = withoutDenied({ + anchor: "2026-10-01", + collections: { + workout: { before: "2026-10-01", denied: true }, + activity: { before: "2015-01-01", done: true }, + }, + }); + expect(cleared.collections).toEqual({ + workout: { before: "2026-10-01" }, + activity: { before: "2015-01-01", done: true }, + }); + }); + + it("walks the history probes first and heart rate last", () => { + expect(OURA_BACKFILL_ORDER.slice(0, 2)).toEqual(["activity", "sleep"]); + expect(OURA_BACKFILL_ORDER.at(-1)).toBe("heartrate"); + expect(new Set(OURA_BACKFILL_ORDER).size).toBe(OURA_BACKFILL_ORDER.length); + }); +}); diff --git a/src/lib/oura/backfill-progress.ts b/src/lib/oura/backfill-progress.ts new file mode 100644 index 000000000..a5c17ef0d --- /dev/null +++ b/src/lib/oura/backfill-progress.ts @@ -0,0 +1,141 @@ +/** + * The `ouraBackfillProgress` column: how far the Oura history backfill has + * got, carried from one slice to the next. + * + * Each collection is walked from the day the backfill began (`anchor`) back + * toward the start of the account's history, one window at a time. After a + * window is written its start becomes the collection's `before`, and the next + * window ends there, so a slice that stops (its budget, a rate limit, a failed + * window) loses at most the window it was in, and the next slice goes on from + * the last one written instead of from the newest day. A collection that + * reached the start of history is `done`; one the grant does not cover is + * `denied` and is asked for again by the first slice of the next chain. + * + * Where history starts is learned, not assumed: `activity` and `sleep` are + * walked first, back to `OURA_HISTORY_FLOOR`, and the oldest day either + * returned (`oldestSeen`) becomes `historyStart`. Every other collection stops + * a week before it, so an account with two years of history costs two years of + * requests, not eleven. + * + * The column is JSON the code wrote, read leniently: a malformed entry is + * dropped on its own and that collection starts over from the anchor, which + * is safe because every write is keyed. + */ +import { z } from "zod"; + +import type { OuraCollectionName } from "./scope"; + +/** The oldest day any Oura ring could have recorded (the first ring shipped in 2015). */ +export const OURA_HISTORY_FLOOR = "2015-01-01"; + +export interface OuraBackfillCollectionState { + /** Upper bound (YYYY-MM-DD) of the next window to read. */ + before: string; + done?: boolean; + denied?: boolean; + /** Windows written across every slice, for the record only. */ + windows?: number; +} + +export interface OuraBackfillProgress { + anchor: string; + /** Oldest day `activity` or `sleep` returned so far. */ + oldestSeen?: string; + /** + * Where the account's history starts, once `activity` and `sleep` are both + * walked to the floor; `null` when they found nothing at all. + */ + historyStart?: string | null; + /** The legacy equivalent-distance rows are retired. */ + distanceRetired?: boolean; + collections: Partial>; +} + +const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +const stateSchema = z.object({ + before: day, + done: z.boolean().optional().catch(undefined), + denied: z.boolean().optional().catch(undefined), + windows: z.number().int().min(0).optional().catch(undefined), +}); + +/** Read the column; null when there is no usable progress yet. */ +export function readOuraBackfillProgress( + value: unknown, + known: readonly OuraCollectionName[], +): OuraBackfillProgress | null { + if (value === null || typeof value !== "object") return null; + const raw = value as Record; + const anchor = day.safeParse(raw.anchor); + if (!anchor.success) return null; + const collections: OuraBackfillProgress["collections"] = {}; + const rawCollections = + raw.collections && typeof raw.collections === "object" + ? (raw.collections as Record) + : {}; + for (const name of known) { + const parsed = stateSchema.safeParse(rawCollections[name]); + if (parsed.success) { + const s = parsed.data; + collections[name] = { + before: s.before, + ...(s.done ? { done: true } : {}), + ...(s.denied ? { denied: true } : {}), + ...(s.windows !== undefined ? { windows: s.windows } : {}), + }; + } + } + const oldestSeen = day.safeParse(raw.oldestSeen); + const historyStart = + raw.historyStart === null ? null : day.safeParse(raw.historyStart); + return { + anchor: anchor.data, + ...(oldestSeen.success ? { oldestSeen: oldestSeen.data } : {}), + ...(historyStart === null + ? { historyStart: null } + : historyStart.success + ? { historyStart: historyStart.data } + : {}), + ...(raw.distanceRetired === true ? { distanceRetired: true } : {}), + collections, + }; +} + +/** The column's value for a progress note. */ +export function ouraBackfillProgressJson(progress: OuraBackfillProgress) { + return { + anchor: progress.anchor, + ...(progress.oldestSeen ? { oldestSeen: progress.oldestSeen } : {}), + ...(progress.historyStart !== undefined + ? { historyStart: progress.historyStart } + : {}), + ...(progress.distanceRetired ? { distanceRetired: true } : {}), + collections: Object.fromEntries( + Object.entries(progress.collections).map(([name, s]) => [ + name, + { + before: s!.before, + ...(s!.done ? { done: true } : {}), + ...(s!.denied ? { denied: true } : {}), + ...(s!.windows !== undefined ? { windows: s!.windows } : {}), + }, + ]), + ), + }; +} + +/** The progress with every `denied` mark cleared, for a fresh grant. */ +export function withoutDenied( + progress: OuraBackfillProgress, +): OuraBackfillProgress { + return { + ...progress, + collections: Object.fromEntries( + Object.entries(progress.collections).map(([name, s]) => { + const { denied: _denied, ...rest } = s!; + return [name, rest]; + }), + ), + }; +} diff --git a/src/lib/oura/retire-distance.ts b/src/lib/oura/retire-distance.ts new file mode 100644 index 000000000..c79c96dfa --- /dev/null +++ b/src/lib/oura/retire-distance.ts @@ -0,0 +1,72 @@ +/** + * Retire the `WALKING_RUNNING_DISTANCE` rows earlier Oura syncs wrote from + * `equivalent_walking_distance`. + * + * That field is an activity equivalent (the distance a walk of the day's + * effort would have covered), not distance travelled, so the rows were wrong + * from the start and the sync no longer writes them (`mapDailyActivity`). + * What is already stored is soft-deleted here, in bounded batches, and the + * touched days are refolded through the shared mutation tail so the rollups + * stop counting them, the same way `retireOuraSleepRecords` retires a + * rejected sleep period. + * + * Only rows the activity mapper keyed (`activity::distance`) of source + * OURA match; another source's distance, and any OURA row of another shape, + * stay. Idempotent: a second run finds nothing live and returns 0. The Oura + * backfill runs it before anything else on every connected account, and the + * backfill's discovery also offers an account that still holds such rows + * after its Oura connection was removed. + */ +import { prisma } from "@/lib/db"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { annotate } from "@/lib/logging/context"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; +import type { Prisma } from "@/generated/prisma/client"; + +/** Rows per batch: one bounded read, one update, one refold. */ +export const OURA_DISTANCE_RETIRE_BATCH = 500; + +/** The rows the activity mapper wrote from the equivalent distance. */ +export const OURA_EQUIVALENT_DISTANCE_WHERE = { + source: "OURA", + type: "WALKING_RUNNING_DISTANCE", + deletedAt: null, + externalId: { startsWith: "activity:", endsWith: ":distance" }, +} as const satisfies Prisma.MeasurementWhereInput; + +/** + * Soft-delete every live equivalent-distance row of one account. Returns how + * many rows were retired. Throws on a database failure: the caller decides + * whether that holds its own progress back. + */ +export async function retireOuraEquivalentDistance( + userId: string, +): Promise { + let retired = 0; + for (;;) { + const rows = await prisma.measurement.findMany({ + where: { userId, ...OURA_EQUIVALENT_DISTANCE_WHERE }, + select: { id: true, type: true, measuredAt: true }, + orderBy: { measuredAt: "asc" }, + take: OURA_DISTANCE_RETIRE_BATCH, + }); + if (rows.length === 0) break; + const res = await prisma.measurement.updateMany({ + where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + retired += res.count; + await afterMeasurementMutation(userId, rows, { + source: "sync", + label: "oura", + }); + // A batch the update found already retired (a concurrent run) still + // leaves the read empty next time round, so the loop ends. + if (rows.length < OURA_DISTANCE_RETIRE_BATCH) break; + } + if (retired > 0) { + invalidateUserMeasurements(userId); + annotate({ meta: { oura_equivalent_distance_retired: retired } }); + } + return retired; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 8c345c0ce..c205e05e5 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -436,7 +436,7 @@ export function ouraCollectionReader( * token and found it valid, the grant just does not cover this collection. A * refresh cannot widen a grant, so none is spent on it. */ -function isNamedScopeRefusal(err: unknown): boolean { +export function isNamedScopeRefusal(err: unknown): boolean { return ( err instanceof OuraApiError && err.httpStatus === 401 && @@ -449,7 +449,7 @@ function isNamedScopeRefusal(err: unknown): boolean { * no body to tell. This is what the one refresh is for. A 403 is neither: Oura * answers it for a lapsed membership, which no refresh or reconnect fixes. */ -function isTokenRefusal(err: unknown): boolean { +export function isTokenRefusal(err: unknown): boolean { return ( err instanceof OuraApiError && err.httpStatus === 401 && diff --git a/tests/integration/oura-backfill.test.ts b/tests/integration/oura-backfill.test.ts new file mode 100644 index 000000000..8dd616149 --- /dev/null +++ b/tests/integration/oura-backfill.test.ts @@ -0,0 +1,502 @@ +/** + * The Oura history backfill against the real schema. + * + * Oura's API is replaced at the `safeFetch` seam by a generator that builds + * each page on request: a year and a half of daily activity, one workout, and + * a heart-rate reading every five minutes over the same span (some 160 000 + * samples, in pages of 1 000). The backfill runs slice after slice, each one + * cut short by its budget, until it stamps its marker. What is pinned: + * + * - it reaches the end: every heart-rate sample is a PULSE row, every day a + * steps row, the workout a Workout row, the marker is stamped, and the + * legacy equivalent-distance rows are retired with their rollup day; + * - it resumes: no window is read twice across slices, apart from the one + * window a 429 interrupted, and that slice sends the next one after the + * Retry-After the 429 named; + * - it does not grow with the history: the collected heap after the first + * quarter of heart-rate pages stays inside a budget a collect-everything + * walk of the same pages passes several times over; + * - discovery offers an existing connection without a reconnect, and an + * account that kept only the legacy rows, and nothing already stamped. + */ +import v8 from "node:v8"; +import vm from "node:vm"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "oura-backfill-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; +/** The day the backfill begins, and the end of the generated history. */ +const ANCHOR = "2026-10-01"; +const NOW = Date.parse(`${ANCHOR}T12:00:00Z`); +/** The first day of the generated history. */ +const HISTORY_FROM = "2025-04-01"; +const HR_FROM = Date.parse(`${HISTORY_FROM}T00:00:00Z`); +const HR_STEP = 5 * 60_000; +const HR_PAGE = 1000; +const HR_TOTAL = Math.floor((NOW - HR_FROM) / HR_STEP); + +const GROWTH_BUDGET_BYTES = 24 * 1024 * 1024; + +const fake = vi.hoisted(() => ({ + requests: [] as string[], + heartRatePages: 0, + /** Answer the next heart-rate page with a 429 once this many have been served. */ + rateLimitAtPage: -1, + onPage: null as (() => void) | null, + /** Collections answered with a 401 naming their scope. */ + denied: new Set(), + sends: [] as Array<{ queue: string; payload: unknown; options: unknown }>, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ + send: async (queue: string, payload: unknown, options: unknown) => { + fake.sends.push({ queue, payload, options }); + return `job-${fake.sends.length}`; + }, + }), +})); + +function dayKey(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + const json = (body: unknown, status = 200, headers = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + return { + ...actual, + // A plain function, not `vi.fn`: a mock records every result, and the + // results here are whole pages. + safeFetch: async (url: string) => { + const u = new URL(url); + const p = u.searchParams; + const collection = u.pathname.split("/").pop()!; + const token = p.get("next_token"); + fake.requests.push( + `${collection}|${p.get("start_date") ?? p.get("start_datetime")}|${p.get("end_date") ?? p.get("end_datetime")}|${token ?? ""}`, + ); + if (fake.denied.has(collection)) { + return json( + { detail: `Token is not authorized access ${collection} scope.` }, + 401, + ); + } + if (collection === "heartrate") { + if (fake.rateLimitAtPage === fake.heartRatePages) { + fake.rateLimitAtPage = -1; + return json({ detail: "Too many requests" }, 429, { + "retry-after": "600", + }); + } + const start = Date.parse(p.get("start_datetime")!); + const end = Date.parse(p.get("end_datetime")!); + const first = Math.max(0, Math.ceil((start - HR_FROM) / HR_STEP)); + const last = Math.min(HR_TOTAL, Math.ceil((end - HR_FROM) / HR_STEP)); + const from = first + Number(token ?? 0) * HR_PAGE; + const to = Math.min(last, from + HR_PAGE); + const data = []; + for (let i = from; i < to; i++) { + data.push({ + bpm: 52 + (i % 40), + source: "awake", + timestamp: new Date(HR_FROM + i * HR_STEP).toISOString(), + }); + } + fake.heartRatePages++; + fake.onPage?.(); + return json({ + data, + next_token: to < last ? String(Number(token ?? 0) + 1) : null, + }); + } + if (collection === "daily_activity") { + const from = + p.get("start_date")! < HISTORY_FROM + ? HISTORY_FROM + : p.get("start_date")!; + const to = p.get("end_date")! > ANCHOR ? ANCHOR : p.get("end_date")!; + const data = []; + for ( + let t = Date.parse(`${from}T00:00:00Z`); + dayKey(t) <= to; + t += DAY_MS + ) { + data.push({ + id: `act-${dayKey(t)}`, + day: dayKey(t), + steps: 5000, + active_calories: 300, + equivalent_walking_distance: 4000, + }); + } + return json({ data, next_token: null }); + } + if (collection === "workout") { + const day = "2026-02-14"; + const inRange = + p.get("start_date")! <= day && day <= p.get("end_date")!; + return json({ + data: inRange + ? [ + { + id: "oura-workout-1", + activity: "cycling", + calories: 410, + distance: 15000, + intensity: "moderate", + source: "manual", + day, + start_datetime: `${day}T08:00:00+00:00`, + end_datetime: `${day}T09:00:00+00:00`, + }, + ] + : [], + next_token: null, + }); + } + return json({ data: [], next_token: null }); + }, + }; +}); + +const forceGc = ((): (() => void) => { + v8.setFlagsFromString("--expose-gc"); + const gc = vm.runInNewContext("gc") as () => void; + v8.setFlagsFromString("--no-expose-gc"); + return gc; +})(); + +function liveHeapBytes(): number { + forceGc(); + forceGc(); + return process.memoryUsage().heapUsed; +} + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + fake.requests = []; + fake.heartRatePages = 0; + fake.rateLimitAtPage = -1; + fake.sends = []; + fake.denied = new Set(); + const { encrypt } = await import("@/lib/crypto"); + await prisma.user.create({ + data: { + id: USER, + username: USER, + timezone: "UTC", + ouraAccessTokenEncrypted: encrypt("access"), + ouraRefreshTokenEncrypted: encrypt("refresh"), + }, + }); +}, 120_000); + +/** Seed the rows an earlier sync wrote from the equivalent distance. */ +async function seedLegacyDistance(userId: string): Promise { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId, + type: "WALKING_RUNNING_DISTANCE", + source: "OURA", + value: 4000, + unit: "m", + measuredAt: new Date("2026-09-01T12:00:00Z"), + externalId: "activity:2026-09-01:distance", + }, + { + userId, + type: "WALKING_RUNNING_DISTANCE", + source: "OURA", + value: 5200, + unit: "m", + measuredAt: new Date("2026-09-02T12:00:00Z"), + externalId: "activity:2026-09-02:distance", + }, + // Another source's distance on the same day stays. + { + userId, + type: "WALKING_RUNNING_DISTANCE", + source: "APPLE_HEALTH", + value: 3100, + unit: "m", + measuredAt: new Date("2026-09-01T18:00:00Z"), + externalId: "hk-dist-1", + }, + ], + }); +} + +describe("retireOuraEquivalentDistance", () => { + it("soft-deletes only the OURA equivalent-distance rows and refolds the day", async () => { + const prisma = getPrismaClient(); + await seedLegacyDistance(USER); + const { recomputeBucketsForMeasurement } = + await import("@/lib/rollups/measurement-rollups"); + await recomputeBucketsForMeasurement( + USER, + "WALKING_RUNNING_DISTANCE", + new Date("2026-09-01T12:00:00Z"), + ); + const ouraDay = () => + prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "WALKING_RUNNING_DISTANCE", + granularity: "DAY", + source: "OURA", + bucketStart: new Date("2026-09-01T00:00:00Z"), + }, + }); + expect((await ouraDay())?.count).toBe(1); + + const { retireOuraEquivalentDistance } = + await import("@/lib/oura/retire-distance"); + expect(await retireOuraEquivalentDistance(USER)).toBe(2); + expect(await retireOuraEquivalentDistance(USER)).toBe(0); + + const live = await prisma.measurement.findMany({ + where: { userId: USER, deletedAt: null }, + select: { source: true, externalId: true }, + }); + expect(live).toEqual([{ source: "APPLE_HEALTH", externalId: "hk-dist-1" }]); + const after = await ouraDay(); + expect(after?.count ?? 0).toBe(0); + }); +}); + +describe("Oura history backfill", () => { + it("walks the whole history in resumable slices with bounded memory", async () => { + const prisma = getPrismaClient(); + await seedLegacyDistance(USER); + const { runOuraBackfillForUser } = await import("@/lib/jobs/oura-backfill"); + + // A rate limit in the middle of the heart-rate walk. + fake.rateLimitAtPage = 40; + + let clock = NOW; + const now = () => (clock += 1000); + const quarter = Math.ceil(HR_TOTAL / HR_PAGE / 4); + let anchor: number | null = null; + let peak = 0; + let samples = 0; + fake.onPage = () => { + if (fake.heartRatePages % 5 !== 0) return; + const live = liveHeapBytes(); + if (anchor === null) { + if (fake.heartRatePages >= quarter) anchor = peak = live; + return; + } + samples++; + peak = Math.max(peak, live); + }; + + let slice = 0; + let continued = true; + let slices = 0; + try { + while (continued) { + const run = await runOuraBackfillForUser(USER, () => false, slice, { + sliceMs: 40_000, + now, + }); + continued = run.continued; + slice += 1; + slices += 1; + expect(slices).toBeLessThan(200); + } + } finally { + fake.onPage = null; + } + process.stderr.write( + `[oura-backfill] slices=${slices} hrPages=${fake.heartRatePages} growth=${((peak - (anchor ?? 0)) / 1024 / 1024).toFixed(1)} MB over ${samples} samples\n`, + ); + + // It ran in many slices, and finished. + expect(slices).toBeGreaterThan(5); + const user = await prisma.user.findUniqueOrThrow({ + where: { id: USER }, + select: { ouraBackfillCompletedAt: true, ouraBackfillProgress: true }, + }); + expect(user.ouraBackfillCompletedAt).not.toBeNull(); + const progress = user.ouraBackfillProgress as { + historyStart: string; + distanceRetired: boolean; + }; + expect(progress.historyStart).toBe(HISTORY_FROM); + expect(progress.distanceRetired).toBe(true); + + // Every heart-rate sample, every day, the workout. + expect( + await prisma.measurement.count({ + where: { userId: USER, type: "PULSE", source: "OURA" }, + }), + ).toBe(HR_TOTAL); + const days = + (Date.parse(`${ANCHOR}T00:00:00Z`) - + Date.parse(`${HISTORY_FROM}T00:00:00Z`)) / + DAY_MS + + 1; + expect( + await prisma.measurement.count({ + where: { userId: USER, type: "ACTIVITY_STEPS", source: "OURA" }, + }), + ).toBe(days); + const workouts = await prisma.workout.findMany({ + where: { userId: USER, source: "OURA" }, + select: { externalId: true, sportType: true, totalDistanceM: true }, + }); + expect(workouts).toEqual([ + { + externalId: "oura-workout-1", + sportType: "cycling", + totalDistanceM: 15000, + }, + ]); + // No equivalent distance written, and the legacy rows retired. + expect( + await prisma.measurement.count({ + where: { + userId: USER, + source: "OURA", + type: "WALKING_RUNNING_DISTANCE", + deletedAt: null, + }, + }), + ).toBe(0); + + // Resumed, never restarted: every first page of a window was asked for + // once, apart from the heart-rate window the 429 interrupted. + const firstPages = fake.requests.filter((r) => r.endsWith("|")); + const repeated = firstPages.filter((r, i) => firstPages.indexOf(r) !== i); + expect(repeated).toHaveLength(1); + expect(repeated[0]).toMatch(/^heartrate\|/); + // The slice the 429 ended sent the next one after the wait it named. + const delays = fake.sends.map( + (s) => (s.options as { startAfter?: number }).startAfter ?? 0, + ); + expect(delays).toContain(600); + + // Bounded: the heap after the first quarter of the stream stays flat. + expect(samples).toBeGreaterThan(5); + expect(peak - anchor!).toBeLessThan(GROWTH_BUDGET_BYTES); + }, 900_000); + + it("leaves an existing connection's history to discovery, no reconnect needed", async () => { + const prisma = getPrismaClient(); + // An account whose Oura connection is gone but whose legacy rows stay. + await prisma.user.create({ + data: { id: "oura-legacy-only", username: "oura-legacy-only" }, + }); + await seedLegacyDistance("oura-legacy-only"); + // An account already backfilled. + await prisma.user.create({ + data: { + id: "oura-done", + username: "oura-done", + ouraAccessTokenEncrypted: "x", + ouraRefreshTokenEncrypted: "y", + ouraBackfillCompletedAt: new Date(), + }, + }); + const { enqueueBootTimeOuraBackfill, runOuraBackfillForUser } = + await import("@/lib/jobs/oura-backfill"); + + const result = await enqueueBootTimeOuraBackfill(30); + expect(result).toEqual({ enqueued: 2, skipped: 0, error: null }); + expect( + fake.sends.map((s) => (s.payload as { userId: string }).userId).sort(), + ).toEqual(["oura-backfill-owner", "oura-legacy-only"]); + + // The legacy-only account retires its rows and is stamped at once. + await runOuraBackfillForUser("oura-legacy-only"); + const legacy = await prisma.user.findUniqueOrThrow({ + where: { id: "oura-legacy-only" }, + select: { ouraBackfillCompletedAt: true }, + }); + expect(legacy.ouraBackfillCompletedAt).not.toBeNull(); + expect( + await prisma.measurement.count({ + where: { userId: "oura-legacy-only", source: "OURA", deletedAt: null }, + }), + ).toBe(0); + }); + + it("finishes past a collection the grant refuses, and asks again after a reconnect", async () => { + const prisma = getPrismaClient(); + const { OURA_BACKFILL_ORDER, runOuraBackfillForUser } = + await import("@/lib/jobs/oura-backfill"); + // Everything but workouts already walked. + await prisma.user.update({ + where: { id: USER }, + data: { + ouraBackfillProgress: { + anchor: ANCHOR, + historyStart: "2026-01-01", + distanceRetired: true, + collections: Object.fromEntries( + OURA_BACKFILL_ORDER.filter((n) => n !== "workout").map((n) => [ + n, + { before: "2015-01-01", done: true }, + ]), + ), + }, + }, + }); + fake.denied.add("workout"); + + expect(await runOuraBackfillForUser(USER)).toEqual({ + imported: 0, + continued: false, + }); + let user = await prisma.user.findUniqueOrThrow({ + where: { id: USER }, + select: { ouraBackfillCompletedAt: true, ouraBackfillProgress: true }, + }); + expect(user.ouraBackfillCompletedAt).not.toBeNull(); + expect( + (user.ouraBackfillProgress as { collections: Record }) + .collections.workout, + ).toEqual({ before: ANCHOR, denied: true }); + // One request found it out; nothing was recorded as a failure. + expect(fake.requests.filter((r) => r.startsWith("workout|"))).toHaveLength( + 1, + ); + expect(await prisma.integrationStatus.count()).toBe(0); + + // A reconnect that grants the scope clears the stamp; the next chain + // reads workouts and nothing else. + fake.denied.clear(); + fake.requests = []; + await prisma.user.update({ + where: { id: USER }, + data: { ouraBackfillCompletedAt: null }, + }); + await runOuraBackfillForUser(USER); + user = await prisma.user.findUniqueOrThrow({ + where: { id: USER }, + select: { ouraBackfillCompletedAt: true, ouraBackfillProgress: true }, + }); + expect(user.ouraBackfillCompletedAt).not.toBeNull(); + expect(new Set(fake.requests.map((r) => r.split("|")[0]))).toEqual( + new Set(["workout"]), + ); + expect( + await prisma.workout.count({ where: { userId: USER, source: "OURA" } }), + ).toBe(1); + }); +}); From 3d4b68f7a6fb71b359f9248f070f13b2e1deac80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:56:16 +0200 Subject: [PATCH 24/98] test(oura): pin the backfill's slice, resume, rate-limit and scope paths --- src/lib/jobs/__tests__/oura-backfill.test.ts | 301 +++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/lib/jobs/__tests__/oura-backfill.test.ts diff --git a/src/lib/jobs/__tests__/oura-backfill.test.ts b/src/lib/jobs/__tests__/oura-backfill.test.ts new file mode 100644 index 000000000..42a9dfc40 --- /dev/null +++ b/src/lib/jobs/__tests__/oura-backfill.test.ts @@ -0,0 +1,301 @@ +/** + * The Oura backfill's slice logic with the provider and the database stubbed: + * where a slice stops, what it saves, where the next one starts, and how long + * it waits. The real walk against Postgres is + * `tests/integration/oura-backfill.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + stored: null as unknown, + completedAt: null as Date | null, + sends: [] as Array<{ payload: unknown; startAfter?: number }>, + windows: [] as string[], + /** Per collection: what a window read does. */ + behave: {} as Record< + string, + (w: { startDate: string; endDate: string }) => Promise + >, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(async () => ({ + ouraBackfillProgress: h.stored, + ouraBackfillCompletedAt: h.completedAt, + })), + update: vi.fn( + async ({ + data, + }: { + data: { + ouraBackfillProgress?: unknown; + ouraBackfillCompletedAt?: Date; + }; + }) => { + if (data.ouraBackfillProgress !== undefined) { + h.stored = JSON.parse(JSON.stringify(data.ouraBackfillProgress)); + } + if (data.ouraBackfillCompletedAt) { + h.completedAt = data.ouraBackfillCompletedAt; + } + }, + ), + }, + }, +})); +vi.mock("@/lib/oura/retire-distance", () => ({ + OURA_EQUIVALENT_DISTANCE_WHERE: {}, + retireOuraEquivalentDistance: vi.fn(async () => 0), +})); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraConnection: vi.fn(async () => ({ + accessToken: "acc", + refreshTokenCiphertext: "enc", + scopeDenied: [], + })), +})); +vi.mock("@/lib/oura/oauth-descriptor", () => ({ + refreshOuraAfterRefusal: vi.fn(async () => "fresh"), +})); +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(async () => false), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "UTC"), +})); +vi.mock("@/lib/sleep/sweep-stale-segments", () => ({ + sweepStaleSleepSegments: vi.fn(async () => {}), +})); +vi.mock("@/lib/oura/sync-heart-rate", async (importOriginal) => ({ + ...(await importOriginal()), + refoldOuraTouchedDays: vi.fn(async () => {}), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ + send: async ( + _queue: string, + payload: unknown, + options: { startAfter?: number }, + ) => { + h.sends.push({ payload, startAfter: options.startAfter }); + return "job"; + }, + }), +})); +vi.mock("@/lib/oura/sync", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ouraCollectionReader: + (name: string, ctx: { query: { startDate: string; endDate: string } }) => + async () => { + h.windows.push(`${name}:${ctx.query.startDate}..${ctx.query.endDate}`); + return (h.behave[name] ?? (async () => []))(ctx.query); + }, + upsertOuraMeasurements: vi.fn( + async (_u: string, readings: unknown[]) => readings.length, + ), + retireOuraSleepRecords: vi.fn(async () => 0), + recordOuraSyncFailure: vi.fn(async () => {}), + }; +}); + +import { OuraApiError } from "@/lib/oura/response-classifier"; +import { OURA_BACKFILL_ORDER, runOuraBackfillForUser } from "../oura-backfill"; + +const T0 = Date.parse("2026-10-01T12:00:00Z"); + +/** Activity on every window from mid-2026: history starts 2026-06-01. */ +function activityFrom(first: string) { + return async (w: { startDate: string; endDate: string }) => + w.endDate < first + ? [] + : [ + { + type: "ACTIVITY_STEPS", + value: 1, + unit: "steps", + measuredAt: new Date( + `${w.startDate < first ? first : w.startDate}T12:00:00Z`, + ), + externalId: `activity:${w.startDate}:steps`, + }, + ]; +} + +/** A clock that moves one second per read. */ +function clock() { + let t = T0; + return () => (t += 1000); +} + +beforeEach(() => { + h.stored = null; + h.completedAt = null; + h.sends = []; + h.windows = []; + h.behave = { activity: activityFrom("2026-06-01") }; +}); + +describe("runOuraBackfillForUser", () => { + it("stops on its budget, saves where each collection got to, and resumes there", async () => { + const first = await runOuraBackfillForUser("u1", () => false, 0, { + sliceMs: 10_000, + now: clock(), + }); + expect(first.continued).toBe(true); + expect(h.sends[0]!.payload).toMatchObject({ + kind: "oura-backfill", + data: { userId: "u1", slice: 1 }, + }); + const saved = h.stored as { + anchor: string; + collections: Record; + }; + expect(saved.anchor).toBe("2026-10-01"); + const lastWindow = h.windows.at(-1)!; + const [name, range] = lastWindow.split(":"); + expect(saved.collections[name!]!.before).toBe(range!.split("..")[0]); + + // The next slice opens with the window right before the saved edge. + h.windows = []; + await runOuraBackfillForUser("u1", () => false, 1, { + sliceMs: 10_000, + now: clock(), + }); + expect(h.windows[0]!.startsWith(`${name}:`)).toBe(true); + expect( + h.windows[0]!.endsWith(`..${saved.collections[name!]!.before}`), + ).toBe(true); + }); + + it("runs to the end and learns where history starts", async () => { + let slice = 0; + let continued = true; + while (continued) { + continued = ( + await runOuraBackfillForUser("u1", () => false, slice++, { + sliceMs: 60_000, + now: clock(), + }) + ).continued; + expect(slice).toBeLessThan(100); + } + expect(h.completedAt).not.toBeNull(); + const saved = h.stored as { + historyStart: string; + collections: Record; + }; + expect(saved.historyStart).toBe("2026-06-01"); + for (const name of OURA_BACKFILL_ORDER) { + expect(saved.collections[name]!.done).toBe(true); + } + // The non-probe collections stop a week before the history start. + expect(saved.collections.heartrate!.before).toBe("2026-05-25"); + expect( + h.windows + .filter((w) => w.startsWith("heartrate:")) + .every((w) => { + const start = w.split(":")[1]!.split("..")[0]!; + return start >= "2026-05-25"; + }), + ).toBe(true); + // No window read twice. + expect(new Set(h.windows).size).toBe(h.windows.length); + }); + + it("waits out the Retry-After a 429 named before the next slice", async () => { + h.behave.activity = async () => { + throw new OuraApiError({ + verb: "fetchDailyActivity", + classification: "transient", + httpStatus: 429, + reason: "http_429", + retryAfterMs: 900_000, + }); + }; + const run = await runOuraBackfillForUser("u1", () => false, 3, { + now: clock(), + }); + expect(run.continued).toBe(true); + expect(h.sends).toHaveLength(1); + expect(h.sends[0]!.startAfter).toBe(900); + expect(h.sends[0]!.payload).toMatchObject({ data: { slice: 4 } }); + }); + + it("marks a collection the grant refuses as denied, finishes, and asks again on the next chain", async () => { + h.behave.workout = async () => { + throw new OuraApiError({ + verb: "fetchWorkouts", + classification: "reauth_required", + httpStatus: 401, + reason: "scope_missing", + missingScope: "workout", + }); + }; + let slice = 0; + let continued = true; + while (continued) { + continued = ( + await runOuraBackfillForUser("u1", () => false, slice++, { + sliceMs: 60_000, + now: clock(), + }) + ).continued; + } + expect(h.completedAt).not.toBeNull(); + const saved = h.stored as { + collections: Record; + }; + expect(saved.collections.workout!.denied).toBe(true); + expect(h.windows.filter((w) => w.startsWith("workout:"))).toHaveLength(1); + + // A connect reopens it; the first slice asks for workouts again and + // nothing else. + h.completedAt = null; + h.windows = []; + delete h.behave.workout; + await runOuraBackfillForUser("u1", () => false, 0, { + sliceMs: 60_000, + now: clock(), + }); + expect(new Set(h.windows.map((w) => w.split(":")[0]))).toEqual( + new Set(["workout"]), + ); + expect(h.completedAt).not.toBeNull(); + }); + + it("goes on with the other collections past a failing one, and throws when nothing moved", async () => { + h.behave.spo2 = async () => { + throw new OuraApiError({ + verb: "fetchDailySpo2", + classification: "transient", + httpStatus: 502, + reason: "http_502", + }); + }; + const run = await runOuraBackfillForUser("u1", () => false, 0, { + sliceMs: 600_000, + now: clock(), + }); + // Everything but spo2 moved, so the chain goes on. + expect(run.continued).toBe(true); + const saved = h.stored as { + collections: Record; + }; + expect(saved.collections.heartrate!.done).toBe(true); + expect(saved.collections.spo2!.done).toBeUndefined(); + + // Now only spo2 is left, and it fails again: no progress, so it throws + // for the pg-boss retry instead of chaining forever. + await expect( + runOuraBackfillForUser("u1", () => false, 1, { + sliceMs: 600_000, + now: clock(), + }), + ).rejects.toThrow(/oura backfill incomplete/); + expect(h.completedAt).toBeNull(); + }); +}); From b04570349306985c819b5b439779ce7f9bba05c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 20:14:20 +0200 Subject: [PATCH 25/98] feat(apple-health): ingest the activity rings, mindfulness, State of Mind and AFib burden Six Apple Health signals leave the deferred list and ride POST /api/measurements/batch and the export.zip import end to end: EXERCISE_MINUTES, STAND_MINUTES and STAND_HOURS (the exercise and stand rings), MINDFUL_MINUTES, STATE_OF_MIND_VALENCE (iOS 18 State of Mind, -1..1) and AFIB_BURDEN (AFib History, percent of the week). The ring types and mindful minutes are cumulative, so the daily rollup is their sum; AFib burden is scaled strictly from Apple's fraction and a value above 1 is refused rather than guessed at. The archive import takes mindful minutes from the session's dates and skips idle stand hours. Every registry carries them: units, ranges, labels in all locales, categories, chart tokens, report catalogue (mindful minutes and the valence fenced as sensitive), signal registry with FHIR codings, and MCP exposure for the three ring types only. Workout GPS tracks are now an opt-in, off by default (PATCH /api/auth/me/workout-routes, a switch on Data & Privacy). While it is off, the workout batch route and the export import keep every workout and drop its track with the same per-entry status. With it on, the import reads the referenced GPX files from the archive one bounded member at a time, thins each to the point cap and stores it sealed. The climb a track carries is written to Workout.elevationM at write time, so the workout insight no longer opens the track, and the AI egress guard now freezes the set of files that read route geometry. --- docs/api/openapi.yaml | 158 ++++++++++- messages/de.json | 13 + messages/en.json | 13 + messages/es.json | 13 + messages/fr.json | 13 + messages/it.json | 13 + messages/ko.json | 13 + messages/nl.json | 13 + messages/pl.json | 13 + .../migration.sql | 21 ++ .../0432_store_workout_routes/migration.sql | 6 + prisma/schema.prisma | 16 ++ .../ai-egress-capability-guard.test.ts | 82 ++++++ ...doctor-report-control-gating-guard.test.ts | 4 +- .../me/workout-routes/__tests__/route.test.ts | 121 ++++++++ src/app/api/auth/me/workout-routes/route.ts | 109 +++++++ .../workouts/__tests__/batch-create.test.ts | 54 +++- src/app/api/workouts/batch/route.ts | 29 +- .../measurements/measurement-list-meta.ts | 15 + .../measurements/measurement-type-colors.ts | 9 + .../__tests__/report-selection-panel.test.tsx | 4 +- src/components/settings/privacy-section.tsx | 4 + .../settings/workout-routes-card.tsx | 83 ++++++ .../measurement-type-enum-coverage.test.ts | 10 +- .../__tests__/route-import.test.ts | 253 +++++++++++++++++ src/lib/apple-health/archive-stream.ts | 153 ++++++++-- src/lib/apple-health/route-import.ts | 266 ++++++++++++++++++ src/lib/data-wipe/wipe-plan.ts | 1 + src/lib/export/account-settings-backup.ts | 1 + src/lib/export/backup-plan.ts | 1 + src/lib/insights/chart-tokens.ts | 8 + src/lib/jobs/apple-health-import-worker.ts | 38 ++- src/lib/jobs/workout-insight-generate.ts | 5 - .../__tests__/apple-health-mapping.test.ts | 84 +++++- .../import-apple-health-export.test.ts | 66 ++++- src/lib/measurements/apple-health-mapping.ts | 96 ++++++- src/lib/measurements/categories.ts | 11 + src/lib/measurements/display-transform.ts | 5 + .../import-apple-health-export.ts | 71 +++++ src/lib/measurements/type-label-keys.ts | 7 + src/lib/measurements/unit-label.ts | 3 + src/lib/measurements/unit-map.ts | 10 + src/lib/measurements/value-domain.ts | 1 + src/lib/modules/measurement-scope.ts | 8 +- src/lib/openapi/routes/profile.ts | 61 ++++ src/lib/personal-records/pr-direction.ts | 11 + src/lib/query-keys/workouts.ts | 5 + .../__tests__/catalogue-guard.test.ts | 10 +- src/lib/report-selection/catalogue.ts | 16 ++ .../fixtures/expected-measurement-loinc.json | 36 +++ src/lib/signals/registry.ts | 136 +++++++++ src/lib/validations/backup.ts | 1 + src/lib/validations/measurement.ts | 20 ++ src/lib/validations/user-prefs.ts | 5 + .../__tests__/insight-evidence.test.ts | 72 +---- .../workouts/__tests__/route-climb.test.ts | 35 +++ src/lib/workouts/insight-evidence.ts | 34 +-- src/lib/workouts/route-climb.ts | 37 +++ 58 files changed, 2233 insertions(+), 163 deletions(-) create mode 100644 prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql create mode 100644 prisma/migrations/0432_store_workout_routes/migration.sql create mode 100644 src/app/api/auth/me/workout-routes/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/workout-routes/route.ts create mode 100644 src/components/settings/workout-routes-card.tsx create mode 100644 src/lib/apple-health/__tests__/route-import.test.ts create mode 100644 src/lib/apple-health/route-import.ts create mode 100644 src/lib/workouts/__tests__/route-climb.test.ts create mode 100644 src/lib/workouts/route-climb.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..b390c93b3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4315,6 +4315,12 @@ paths: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN - in: query name: from schema: @@ -9455,6 +9461,52 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/me/workout-routes: + get: + tags: + - Auth + summary: Read the workout-route opt-in + description: Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and + the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with + the route stored. No AI path ever reads a stored track. + responses: + "200": + description: The current flag. + content: + application/json: + schema: + $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Auth + summary: Set the workout-route opt-in + description: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off + stops new tracks; tracks already stored stay until their workout is deleted. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WorkoutRoutesPatchRequest" + responses: + "200": + description: The resolved next flag. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/auth/me/use-central-codex: get: tags: @@ -25948,7 +26000,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 101 type: array items: type: string @@ -26008,7 +26060,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 101 type: array items: type: string @@ -26843,6 +26895,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN value: type: number measuredAt: @@ -28524,6 +28582,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN biomarkerId: type: string minLength: 1 @@ -29023,7 +29087,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 101 type: array items: type: string @@ -29358,6 +29422,14 @@ components: - labsLocalOcrEnabled description: Turn browser-side OCR on or off for lab-report scanning. When on, a text-only provider can still read a paper report because the image is transcribed on the device and only the TEXT is sent. + WorkoutRoutesPatchRequest: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + description: Keep or stop keeping the GPS track of new workouts. Off by default. UseCentralCodexPatchRequest: type: object properties: @@ -30186,6 +30258,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -38851,6 +38929,8 @@ components: type: boolean labsLocalOcrEnabled: type: boolean + storeWorkoutRoutes: + type: boolean onboardingCompletedAt: anyOf: - type: string @@ -38961,6 +39041,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN value: type: number valueMin: @@ -39701,6 +39787,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN - type: "null" biomarkerName: anyOf: @@ -42176,6 +42268,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -43354,6 +43452,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN - type: "null" intervalDays: anyOf: @@ -43566,6 +43670,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN metricSlot: type: - string @@ -55934,6 +56044,48 @@ components: - data - error additionalProperties: false + GetWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WorkoutRoutesResponse: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + additionalProperties: false + PatchWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetUseCentralCodexEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index ba1ca12ef..6b34c7498 100644 --- a/messages/de.json +++ b/messages/de.json @@ -911,6 +911,12 @@ "unitRating": "Stufe", "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert", + "typeExerciseMinutes": "Trainingsminuten", + "typeStandMinutes": "Stehminuten", + "typeStandHours": "Stehstunden", + "typeMindfulMinutes": "Achtsamkeitsminuten", + "typeStateOfMindValence": "Gemütszustand", + "typeAfibBurden": "VHF-Last", "durationReadAs": "Gelesen als {duration}", "durationHoursMinutes": "{hours} Std. {minutes} Min.", "durationHoursOnly": "{hours} Std.", @@ -3565,6 +3571,7 @@ "falls": "Stürze", "years": "Jahre", "breathingEvents": "Ereignisse", + "standHours": "Stunden", "respiratoryRate": "Atemzüge/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Dein Profil, deine Anamnese und die Angaben für die Gesundheitsakte", "modules": "Einträge der Module: {list}" }, + "workoutRoutes": { + "title": "Trainingsrouten", + "description": "Den GPS-Verlauf neuer Trainings aus der iPhone-App und dem Apple-Health-Export speichern.", + "detail": "Standardmäßig aus. Eine Route zeigt, wo du wohnst, arbeitest und trainierst, deshalb wird sie erst gespeichert, wenn du das einschaltest. Die Trainings selbst werden immer gespeichert, und keine KI-Funktion liest jemals eine Route. Ausschalten stoppt neue Routen; schon gespeicherte bleiben, bis du das Training löschst.", + "loadError": "Die Routen-Einstellung konnte nicht geladen werden." + }, "retention": { "title": "Aufbewahrung", "description": "Wie lange zeitgebundene Datensätze vor der automatischen Bereinigung gespeichert bleiben.", diff --git a/messages/en.json b/messages/en.json index 3a6dfe846..60fe5c6be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -911,6 +911,12 @@ "unitRating": "rating", "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score", + "typeExerciseMinutes": "Exercise minutes", + "typeStandMinutes": "Standing minutes", + "typeStandHours": "Stand hours", + "typeMindfulMinutes": "Mindful minutes", + "typeStateOfMindValence": "State of mind", + "typeAfibBurden": "AFib burden", "durationReadAs": "Read as {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "falls", "years": "years", "breathingEvents": "events", + "standHours": "hours", "respiratoryRate": "breaths/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Your profile, medical history and health-record details", "modules": "Records of each module: {list}" }, + "workoutRoutes": { + "title": "Workout routes", + "description": "Keep the GPS track of new workouts from the iPhone app and the Apple Health export.", + "detail": "Off by default. A route shows where you live, work and train, so it is only kept once you turn this on. Workouts are kept either way, and no AI feature ever reads a route. Turning it off stops new routes; routes already kept stay until you delete the workout.", + "loadError": "The route setting could not be loaded." + }, "retention": { "title": "Retention", "description": "How long time-bound records are kept before automatic cleanup.", diff --git a/messages/es.json b/messages/es.json index 0196a1f7b..fcfada1b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -911,6 +911,12 @@ "unitRating": "nivel", "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI", + "typeExerciseMinutes": "Minutos de ejercicio", + "typeStandMinutes": "Minutos de pie", + "typeStandHours": "Horas de pie", + "typeMindfulMinutes": "Minutos de atención plena", + "typeStateOfMindValence": "Estado de ánimo", + "typeAfibBurden": "Carga de FA", "durationReadAs": "Se lee como {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "caídas", "years": "años", "breathingEvents": "eventos", + "standHours": "horas", "respiratoryRate": "respiraciones/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Tu perfil, tus antecedentes médicos y los datos del historial de salud", "modules": "Registros de cada módulo: {list}" }, + "workoutRoutes": { + "title": "Rutas de entrenamiento", + "description": "Guardar el recorrido GPS de los entrenamientos nuevos de la app de iPhone y de la exportación de Apple Salud.", + "detail": "Desactivado por defecto. Una ruta muestra dónde vives, trabajas y entrenas, así que solo se guarda cuando lo activas. Los entrenamientos se guardan siempre y ninguna función de IA lee nunca una ruta. Al desactivarlo dejan de guardarse rutas nuevas; las ya guardadas se quedan hasta que borres el entrenamiento.", + "loadError": "No se pudo cargar el ajuste de rutas." + }, "retention": { "title": "Retención", "description": "Cuánto tiempo se conservan los registros con límite temporal antes de su limpieza automática.", diff --git a/messages/fr.json b/messages/fr.json index d7109278c..843d5ebf6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -911,6 +911,12 @@ "unitRating": "niveau", "typeWho5Score": "Score WHO-5", "typeSciScore": "Score SCI", + "typeExerciseMinutes": "Minutes d’exercice", + "typeStandMinutes": "Minutes debout", + "typeStandHours": "Heures debout", + "typeMindfulMinutes": "Minutes de pleine conscience", + "typeStateOfMindValence": "État d’esprit", + "typeAfibBurden": "Charge de FA", "durationReadAs": "Compris comme {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "chutes", "years": "ans", "breathingEvents": "événements", + "standHours": "heures", "respiratoryRate": "respirations/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Votre profil, vos antécédents médicaux et les informations du dossier de santé", "modules": "Données de chaque module : {list}" }, + "workoutRoutes": { + "title": "Parcours d’entraînement", + "description": "Conserver le tracé GPS des nouveaux entraînements de l’app iPhone et de l’export Apple Santé.", + "detail": "Désactivé par défaut. Un parcours montre où vous habitez, travaillez et vous entraînez : il n’est donc conservé qu’une fois l’option activée. Les entraînements sont conservés dans tous les cas, et aucune fonction d’IA ne lit jamais un parcours. Désactiver l’option arrête les nouveaux parcours ; ceux déjà conservés restent jusqu’à la suppression de l’entraînement.", + "loadError": "Impossible de charger le réglage des parcours." + }, "retention": { "title": "Conservation", "description": "Durée de conservation des enregistrements limités dans le temps avant le nettoyage automatique.", diff --git a/messages/it.json b/messages/it.json index 91db78f4b..15d737a1c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -911,6 +911,12 @@ "unitRating": "livello", "typeWho5Score": "Punteggio WHO-5", "typeSciScore": "Punteggio SCI", + "typeExerciseMinutes": "Minuti di esercizio", + "typeStandMinutes": "Minuti in piedi", + "typeStandHours": "Ore in piedi", + "typeMindfulMinutes": "Minuti di consapevolezza", + "typeStateOfMindValence": "Stato d’animo", + "typeAfibBurden": "Carico di FA", "durationReadAs": "Letto come {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "cadute", "years": "anni", "breathingEvents": "eventi", + "standHours": "ore", "respiratoryRate": "respiri/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Il tuo profilo, l'anamnesi e i dati della cartella sanitaria", "modules": "Dati di ogni modulo: {list}" }, + "workoutRoutes": { + "title": "Percorsi degli allenamenti", + "description": "Conserva il tracciato GPS dei nuovi allenamenti dall’app per iPhone e dall’esportazione di Apple Salute.", + "detail": "Disattivato di default. Un percorso mostra dove vivi, lavori e ti alleni, quindi viene conservato solo dopo averlo attivato. Gli allenamenti vengono conservati comunque e nessuna funzione IA legge mai un percorso. Disattivandolo si fermano i nuovi percorsi; quelli già conservati restano finché non elimini l’allenamento.", + "loadError": "Impossibile caricare l’impostazione dei percorsi." + }, "retention": { "title": "Conservazione", "description": "Per quanto tempo i record a tempo vengono conservati prima della pulizia automatica.", diff --git a/messages/ko.json b/messages/ko.json index 0708547d5..f23096369 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -911,6 +911,12 @@ "unitRating": "단계", "typeWho5Score": "WHO-5 점수", "typeSciScore": "SCI 점수", + "typeExerciseMinutes": "운동 시간(분)", + "typeStandMinutes": "서 있기 시간(분)", + "typeStandHours": "일어서기 시간", + "typeMindfulMinutes": "마음 챙기기 시간(분)", + "typeStateOfMindValence": "마음 상태", + "typeAfibBurden": "심방세동 부담", "durationReadAs": "{duration}(으)로 읽었어요", "durationHoursMinutes": "{hours}시간 {minutes}분", "durationHoursOnly": "{hours}시간", @@ -3565,6 +3571,7 @@ "falls": "회", "years": "세", "breathingEvents": "회", + "standHours": "시간", "respiratoryRate": "회/분" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "프로필, 병력, 건강 기록 정보", "modules": "모듈별 기록: {list}" }, + "workoutRoutes": { + "title": "운동 경로", + "description": "iPhone 앱과 Apple 건강 내보내기에서 새 운동의 GPS 경로를 저장합니다.", + "detail": "기본값은 꺼짐입니다. 경로는 사는 곳, 일하는 곳, 운동하는 곳을 보여 주므로 이 설정을 켠 뒤에만 저장됩니다. 운동 기록은 항상 저장되며, 어떤 AI 기능도 경로를 읽지 않습니다. 끄면 새 경로 저장이 멈추고, 이미 저장된 경로는 운동을 삭제할 때까지 남습니다.", + "loadError": "경로 설정을 불러오지 못했습니다." + }, "retention": { "title": "보관 기간", "description": "자동 정리 전까지 기간이 정해진 기록을 얼마나 보관하는지예요.", diff --git a/messages/nl.json b/messages/nl.json index c5e9d9a13..3f6fabe35 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -911,6 +911,12 @@ "unitRating": "beoordeling", "typeWho5Score": "WHO-5-score", "typeSciScore": "SCI-score", + "typeExerciseMinutes": "Trainingsminuten", + "typeStandMinutes": "Staminuten", + "typeStandHours": "Stauren", + "typeMindfulMinutes": "Mindfulnessminuten", + "typeStateOfMindValence": "Gemoedstoestand", + "typeAfibBurden": "AF-belasting", "durationReadAs": "Gelezen als {duration}", "durationHoursMinutes": "{hours} u {minutes} min", "durationHoursOnly": "{hours} u", @@ -3565,6 +3571,7 @@ "falls": "valpartijen", "years": "jaar", "breathingEvents": "gebeurtenissen", + "standHours": "uur", "respiratoryRate": "ademhalingen/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Je profiel, medische voorgeschiedenis en dossiergegevens", "modules": "Gegevens van elke module: {list}" }, + "workoutRoutes": { + "title": "Trainingsroutes", + "description": "Het GPS-spoor van nieuwe trainingen uit de iPhone-app en de Apple Gezondheid-export bewaren.", + "detail": "Standaard uit. Een route laat zien waar je woont, werkt en traint, dus hij wordt pas bewaard als je dit aanzet. Trainingen worden altijd bewaard en geen enkele AI-functie leest ooit een route. Uitzetten stopt nieuwe routes; al bewaarde routes blijven tot je de training verwijdert.", + "loadError": "De route-instelling kon niet worden geladen." + }, "retention": { "title": "Bewaartermijn", "description": "Hoe lang tijdgebonden gegevens worden bewaard voordat ze automatisch worden opgeschoond.", diff --git a/messages/pl.json b/messages/pl.json index 6d55a5d6d..66982c68e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -911,6 +911,12 @@ "unitRating": "poziom", "typeWho5Score": "Wynik WHO-5", "typeSciScore": "Wynik SCI", + "typeExerciseMinutes": "Minuty ćwiczeń", + "typeStandMinutes": "Minuty stania", + "typeStandHours": "Godziny stania", + "typeMindfulMinutes": "Minuty uważności", + "typeStateOfMindValence": "Stan umysłu", + "typeAfibBurden": "Obciążenie AF", "durationReadAs": "Odczytano jako {duration}", "durationHoursMinutes": "{hours} godz. {minutes} min", "durationHoursOnly": "{hours} godz.", @@ -3565,6 +3571,7 @@ "falls": "upadki", "years": "lata", "breathingEvents": "zdarzenia", + "standHours": "godz.", "respiratoryRate": "oddechów/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Twój profil, wywiad medyczny i dane dokumentacji zdrowotnej", "modules": "Wpisy poszczególnych modułów: {list}" }, + "workoutRoutes": { + "title": "Trasy treningów", + "description": "Zapisuj ślad GPS nowych treningów z aplikacji na iPhone’a i z eksportu Apple Zdrowie.", + "detail": "Domyślnie wyłączone. Trasa pokazuje, gdzie mieszkasz, pracujesz i trenujesz, więc zapisujemy ją dopiero po włączeniu. Treningi są zapisywane zawsze, a żadna funkcja AI nigdy nie odczytuje trasy. Wyłączenie zatrzymuje nowe trasy; już zapisane zostają do usunięcia treningu.", + "loadError": "Nie udało się wczytać ustawienia tras." + }, "retention": { "title": "Retencja", "description": "Jak długo rekordy ograniczone czasowo są przechowywane przed automatycznym czyszczeniem.", diff --git a/prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql b/prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql new file mode 100644 index 000000000..d48e5661c --- /dev/null +++ b/prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql @@ -0,0 +1,21 @@ +-- Extend `measurement_type` with six Apple Health signals that sat in the +-- deferred list: the exercise and stand rings, mindful minutes, the State of +-- Mind valence, and AFib burden. +-- +-- EXERCISE_MINUTES <- HKQuantityTypeIdentifierAppleExerciseTime +-- STAND_MINUTES <- HKQuantityTypeIdentifierAppleStandTime +-- STAND_HOURS <- HKCategoryTypeIdentifierAppleStandHour +-- MINDFUL_MINUTES <- HKCategoryTypeIdentifierMindfulSession +-- STATE_OF_MIND_VALENCE <- HKDataTypeIdentifierStateOfMind (valence) +-- AFIB_BURDEN <- HKQuantityTypeIdentifierAtrialFibrillationBurden +-- +-- Purely additive: no existing row carries any of these values. IF NOT +-- EXISTS keeps a replay on a partially applied database harmless. Enum +-- values cannot be dropped in PostgreSQL, so there is no down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'EXERCISE_MINUTES'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_MINUTES'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_HOURS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MINDFUL_MINUTES'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STATE_OF_MIND_VALENCE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AFIB_BURDEN'; diff --git a/prisma/migrations/0432_store_workout_routes/migration.sql b/prisma/migrations/0432_store_workout_routes/migration.sql new file mode 100644 index 000000000..4ce5ea98f --- /dev/null +++ b/prisma/migrations/0432_store_workout_routes/migration.sql @@ -0,0 +1,6 @@ +-- A per-account opt-in for keeping workout GPS tracks. Off by default: a +-- route shows where a person lives, works and trains, so new tracks are +-- stored only once the person turns this on. Tracks already stored stay +-- where they are; the setting decides what new syncs and imports keep. + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "store_workout_routes" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 61e9e8270..82129aac4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -374,6 +374,12 @@ model User { // review screen remains the safety backstop. Default `false`; vision-capable // users keep the higher-accuracy native path and never pay the WASM download. labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled") + /// v1.44 — keep the GPS track of a workout. OFF by default: a route is + /// where a person lives, works and runs, so it is stored only after they + /// opt in. Read by the workout batch route and the export.zip import, which + /// drop the track while it is off (the workout itself is always kept). + /// Never read by any AI path; the AI egress guard freezes the route readers. + storeWorkoutRoutes Boolean @default(false) @map("store_workout_routes") // v1.7.0 unit pref — global metric/imperial display preference. // Canonical storage stays SI; this only selects the display-time // transform branch (km/h vs mph, km vs mi). Null = default "metric". @@ -1703,6 +1709,16 @@ enum MeasurementType { // Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both. WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment. SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment. + // ── v1.44 — Apple Health activity rings, mindfulness, mood and AFib burden ── + // All five arrive over POST /api/measurements/batch and the export.zip import. + // The minute and hour types are cumulative (daily SUM); AFib burden and the + // State of Mind valence are spot readings. + EXERCISE_MINUTES // minutes — HealthKit `appleExerciseTime`. The activity ring's exercise minutes; cumulative, daily SUM. + STAND_MINUTES // minutes — HealthKit `appleStandTime`. Minutes spent standing; cumulative, daily SUM. + STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours. + MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM. + STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement. + AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis. @@map("measurement_type") } diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index ed12342c5..140c21f96 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -288,3 +288,85 @@ describe("the status chokepoint's capability argument", () => { expect(withCapability.capability).toBe("statusText"); }); }); + +/** + * Workout GPS tracks never reach a model. + * + * A route shows where a person lives, works and trains, and it is stored only + * after an opt-in. So the set of files that can open or write a track is + * frozen here, with the reason each needs it, and none of them may be a + * provider call site or sit in an AI module. The workout insight used to open + * the track to derive metres climbed; that figure is now filled into + * `Workout.elevationM` at write time (`route-climb.ts`), and the insight reads + * the column. A new reader fails the first check until someone decides, here, + * that it is not an AI path. + * + * Limit: the matcher looks for the cipher helpers, the sealed column and a + * `geometry: true` select. A read under some other spelling (a raw SQL + * `SELECT geometry_encrypted`, a backup dumping the model generically) would + * slip it. The generic backup dump and restore do carry the sealed column; + * they are not AI paths and hand it to no model. + */ +describe("workout route geometry never reaches an AI path", () => { + const ROUTE_READER = + /\breadRouteGeometry\s*\(|\bencryptRouteGeometry\s*\(|\bgeometryEncrypted\b|\bgeometry\s*:\s*true\b/; + + const ROUTE_READERS: Record = { + "app/api/workouts/[id]/route.ts": + "The workout detail read: serves the track to the person's own map.", + "app/api/workouts/batch/route.ts": + "Writes a kept track (opt-in) and derives its climb before storage.", + "lib/apple-health/route-import.ts": + "Writes a kept track from the Apple Health export (opt-in).", + "lib/crypto/encrypted-columns.ts": + "The encrypted-column registry the key rotation walks.", + "lib/jobs/free-text-encryption-backfill.ts": + "Seals legacy readable tracks in place.", + "lib/workouts/route-geometry-cipher.ts": "The cipher itself.", + }; + + /** Directories whose code feeds a model. */ + const AI_AREAS = [ + "lib/ai/", + "lib/insights/", + "lib/mcp/", + "app/mcp/", + "app/api/insights/", + "app/api/coach/", + ]; + + const found = sourceFiles().filter((rel) => ROUTE_READER.test(code(rel))); + + it("finds route readers at all (an empty matcher is a failure, not a pass)", () => { + expect(found.length).toBeGreaterThanOrEqual(4); + }); + + it("every file that opens or writes a track is named, with a reason", () => { + expect(found.filter((rel) => !(rel in ROUTE_READERS))).toEqual([]); + }); + + it("the list carries no file that no longer touches a track", () => { + expect( + Object.keys(ROUTE_READERS).filter((rel) => !found.includes(rel)), + ).toEqual([]); + }); + + it("no route reader calls a provider or lives in an AI module", () => { + for (const rel of Object.keys(ROUTE_READERS)) { + expect(rel in EGRESS_SITES, `${rel} is an egress site`).toBe(false); + expect(EGRESS_CALL.test(code(rel)), `${rel} calls a provider`).toBe( + false, + ); + expect( + AI_AREAS.some((area) => rel.startsWith(area)), + `${rel} sits in an AI module`, + ).toBe(false); + } + }); + + it("the workout insight job does not read the track", () => { + const job = code("lib/jobs/workout-insight-generate.ts"); + expect(job).not.toMatch(/\broute\s*:/); + expect(job).not.toMatch(ROUTE_READER); + }); +}); diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 36a3a0765..1c06583ae 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,8 +74,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(95); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); + expect(ALL_LEAF_IDS.length).toBe(101); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(83); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18); }); diff --git a/src/app/api/auth/me/workout-routes/__tests__/route.test.ts b/src/app/api/auth/me/workout-routes/__tests__/route.test.ts new file mode 100644 index 000000000..dad36584d --- /dev/null +++ b/src/app/api/auth/me/workout-routes/__tests__/route.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The workout-route opt-in: off by default, a hard-set PATCH, audit-logged, + * and a malformed body refused before anything is written. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/workout-routes", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +}); + +describe("/api/auth/me/workout-routes", () => { + it("reads off when the row carries no value", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + const res = await GET( + new Request("http://localhost/api/auth/me/workout-routes") as never, + { params: Promise.resolve({}) } as never, + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + data: { storeWorkoutRoutes: false }, + }); + }); + + it("sets the flag field by field and audits the change", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + storeWorkoutRoutes: false, + } as never); + const res = await PATCH( + mkPatch({ storeWorkoutRoutes: true }) as never, + { + params: Promise.resolve({}), + } as never, + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + data: { storeWorkoutRoutes: true }, + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { storeWorkoutRoutes: true }, + }); + expect(auditLog).toHaveBeenCalledWith( + "user.storeWorkoutRoutes.update", + expect.objectContaining({ + details: { previous: false, next: true }, + }), + ); + }); + + it("refuses a malformed body with 422 and writes nothing", async () => { + const res = await PATCH( + mkPatch({ storeWorkoutRoutes: "yes" }) as never, + { + params: Promise.resolve({}), + } as never, + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses an unauthenticated caller", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await PATCH( + mkPatch({ storeWorkoutRoutes: true }) as never, + { + params: Promise.resolve({}), + } as never, + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/workout-routes/route.ts b/src/app/api/auth/me/workout-routes/route.ts new file mode 100644 index 000000000..aea7aba69 --- /dev/null +++ b/src/app/api/auth/me/workout-routes/route.ts @@ -0,0 +1,109 @@ +/** + * Per-account opt-in for keeping workout GPS tracks. + * + * GET /api/auth/me/workout-routes — current flag. + * PATCH /api/auth/me/workout-routes — body `{ storeWorkoutRoutes: boolean }`. + * + * Off by default. While it is off, `POST /api/workouts/batch` and the + * Apple Health export import keep every workout but drop its track, so a + * route (where a person lives, works and trains) is stored only after the + * person asked for it. Turning it off later stops new tracks; tracks already + * stored stay until the workout or the account is deleted. + * + * Same per-user-scalar pattern as `labs-local-ocr`: 60/min rate limit, a + * bounded body, Zod safeParse → 422, field-by-field write, audit row, and + * the resolved next state in the response. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { workoutRoutesPatchSchema } from "@/lib/validations/user-prefs"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +type WorkoutRoutesResponse = { + storeWorkoutRoutes: boolean; +}; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.workoutRoutes.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { storeWorkoutRoutes: true }, + }); + const payload: WorkoutRoutesResponse = { + storeWorkoutRoutes: row?.storeWorkoutRoutes ?? false, + }; + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `workout-routes:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = workoutRoutesPatchSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "auth.me.workoutRoutes.patch.invalid_shape" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = parsed.data.storeWorkoutRoutes; + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { storeWorkoutRoutes: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { storeWorkoutRoutes: next }, + }); + + await auditLog("user.storeWorkoutRoutes.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: previous?.storeWorkoutRoutes ?? false, + next, + }, + }); + + annotate({ + action: { name: "auth.me.workoutRoutes.patch" }, + meta: { storeWorkoutRoutes: next }, + }); + + const payload: WorkoutRoutesResponse = { storeWorkoutRoutes: next }; + return apiSuccess(payload); +}); diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 0a434f9a9..ecdc3b436 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -203,8 +203,11 @@ beforeEach(() => { // v1.4.43 — default to no per-user source-priority override so // the write-time picker walks the canonical default ladder. Tests // that exercise a custom ladder override this in-line. + // The route opt-in is on here so the route cases below exercise the + // write; the off case sets it explicitly. vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, + storeWorkoutRoutes: true, } as never); }); @@ -507,6 +510,55 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => { }); }); +describe("POST /api/workouts/batch — workout-route opt-in", () => { + const withRoute = () => + validWorkout("uuid-with-route", { + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + }); + + it("keeps the workout and drops the track while the opt-in is off", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + storeWorkoutRoutes: false, + } as never); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + mockInsertReturning([ + { id: "wkt-off", source: "APPLE_HEALTH", externalId: "uuid-with-route" }, + ]); + + const res = await POST(makeRequest({ workouts: [withRoute()] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + // Same per-entry status as a stored track: the client needs no branch. + expect(body.data.entries[0]?.status).toBe("inserted"); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); + + it("drops the track when the account row carries no opt-in at all", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + mockInsertReturning([ + { id: "wkt-none", source: "APPLE_HEALTH", externalId: "uuid-with-route" }, + ]); + + const res = await POST(makeRequest({ workouts: [withRoute()] })); + expect(res.status).toBe(200); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/workouts/batch — nested route attachment", () => { it("creates a WorkoutRoute row for a workout that ships a route", async () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); @@ -700,7 +752,7 @@ describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () => expect(sourcePriorityReads).toHaveLength(1); expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { id: "user-1" }, - select: { sourcePriorityJson: true }, + select: { sourcePriorityJson: true, storeWorkoutRoutes: true }, }); }); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 37ca6dfaf..90c88cb54 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -66,6 +66,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler"; +import { routeClimbM } from "@/lib/workouts/route-climb"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -259,10 +260,15 @@ async function postBatch(request: NextRequest): Promise { // time before the read-time picker ever ran. One indexed // findUnique per batch — well under the cost of letting the // duplicate persist and carrying it forward on every read. + // The same read carries the route opt-in: a GPS track is kept only when + // the person turned `storeWorkoutRoutes` on. While it is off the workout + // is stored and the track dropped, with the same per-entry status, so a + // client cannot tell the two apart and needs no new branch. const userPriority = await prisma.user.findUnique({ where: { id: user.id }, - select: { sourcePriorityJson: true }, + select: { sourcePriorityJson: true, storeWorkoutRoutes: true }, }); + const keepRoutes = userPriority?.storeWorkoutRoutes === true; // Content-Length pre-flight. The body cap exists to bound the // request-pipeline cost BEFORE Node has to read the full payload — @@ -440,12 +446,13 @@ async function postBatch(request: NextRequest): Promise { 0, Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000), ); - const route = w.route - ? { - geometry: w.route.geometry as unknown, - sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown, - } - : null; + const route = + w.route && keepRoutes + ? { + geometry: w.route.geometry as unknown, + sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown, + } + : null; const samples = w.samples ? { samples: w.samples as unknown, @@ -464,7 +471,9 @@ async function postBatch(request: NextRequest): Promise { maxHeartRate: w.maxHeartRate ?? null, minHeartRate: w.minHeartRate ?? null, stepCount: w.stepCount ?? null, - elevationM: w.elevationM ?? null, + // A kept track fills the climb when the source sent none, so the figure + // lives on the workout row and no reader has to open the track for it. + elevationM: w.elevationM ?? (route ? routeClimbM(route.geometry) : null), pauseDurationSec: w.pauseDurationSec ?? null, // `EXTERNAL` under a scoped credential, and nothing else: the guard // above refused any body that named a source, so nothing asserted is @@ -908,6 +917,10 @@ async function postBatch(request: NextRequest): Promise { enriched: enrichedCount, updated: updatedCount, skipped: skipped.length, + // Tracks the body carried but the opt-in told us not to keep. + routes_dropped: keepRoutes + ? 0 + : workouts.filter((w) => w.route != null).length, }, }); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index e190c189a..5be62c111 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -132,6 +132,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { PAIN_NRS: "measurements.typePainNrs", WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference", WAIST_TO_HEIGHT: "measurements.typeWaistToHeight", + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + STAND_HOURS: "measurements.typeStandHours", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence", + AFIB_BURDEN: "measurements.typeAfibBurden", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -267,6 +274,14 @@ export const MEASUREMENT_TYPE_ICONS: Record = { PAIN_NRS: Activity, WAIST_CIRCUMFERENCE: Scale, WAIST_TO_HEIGHT: Scale, + // ── v1.44 — the rings take the activity glyphs, mindful minutes the + // breath, the valence the score dial, AFib burden the rhythm trace. + EXERCISE_MINUTES: Dumbbell, + STAND_MINUTES: PersonStanding, + STAND_HOURS: PersonStanding, + MINDFUL_MINUTES: Wind, + STATE_OF_MIND_VALENCE: Gauge, + AFIB_BURDEN: HeartPulse, }; // The colour map lives in its own icon-free module so a surface that only diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index 5f3386b6c..76a7d6803 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -129,4 +129,13 @@ export const MEASUREMENT_TYPE_COLORS: Record = { PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + // ── v1.44 — the rings and mindful minutes join the activity family + // (chart-2), the valence the score family (chart-1), AFib burden the + // cardio family (chart-3). + EXERCISE_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAND_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAND_HOURS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + MINDFUL_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STATE_OF_MIND_VALENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + AFIB_BURDEN: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", }; diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 394ee3b19..b3c162515 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -194,8 +194,8 @@ describe(" — the first run", () => { expect(groupChip(html, "medications")).toBe("0/4"); // ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS. expect(groupChip(html, "history")).toBe("0/5"); - expect(groupChip(html, "cardio")).toBe("0/14"); - expect(groupChip(html, "activity")).toBe("0/8"); + expect(groupChip(html, "cardio")).toBe("0/15"); + expect(groupChip(html, "activity")).toBe("0/11"); expect(groupChip(html, "sleepRecovery")).toBe("0/16"); expect(groupChip(html, "mobility")).toBe("0/11"); expect(groupChip(html, "environment")).toBe("0/4"); diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 193017392..393543b91 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -32,6 +32,7 @@ import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; import { SecurityActivityCard } from "@/components/settings/security-activity-card"; import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; +import { WorkoutRoutesCard } from "@/components/settings/workout-routes-card"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; @@ -130,6 +131,9 @@ export function PrivacySection() { + {/* Workout GPS tracks: kept only on opt-in. */} + + {/* Retention */} ({ + queryKey: queryKeys.workoutRoutesPref(), + queryFn: () => apiGet("/api/auth/me/workout-routes"), + staleTime: 60_000, + }); + const update = useMutation({ + mutationFn: (storeWorkoutRoutes) => + apiPatch("/api/auth/me/workout-routes", { + storeWorkoutRoutes, + }), + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.workoutRoutesPref(), data); + }, + }); + + return ( + + + update.mutate(checked, { + onSuccess: () => + toastWrittenOutcome("success", t("settings.saved")), + onError: (failure) => + showErrorToast(t("settings.savingError"), { + cause: failure, + }), + }) + } + aria-label={t("settings.privacy.workoutRoutes.title")} + /> + } + /> + {pref.isError ? ( + void pref.refetch()} + message={t("settings.privacy.workoutRoutes.loadError")} + /> + ) : ( +

{t("settings.privacy.workoutRoutes.detail")}

+ )} + + ); +} diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 99a5dc9a2..5ef5738b7 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -112,10 +112,18 @@ const EXPECTED_TYPES = [ // both server-derived from a completed assessment, higher = better) ── "WHO5_SCORE", "SCI_SCORE", + // ── v1.44 — Apple Health activity rings, mindfulness, State of Mind and + // AFib burden ── + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "AFIB_BURDEN", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 75 canonical measurement types", () => { + it("exposes the 83 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); diff --git a/src/lib/apple-health/__tests__/route-import.test.ts b/src/lib/apple-health/__tests__/route-import.test.ts new file mode 100644 index 000000000..e24f33fe6 --- /dev/null +++ b/src/lib/apple-health/__tests__/route-import.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; + +import { strToU8, zipSync } from "fflate"; + +vi.mock("@/lib/workouts/route-geometry-cipher", () => ({ + encryptRouteGeometry: (geometry: unknown) => + Buffer.from(JSON.stringify(geometry)), +})); + +import { + importAppleHealthWorkoutRoutes, + parseGpxTrack, + thinIndices, + trackToRoute, +} from "../route-import"; +import { routeMemberNameFromReference } from "../archive-stream"; +import { MAX_ROUTE_POINTS } from "@/lib/validations/workout"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +function archive(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "healthlog-route-import-")); + dirs.push(dir); + const path = join(dir, "export.zip"); + writeFileSync( + path, + zipSync( + Object.fromEntries( + Object.entries(files).map(([name, body]) => [name, strToU8(body)]), + ), + ), + ); + return path; +} + +function gpx(points: Array<[number, number, number?, string?]>): string { + return ` + +Route +${points + .map( + ([lat, lon, ele, time]) => + `${ele === undefined ? "" : `${ele}`}${time ? `` : ""}`, + ) + .join("\n")} +`; +} + +function fakePrisma( + workout: { + id: string; + elevationM: number | null; + route: { id: string } | null; + } | null, +) { + return { + workout: { + findUnique: vi.fn(async () => workout), + update: vi.fn(async () => ({})), + }, + workoutRoute: { createMany: vi.fn(async () => ({ count: 1 })) }, + }; +} + +describe("routeMemberNameFromReference", () => { + it("maps Apple's reference path onto the archive member", () => { + expect(routeMemberNameFromReference("/workout-routes/route_1.gpx")).toBe( + "apple_health_export/workout-routes/route_1.gpx", + ); + }); + + it("refuses anything that is not a direct route file", () => { + expect( + routeMemberNameFromReference("/workout-routes/../export.xml"), + ).toBeNull(); + expect( + routeMemberNameFromReference("/electrocardiograms/a.csv"), + ).toBeNull(); + expect(routeMemberNameFromReference("/workout-routes/a/b.gpx")).toBeNull(); + }); +}); + +describe("parseGpxTrack + trackToRoute", () => { + it("keeps altitude and time when every point carries them", async () => { + const track = await parseGpxTrack( + Readable.from([ + gpx([ + [50.1, 8.1, 100, "2026-05-14T06:00:00Z"], + [50.2, 8.2, 130, "2026-05-14T06:00:05Z"], + ]), + ]), + ); + const route = trackToRoute(track); + expect(route?.geometry.coordinates).toEqual([ + [8.1, 50.1, 100], + [8.2, 50.2, 130], + ]); + expect(route?.sampleTimestamps).toEqual([ + { t: "2026-05-14T06:00:00.000Z" }, + { t: "2026-05-14T06:00:05.000Z" }, + ]); + }); + + it("drops altitude and times rather than half-filling them", async () => { + const route = trackToRoute( + await parseGpxTrack( + Readable.from([ + gpx([ + [50.1, 8.1, 100, "2026-05-14T06:00:00Z"], + [50.2, 8.2], + ]), + ]), + ), + ); + expect(route?.geometry.coordinates).toEqual([ + [8.1, 50.1], + [8.2, 50.2], + ]); + expect(route?.sampleTimestamps).toBeNull(); + }); + + it("skips out-of-range points and refuses a track shorter than two", async () => { + const track = await parseGpxTrack( + Readable.from([ + gpx([ + [95, 8.1], + [50.2, 8.2], + ]), + ]), + ); + expect(track.lat).toEqual([50.2]); + expect(trackToRoute(track)).toBeNull(); + }); + + it("rejects malformed XML", async () => { + await expect( + parseGpxTrack(Readable.from([" { + const idx = thinIndices(MAX_ROUTE_POINTS * 3 + 7, MAX_ROUTE_POINTS); + expect(idx).toHaveLength(MAX_ROUTE_POINTS); + expect(idx[0]).toBe(0); + expect(idx.at(-1)).toBe(MAX_ROUTE_POINTS * 3 + 6); + expect(thinIndices(5, MAX_ROUTE_POINTS)).toEqual([0, 1, 2, 3, 4]); + }); +}); + +describe("importAppleHealthWorkoutRoutes", () => { + const files = { + "apple_health_export/export.xml": "", + "apple_health_export/workout-routes/route_1.gpx": gpx([ + [50.1, 8.1, 100], + [50.2, 8.2, 140], + [50.3, 8.3, 120], + ]), + "apple_health_export/workout-routes/route_2.gpx": gpx([[50.1, 8.1]]), + }; + + it("attaches a referenced track sealed and fills the climb", async () => { + const prisma = fakePrisma({ id: "w1", elevationM: null, route: null }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-1", path: "/workout-routes/route_1.gpx" }, + ], + prisma: prisma as never, + }); + expect(counts).toEqual({ + discovered: 1, + attached: 1, + skipped: 0, + failed: 0, + }); + const data = ( + prisma.workoutRoute.createMany.mock.calls[0] as unknown as [ + { data: Array<{ workoutId: string; geometryEncrypted: Buffer }> }, + ] + )[0].data[0]; + expect(data.workoutId).toBe("w1"); + expect(JSON.parse(data.geometryEncrypted.toString())).toMatchObject({ + type: "LineString", + }); + expect(prisma.workout.update).toHaveBeenCalledWith({ + where: { id: "w1" }, + data: { elevationM: 40 }, + }); + }); + + it("reads only referenced members, and leaves a stored track alone", async () => { + const prisma = fakePrisma({ id: "w1", elevationM: 12, route: { id: "r" } }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-1", path: "/workout-routes/route_1.gpx" }, + ], + prisma: prisma as never, + }); + expect(counts).toEqual({ + discovered: 1, + attached: 0, + skipped: 1, + failed: 0, + }); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); + + it("counts a too-short track as failed", async () => { + const prisma = fakePrisma({ id: "w2", elevationM: null, route: null }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-2", path: "/workout-routes/route_2.gpx" }, + ], + prisma: prisma as never, + }); + expect(counts.failed).toBe(1); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); + + it("fails soft on a member over the byte cap", async () => { + const prisma = fakePrisma({ id: "w1", elevationM: null, route: null }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-1", path: "/workout-routes/route_1.gpx" }, + ], + prisma: prisma as never, + limits: { + maxMembers: 100, + maxRouteMembers: 10, + maxMemberBytes: 64, + maxTotalRouteBytes: 1024, + maxCompressionRatio: 200, + }, + }); + expect(counts.attached).toBe(0); + expect(counts.failed).toBe(1); + }); +}); diff --git a/src/lib/apple-health/archive-stream.ts b/src/lib/apple-health/archive-stream.ts index b5718a78a..7af968af6 100644 --- a/src/lib/apple-health/archive-stream.ts +++ b/src/lib/apple-health/archive-stream.ts @@ -1,5 +1,6 @@ /** - * Bounded ZIP reader for Apple Health ECG CSV members. + * Bounded ZIP reader for Apple Health's auxiliary members: the ECG CSVs and + * the workout-route GPX files. * * ZIP entry names are treated as untrusted identifiers only: no archive member * is ever materialised on disk. Central-directory metadata is checked before @@ -37,6 +38,18 @@ const CENTRAL_SIGNATURE = 0x02014b50; const LOCAL_SIGNATURE = 0x04034b50; const MAX_EOCD_SEARCH = 65_535 + 22; const ECG_PREFIX = "apple_health_export/electrocardiograms/"; +const ROUTE_PREFIX = "apple_health_export/workout-routes/"; + +/** + * One family of auxiliary members: which names belong to it, the label its + * errors carry, and its own count and total-byte ceilings. + */ +interface MemberKind { + label: string; + matches: (name: string) => boolean; + maxMembers: number; + maxTotalBytes: number; +} function archiveError(message: string): Error { return new Error(`Unsafe Apple Health archive: ${message}`); @@ -95,19 +108,41 @@ function isUnsafeName(name: string): boolean { return segments.some((segment) => segment === "." || segment === ".."); } -function isEcgCsv(name: string): boolean { +function isDirectChild(name: string, prefix: string, extension: string) { return ( - name.startsWith(ECG_PREFIX) && - name.length > ECG_PREFIX.length && - name.toLowerCase().endsWith(".csv") && - !name.slice(ECG_PREFIX.length).includes("/") + name.startsWith(prefix) && + name.length > prefix.length && + name.toLowerCase().endsWith(extension) && + !name.slice(prefix.length).includes("/") ); } +function isEcgCsv(name: string): boolean { + return isDirectChild(name, ECG_PREFIX, ".csv"); +} + +/** A workout route as the archive names it: `workout-routes/.gpx`. */ +export function isWorkoutRouteGpx(name: string): boolean { + return isDirectChild(name, ROUTE_PREFIX, ".gpx"); +} + +/** + * The archive member a `` inside a `` + * points at. Apple writes the path from the export root + * (`/workout-routes/route_….gpx`); the member carries the export folder in + * front. Null for anything that is not a direct route file. + */ +export function routeMemberNameFromReference(path: string): string | null { + const trimmed = path.startsWith("/") ? path.slice(1) : path; + const name = `apple_health_export/${trimmed}`; + return isWorkoutRouteGpx(name) ? name : null; +} + async function readCentralDirectory( handle: FileHandle, fileSize: number, limits: AppleHealthArchiveLimits, + kind: MemberKind, ): Promise { if (fileSize < 22) throw archiveError("missing ZIP directory"); @@ -148,7 +183,7 @@ async function readCentralDirectory( const entries: ZipEntry[] = []; const names = new Set(); let cursor = centralOffset; - let ecgCount = 0; + let kindCount = 0; for (let index = 0; index < totalEntries; index += 1) { const header = await readExactly(handle, 46, cursor); @@ -177,23 +212,23 @@ async function readCentralDirectory( if (names.has(name)) throw archiveError("duplicate member name"); names.add(name); - if (isEcgCsv(name)) { - ecgCount += 1; - if (ecgCount > limits.maxEcgMembers) { - throw archiveError("ECG member count limit exceeded"); + if (kind.matches(name)) { + kindCount += 1; + if (kindCount > kind.maxMembers) { + throw archiveError(`${kind.label} member count limit exceeded`); } if ((flags & 0x1) !== 0) { - throw archiveError("encrypted ECG member is unsupported"); + throw archiveError(`encrypted ${kind.label} member is unsupported`); } if (method !== 0 && method !== 8) { - throw archiveError("unsupported ECG compression method"); + throw archiveError(`unsupported ${kind.label} compression method`); } if (uncompressedSize > limits.maxMemberBytes) { - throw archiveError("ECG member byte limit exceeded"); + throw archiveError(`${kind.label} member byte limit exceeded`); } const ratio = uncompressedSize / Math.max(1, compressedSize); if (ratio > limits.maxCompressionRatio) { - throw archiveError("ECG compression ratio limit exceeded"); + throw archiveError(`${kind.label} compression ratio limit exceeded`); } } @@ -218,6 +253,7 @@ async function decompressEntry( entry: ZipEntry, fileSize: number, limits: AppleHealthArchiveLimits, + kind: MemberKind, total: { bytes: number }, ): Promise { const local = await readExactly(handle, 30, entry.localHeaderOffset); @@ -260,16 +296,16 @@ async function decompressEntry( const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); memberBytes += chunk.byteLength; if (memberBytes > limits.maxMemberBytes) { - throw archiveError("actual ECG member byte limit exceeded"); + throw archiveError(`actual ${kind.label} member byte limit exceeded`); } - if (total.bytes + memberBytes > limits.maxTotalEcgBytes) { - throw archiveError("total ECG byte limit exceeded"); + if (total.bytes + memberBytes > kind.maxTotalBytes) { + throw archiveError(`total ${kind.label} byte limit exceeded`); } chunks.push(chunk); } } catch { for (const chunk of chunks) chunk.fill(0); - throw archiveError("ECG decompression or byte limit failure"); + throw archiveError(`${kind.label} decompression or byte limit failure`); } finally { compressed.fill(0); } @@ -279,31 +315,35 @@ async function decompressEntry( } /** - * Yield only Apple Health's ECG CSV members as in-memory bounded streams. - * The generator validates the complete central directory before yielding any - * health data, preventing partial processing of a structurally unsafe archive. + * Yield the members of one kind as in-memory bounded streams, one at a time. + * The complete central directory is validated before any health data is + * yielded, so a structurally unsafe archive is never partly processed. + * `wanted` narrows further (a route the export's XML actually references). */ -export async function* streamAppleHealthEcgMembers(input: { - archivePath: string; - limits: AppleHealthArchiveLimits; -}): AsyncGenerator { - assertPositiveLimits(input.limits); - const handle = await open(input.archivePath, "r"); +async function* streamMembers( + archivePath: string, + limits: AppleHealthArchiveLimits, + kind: MemberKind, + wanted: (name: string) => boolean = () => true, +): AsyncGenerator { + assertPositiveLimits(limits); + const handle = await open(archivePath, "r"); try { const stat = await handle.stat(); const fileSize = Number(stat.size); if (!Number.isSafeInteger(fileSize)) { throw archiveError("archive is too large to address safely"); } - const entries = await readCentralDirectory(handle, fileSize, input.limits); + const entries = await readCentralDirectory(handle, fileSize, limits, kind); const total = { bytes: 0 }; for (const entry of entries) { - if (!isEcgCsv(entry.name)) continue; + if (!kind.matches(entry.name) || !wanted(entry.name)) continue; const chunks = await decompressEntry( handle, entry, fileSize, - input.limits, + limits, + kind, total, ); yield { name: entry.name, stream: Readable.from(chunks) }; @@ -312,3 +352,54 @@ export async function* streamAppleHealthEcgMembers(input: { await handle.close(); } } + +/** Yield only Apple Health's ECG CSV members as in-memory bounded streams. */ +export async function* streamAppleHealthEcgMembers(input: { + archivePath: string; + limits: AppleHealthArchiveLimits; +}): AsyncGenerator { + yield* streamMembers(input.archivePath, input.limits, { + label: "ECG", + matches: isEcgCsv, + maxMembers: input.limits.maxEcgMembers, + maxTotalBytes: input.limits.maxTotalEcgBytes, + }); +} + +/** Ceilings for the route members; the shared ones keep their meaning. */ +export interface AppleHealthRouteArchiveLimits { + maxMembers: number; + maxRouteMembers: number; + maxMemberBytes: number; + maxTotalRouteBytes: number; + maxCompressionRatio: number; +} + +/** + * Yield the workout-route GPX members `wanted` names, one bounded buffer at + * a time: the caller parses and drops each before the next is inflated. + */ +export async function* streamAppleHealthRouteMembers(input: { + archivePath: string; + limits: AppleHealthRouteArchiveLimits; + wanted: (name: string) => boolean; +}): AsyncGenerator { + const { limits } = input; + yield* streamMembers( + input.archivePath, + { + maxMembers: limits.maxMembers, + maxEcgMembers: limits.maxRouteMembers, + maxMemberBytes: limits.maxMemberBytes, + maxTotalEcgBytes: limits.maxTotalRouteBytes, + maxCompressionRatio: limits.maxCompressionRatio, + }, + { + label: "route", + matches: isWorkoutRouteGpx, + maxMembers: limits.maxRouteMembers, + maxTotalBytes: limits.maxTotalRouteBytes, + }, + input.wanted, + ); +} diff --git a/src/lib/apple-health/route-import.ts b/src/lib/apple-health/route-import.ts new file mode 100644 index 000000000..c392d2dc0 --- /dev/null +++ b/src/lib/apple-health/route-import.ts @@ -0,0 +1,266 @@ +/** + * Workout GPS tracks from an Apple Health export, kept only on opt-in. + * + * `export.xml` names each workout's track as a `` to a GPX + * file under `workout-routes/`. The XML pass hands those references here + * (only when the account turned `storeWorkoutRoutes` on); this module then + * reads the referenced members one at a time, parses each GPX as a stream, + * thins it evenly to `MAX_ROUTE_POINTS`, and stores it sealed against the + * workout the XML pass wrote. + * + * Memory: one member is held at a time, inflated into a buffer the archive + * reader caps (`maxMemberBytes`), and its points are kept as flat number + * arrays until they are thinned. Nothing about the track goes into the job + * result, the logs or a wide event: only counts. + * + * A failure here never fails the import. The workouts and every measurement + * are already committed; a track that cannot be read is counted and skipped. + */ +import type { Readable } from "node:stream"; + +import sax from "sax"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { + routeMemberNameFromReference, + streamAppleHealthRouteMembers, + type AppleHealthRouteArchiveLimits, +} from "@/lib/apple-health/archive-stream"; +import { MAX_ROUTE_POINTS } from "@/lib/validations/workout"; +import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; +import { routeClimbM } from "@/lib/workouts/route-climb"; + +/** A workout the XML pass wrote, and the track file it referenced. */ +export interface WorkoutRouteReference { + /** The workout's `externalId` under `source = APPLE_HEALTH`. */ + externalId: string; + /** The `` as Apple wrote it. */ + path: string; +} + +export interface WorkoutRouteImportCounts { + /** Tracks the XML referenced that are present in the archive. */ + discovered: number; + /** Tracks stored against their workout. */ + attached: number; + /** Workout already had a track, or its row is gone. */ + skipped: number; + /** Unreadable, too short, or over a limit. */ + failed: number; +} + +/** Per-archive ceilings for the route members. */ +export const APPLE_HEALTH_ROUTE_ARCHIVE_LIMITS: AppleHealthRouteArchiveLimits = + { + maxMembers: 100_000, + maxRouteMembers: 20_000, + // A three-hour ride at 1 Hz is a few MB of GPX; 32 MB covers a 24-hour + // ultra with room to spare and bounds the one buffer held at a time. + maxMemberBytes: 32 * 1024 * 1024, + maxTotalRouteBytes: 4 * 1024 * 1024 * 1024, + maxCompressionRatio: 200, + }; + +/** The points of one track, flat: lon, lat, elevation (NaN when absent), time ms (NaN when absent). */ +interface ParsedTrack { + lon: number[]; + lat: number[]; + ele: number[]; + time: number[]; +} + +/** Stream a GPX document into flat point arrays. Rejects malformed XML. */ +export async function parseGpxTrack(stream: Readable): Promise { + const track: ParsedTrack = { lon: [], lat: [], ele: [], time: [] }; + const parser = sax.parser(true, { trim: true }); + let inPoint = false; + let field: "ele" | "time" | null = null; + let ele = Number.NaN; + let time = Number.NaN; + let lat = Number.NaN; + let lon = Number.NaN; + let failure: Error | null = null; + + parser.onerror = (err) => { + failure = err instanceof Error ? err : new Error(String(err)); + }; + parser.onopentag = (node) => { + const name = node.name.toLowerCase(); + if (name === "trkpt") { + const attrs = node.attributes as Record; + lat = Number.parseFloat(attrs.lat ?? ""); + lon = Number.parseFloat(attrs.lon ?? ""); + ele = Number.NaN; + time = Number.NaN; + inPoint = true; + } else if (inPoint && (name === "ele" || name === "time")) { + field = name; + } + }; + parser.ontext = (text) => { + if (!inPoint || field === null) return; + if (field === "ele") ele = Number.parseFloat(text); + else time = Date.parse(text); + }; + parser.onclosetag = (tagName) => { + const name = tagName.toLowerCase(); + if (name === "ele" || name === "time") field = null; + if (name !== "trkpt") return; + inPoint = false; + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + lat < -90 || + lat > 90 || + lon < -180 || + lon > 180 + ) { + return; + } + track.lon.push(lon); + track.lat.push(lat); + track.ele.push(Number.isFinite(ele) ? ele : Number.NaN); + track.time.push(Number.isFinite(time) ? time : Number.NaN); + }; + + for await (const chunk of stream) { + parser.write(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk); + if (failure) throw failure; + } + parser.close(); + if (failure) throw failure; + return track; +} + +/** + * Indices that thin `count` points evenly to at most `max`, always keeping + * the first and the last. + */ +export function thinIndices(count: number, max: number): number[] { + if (count <= max) return Array.from({ length: count }, (_, i) => i); + const out: number[] = []; + const step = (count - 1) / (max - 1); + for (let i = 0; i < max; i += 1) out.push(Math.round(i * step)); + return out; +} + +/** + * The stored shapes for a parsed track: a GeoJSON LineString (altitude only + * when every kept point has one) and, when every kept point is timed, the + * parallel sample list. Null for a track with fewer than two points. + */ +export function trackToRoute(track: ParsedTrack): { + geometry: { type: "LineString"; coordinates: number[][] }; + sampleTimestamps: Array<{ t: string }> | null; +} | null { + const indices = thinIndices(track.lon.length, MAX_ROUTE_POINTS); + if (indices.length < 2) return null; + const withEle = indices.every((i) => Number.isFinite(track.ele[i])); + const withTime = indices.every((i) => Number.isFinite(track.time[i])); + return { + geometry: { + type: "LineString", + coordinates: indices.map((i) => + withEle + ? [track.lon[i], track.lat[i], track.ele[i]] + : [track.lon[i], track.lat[i]], + ), + }, + sampleTimestamps: withTime + ? indices.map((i) => ({ t: new Date(track.time[i]).toISOString() })) + : null, + }; +} + +/** + * Store the referenced tracks. `references` comes from the XML pass; each + * names a workout the import wrote under `source = APPLE_HEALTH`. + */ +export async function importAppleHealthWorkoutRoutes(input: { + archivePath: string; + userId: string; + references: readonly WorkoutRouteReference[]; + prisma: Pick; + limits?: AppleHealthRouteArchiveLimits; +}): Promise { + const counts: WorkoutRouteImportCounts = { + discovered: 0, + attached: 0, + skipped: 0, + failed: 0, + }; + const byMember = new Map(); + for (const ref of input.references) { + const member = routeMemberNameFromReference(ref.path); + if (member) byMember.set(member, ref.externalId); + } + if (byMember.size === 0) return counts; + + try { + for await (const member of streamAppleHealthRouteMembers({ + archivePath: input.archivePath, + limits: input.limits ?? APPLE_HEALTH_ROUTE_ARCHIVE_LIMITS, + wanted: (name) => byMember.has(name), + })) { + counts.discovered += 1; + const externalId = byMember.get(member.name); + if (!externalId) continue; + try { + const route = trackToRoute(await parseGpxTrack(member.stream)); + if (!route) { + counts.failed += 1; + continue; + } + const workout = await input.prisma.workout.findUnique({ + where: { + userId_source_externalId: { + userId: input.userId, + source: "APPLE_HEALTH", + externalId, + }, + }, + select: { + id: true, + elevationM: true, + route: { select: { id: true } }, + }, + }); + if (!workout || workout.route) { + counts.skipped += 1; + continue; + } + await input.prisma.workoutRoute.createMany({ + data: [ + { + workoutId: workout.id, + geometryEncrypted: encryptRouteGeometry(route.geometry), + sampleTimestamps: route.sampleTimestamps ?? undefined, + }, + ], + skipDuplicates: true, + }); + // The climb lives on the workout row, so no reader opens the track + // for it (the same write-time derivation as the batch route). + if (workout.elevationM === null) { + const climb = routeClimbM(route.geometry); + if (climb !== null) { + await input.prisma.workout.update({ + where: { id: workout.id }, + data: { elevationM: climb }, + }); + } + } + counts.attached += 1; + } catch { + counts.failed += 1; + } + } + } catch { + // The archive itself is unreadable for this pass (a ZIP64 export, a + // limit hit in the directory). Every referenced track not yet handled + // counts as failed; the import stays done. + const handled = counts.attached + counts.skipped + counts.failed; + counts.failed += Math.max(0, byMember.size - handled); + } + return counts; +} diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 93347dfcc..da5cdb6ac 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -427,6 +427,7 @@ export const USER_RESET = { coachLastSeenAt: null, documentsAutoAiRead: false, labsLocalOcrEnabled: false, + storeWorkoutRoutes: false, codexAccessTokenEncrypted: null, codexRefreshTokenEncrypted: null, codexTokenExpiresAt: null, diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 2585a2c11..1ac7b794b 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -527,6 +527,7 @@ const ACCOUNT_SETTING_CODECS: Readonly< coachPrefsJson: json("coachPrefsJson"), documentsAutoAiRead: plain("documentsAutoAiRead"), labsLocalOcrEnabled: plain("labsLocalOcrEnabled"), + storeWorkoutRoutes: plain("storeWorkoutRoutes"), onboardingCompletedAt: instant("onboardingCompletedAt"), onboardingTourCompleted: plain("onboardingTourCompleted"), onboardingTourProgressJson: json("onboardingTourProgressJson"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 1d517711b..c86898c1a 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -1038,6 +1038,7 @@ export const USER_COLUMN_BACKUP_CLASS = { coachPrefsJson: "SETTING", documentsAutoAiRead: "SETTING", labsLocalOcrEnabled: "SETTING", + storeWorkoutRoutes: "SETTING", // Where the person is in the app's introductions. Without these a restored // account is sent through setup and the tour again, and asked to accept the // disclaimer it already accepted (a newer disclaimer still asks, because the diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 56c0c470c..94df12acc 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -137,6 +137,14 @@ export const ALLOWED_CHART_TOKENS = [ // continuous-series contract, higher = better. "metric:WHO5_SCORE", "metric:SCI_SCORE", + // v1.44 — Apple Health activity rings, mindfulness, State of Mind and + // AFib burden. Charted through the generic series renderer. + "metric:EXERCISE_MINUTES", + "metric:STAND_MINUTES", + "metric:STAND_HOURS", + "metric:MINDFUL_MINUTES", + "metric:STATE_OF_MIND_VALENCE", + "metric:AFIB_BURDEN", "metric:GRIP_STRENGTH", "metric:PAIN_NRS", "metric:WAIST_CIRCUMFERENCE", diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index f90e09300..1023cc0f8 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -34,6 +34,11 @@ import { streamAppleHealthEcgMembers } from "@/lib/apple-health/archive-stream"; import { parseAppleHealthEcgCsv } from "@/lib/apple-health/ecg-csv"; import { importAppleHealthEcg } from "@/lib/apple-health/ecg-import"; import { extractExportXml } from "@/lib/import/unzip-export-xml"; +import { annotate } from "@/lib/logging/context"; +import { + importAppleHealthWorkoutRoutes, + type WorkoutRouteReference, +} from "@/lib/apple-health/route-import"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; @@ -428,8 +433,11 @@ export async function handleAppleHealthImport( // user has no preference set. const userRow = await prisma.user.findUnique({ where: { id: userId }, - select: { timezone: true }, + select: { timezone: true, storeWorkoutRoutes: true }, }); + // GPS tracks are read from the archive only when the person keeps them. + const keepRoutes = userRow?.storeWorkoutRoutes === true; + const routeReferences: WorkoutRouteReference[] = []; const userTimezone = userRow?.timezone && userRow.timezone.length > 0 ? userRow.timezone @@ -471,6 +479,9 @@ export async function handleAppleHealthImport( userId, userTimezone, prisma, + ...(keepRoutes + ? { onWorkoutRoute: (ref) => void routeReferences.push(ref) } + : {}), onProgress: async (snapshot) => { // The phase label here is what the polling endpoint surfaces; // map "parsing" / "upserting" through verbatim. @@ -536,6 +547,31 @@ export async function handleAppleHealthImport( ecg.failed += Math.max(0, ecg.discovered - completed); } + // Workout GPS tracks, on opt-in only. Fail-soft like the ECGs: the + // workouts are committed, a track that cannot be read is only counted. + // Read one member at a time from the archive; nothing about a track + // reaches the result but the count. + if (routeReferences.length > 0) { + const routes = await importAppleHealthWorkoutRoutes({ + archivePath: uploadPath, + userId, + references: routeReferences, + prisma, + }); + result.workouts.routesAttached = routes.attached; + annotate({ + action: { name: "apple_health.import.workout_routes" }, + meta: { + referenced: routeReferences.length, + discovered: routes.discovered, + attached: routes.attached, + skipped: routes.skipped, + failed: routes.failed, + }, + }); + routeReferences.length = 0; + } + // Done. Persist the terminal envelope. await prisma.importJob.update({ where: { id: importJobId }, diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index 839d38eef..699a68aa7 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -66,7 +66,6 @@ import { meetsDurationFloor, workoutInsightInputHash, } from "@/lib/workouts/insight-gates"; -import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { computeZones, @@ -272,7 +271,6 @@ export async function runWorkoutInsightGenerate( minHeartRate: true, elevationM: true, metadata: true, - route: { select: { geometry: true, geometryEncrypted: true } }, samples: { select: { samples: true } }, }, }); @@ -397,9 +395,6 @@ export async function runWorkoutInsightGenerate( tz, hrSeries, zones, - // Opened in memory only to derive metres climbed; the track itself - // never reaches the prompt. - routeGeometry: row.route ? readRouteGeometry(row.route) : null, history, }); const inputHash = workoutInsightInputHash( diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index fbdab574f..86afc5d32 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -140,6 +140,12 @@ describe("APPLE_HEALTH_TYPE_MAP", () => { "HKCategoryTypeIdentifierLowHeartRateEvent", "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", "HKCategoryTypeIdentifierSleepApneaEvent", + // v1.44 — mindfulness and the State of Mind are mental-health + // self-care and self-report; AFib burden is the AFib History + // feature the person enabled on the watch. + "HKCategoryTypeIdentifierMindfulSession", + "HKDataTypeIdentifierStateOfMind", + "HKQuantityTypeIdentifierAtrialFibrillationBurden", ].sort(), ); // Audio exposure + time-in-daylight are environment / lifestyle @@ -209,8 +215,8 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { // skipped-but-known rather than unknown-and-dropped. it("covers the iOS-17 + iOS-18 long-tail identifiers", () => { const expectedLongTail = [ - // Cardiovascular / clinical - "HKQuantityTypeIdentifierAtrialFibrillationBurden", + // Cardiovascular / clinical (AFib burden moved into the mapped set in + // v1.44 as AFIB_BURDEN). "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility — `AppleWalkingSteadiness` moved into the mapped set in // v1.4.30. In v1.10.0 both remaining identifiers moved in too: @@ -772,11 +778,85 @@ describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { "HKQuantityTypeIdentifierTimeInDaylight", // v1.10.0 — hard-fall detections accumulate across the day. "HKQuantityTypeIdentifierNumberOfTimesFallen", + // v1.44 — the exercise and stand rings and mindful minutes. + "HKQuantityTypeIdentifierAppleExerciseTime", + "HKQuantityTypeIdentifierAppleStandTime", + "HKCategoryTypeIdentifierAppleStandHour", + "HKCategoryTypeIdentifierMindfulSession", ].sort(), ); }); }); +describe("v1.44 — activity rings, mindfulness, State of Mind, AFib burden", () => { + const sample = (hkIdentifier: string, value: number, unit: string) => + mapAppleHealthEntry({ + hkIdentifier, + value, + unit, + startDate: "2026-10-20T08:00:00.000Z", + endDate: "2026-10-20T08:30:00.000Z", + }); + + it("maps every identifier and removes it from the deferred set", () => { + const expected: Record = { + HKQuantityTypeIdentifierAppleExerciseTime: "EXERCISE_MINUTES", + HKQuantityTypeIdentifierAppleStandTime: "STAND_MINUTES", + HKCategoryTypeIdentifierAppleStandHour: "STAND_HOURS", + HKCategoryTypeIdentifierMindfulSession: "MINDFUL_MINUTES", + HKDataTypeIdentifierStateOfMind: "STATE_OF_MIND_VALENCE", + HKQuantityTypeIdentifierAtrialFibrillationBurden: "AFIB_BURDEN", + }; + for (const [hk, type] of Object.entries(expected)) { + expect(APPLE_HEALTH_TYPE_MAP[hk]?.measurementType).toBe(type); + expect(HK_QUANTITY_TYPE_DEFERRED.has(hk)).toBe(false); + expect(hkIdentifierForType(type)).toBe(hk); + } + }); + + it("passes the minute and hour values through", () => { + expect( + sample("HKQuantityTypeIdentifierAppleExerciseTime", 32, "min"), + ).toMatchObject({ type: "EXERCISE_MINUTES", value: 32, unit: "minutes" }); + expect( + sample("HKCategoryTypeIdentifierAppleStandHour", 1, "count"), + ).toMatchObject({ type: "STAND_HOURS", value: 1, unit: "hours" }); + expect( + sample("HKCategoryTypeIdentifierMindfulSession", 10, "min"), + ).toMatchObject({ type: "MINDFUL_MINUTES", value: 10, unit: "minutes" }); + }); + + it("stores the State of Mind valence as is", () => { + expect( + sample("HKDataTypeIdentifierStateOfMind", -0.4, "valence"), + ).toMatchObject({ type: "STATE_OF_MIND_VALENCE", value: -0.4 }); + }); + + it("scales AFib burden strictly, so a small burden stays small", () => { + // 0.5 % arrives as the fraction 0.005. A client that sends 2 for 2 % + // is not guessed at: it lands at 200 and the range guard refuses it. + expect( + sample("HKQuantityTypeIdentifierAtrialFibrillationBurden", 0.005, "%") + ?.value, + ).toBeCloseTo(0.5, 10); + expect( + sample("HKQuantityTypeIdentifierAtrialFibrillationBurden", 2, "%")?.value, + ).toBe(200); + expect(validateMeasurementRange("AFIB_BURDEN", 200)).not.toBeNull(); + expect(validateMeasurementRange("AFIB_BURDEN", 0.5)).toBeNull(); + }); + + it("bounds every new type", () => { + expect( + validateMeasurementRange("STATE_OF_MIND_VALENCE", 1.5), + ).not.toBeNull(); + expect(validateMeasurementRange("STATE_OF_MIND_VALENCE", -1)).toBeNull(); + expect(validateMeasurementRange("STAND_HOURS", 25)).not.toBeNull(); + expect(validateMeasurementRange("EXERCISE_MINUTES", 1441)).not.toBeNull(); + expect(validateMeasurementRange("MINDFUL_MINUTES", 20)).toBeNull(); + }); +}); + describe("HIGH_FREQUENCY_MEAN_TYPES (v1.7.0)", () => { it("is strictly disjoint from CUMULATIVE_HK_TYPES", () => { for (const type of HIGH_FREQUENCY_MEAN_TYPES) { diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 1fc88cdc7..5c994f5f2 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -637,9 +637,18 @@ describe("hashSampleKey", () => { expect(a).not.toBe(b); }); }); -const cumulativeMappings = Object.values(APPLE_HEALTH_TYPE_MAP).filter( +const allCumulativeMappings = Object.values(APPLE_HEALTH_TYPE_MAP).filter( (mapping) => CUMULATIVE_HK_TYPES.has(mapping.measurementType), ); +/** + * The cumulative quantities, whose `` carries its reading in + * `value`. The two cumulative category types (stand hours, mindful + * sessions) read theirs from a symbolic value or the dates and have their + * own cases below. + */ +const cumulativeMappings = allCumulativeMappings.filter((mapping) => + mapping.hkIdentifier.startsWith("HKQuantityTypeIdentifier"), +); function cumulativeExportXml( hkIdentifier: string, @@ -678,11 +687,60 @@ async function importCumulativeFixture(xml: string) { } describe("streamParseExportXml — cumulative source-day estimates", () => { - it("covers exactly the six cumulative HealthKit measurement types", () => { - expect(CUMULATIVE_HK_TYPES.size).toBe(6); + it("covers exactly the ten cumulative HealthKit measurement types", () => { + expect(CUMULATIVE_HK_TYPES.size).toBe(10); expect( - new Set(cumulativeMappings.map((mapping) => mapping.measurementType)), + new Set(allCumulativeMappings.map((mapping) => mapping.measurementType)), ).toEqual(CUMULATIVE_HK_TYPES); + expect(allCumulativeMappings.length - cumulativeMappings.length).toBe(2); + }); + + it("counts stood hours and never stores an idle one", async () => { + const hour = ( + h: number, + value: string, + ) => ` `; + const { prisma, result } = + await importCumulativeFixture(` + +${hour(8, "HKCategoryValueAppleStandHourStood")} +${hour(9, "HKCategoryValueAppleStandHourIdle")} +${hour(10, "HKCategoryValueAppleStandHourStood")} +${hour(11, "HKCategoryValueAppleStandHourStood")} +`); + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0]).toMatchObject({ + type: "STAND_HOURS", + value: 3, + unit: "hours", + }); + expect( + result.deferred["HKCategoryTypeIdentifierAppleStandHour::idle"], + ).toBe(1); + expect( + result.unknown["HKCategoryTypeIdentifierAppleStandHour::unparseable"], + ).toBeUndefined(); + }); + + it("takes mindful minutes from the session's own dates", async () => { + const { prisma } = await importCumulativeFixture(` + + + +`); + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0]).toMatchObject({ + type: "MINDFUL_MINUTES", + value: 25, + unit: "minutes", + }); }); it("stores 8,526 rather than source-blind 15,608 for overlapping iPhone and Zepp steps", async () => { diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 60824d9c8..6e8c9cf78 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -245,6 +245,12 @@ const UNIT_FIXED_AUDIO_SPL = "Sound-pressure level: Apple emits `dBASPL` only, and a decibel is logarithmic — a sibling unit would need its own formula, never a factor."; const UNIT_FIXED_COMPOSITE_VO2 = "Compound rate Apple emits verbatim as `mL/(kg*min)`; the Health app exposes no other spelling and the compound has no sibling unit to convert from."; +const UNIT_FIXED_STAND_HOUR = + "Stand hours are a tally of hour samples: the importer maps `Stood` to 1 and drops `Idle`, and a daily row carries the count, so no record unit can rescale it."; +const UNIT_FIXED_VALENCE = + "State of Mind valence is a dimensionless −1..1 self-report; HealthKit has no unit for it and nothing to convert from."; +const UNIT_FIXED_AFIB_FRACTION = + "Apple writes the percent unit but the value rides as a 0..1 fraction, scaled ×100 in `convertToDbUnit`; branching on the record unit would double-scale it."; const UNIT_FIXED_SLEEP_CATEGORY = "Sleep samples carry a symbolic category value, not a quantity: the importer derives the duration in minutes from the sample's own dates, so the record's unit attribute (empty in Apple's archive) says nothing about the reading."; @@ -479,6 +485,83 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { aggregation: "sum", }, + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + // The exercise and stand rings. Apple ships both as minute quantities; + // cumulative, so the daily SUM is the ring value (same posture as + // daylight minutes). + HKQuantityTypeIdentifierAppleExerciseTime: { + hkIdentifier: "HKQuantityTypeIdentifierAppleExerciseTime", + measurementType: "EXERCISE_MINUTES", + hkUnit: "min", + dbUnit: "minutes", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + HKQuantityTypeIdentifierAppleStandTime: { + hkIdentifier: "HKQuantityTypeIdentifierAppleStandTime", + measurementType: "STAND_MINUTES", + hkUnit: "min", + dbUnit: "minutes", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + // The stand ring's hours. A category sample per hour, `stood` (0) or + // `idle` (1); the wire `value` is the number of stood hours the row + // stands for: 1 for one stood-hour sample, the day's count on a + // `stats:` daily row. The archive importer turns `Stood` into 1 and + // skips `Idle`, so an idle hour is never a row. + HKCategoryTypeIdentifierAppleStandHour: { + hkIdentifier: "HKCategoryTypeIdentifierAppleStandHour", + measurementType: "STAND_HOURS", + hkUnit: "count", + unitFixedReason: UNIT_FIXED_STAND_HOUR, + dbUnit: "hours", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + // Mindful sessions. A category sample with no quantity: the value is the + // session length in minutes, from the sample's own dates (the archive + // importer derives it; the batch client sends it), the sleep contract. + HKCategoryTypeIdentifierMindfulSession: { + hkIdentifier: "HKCategoryTypeIdentifierMindfulSession", + measurementType: "MINDFUL_MINUTES", + hkUnit: "min", + dbUnit: "minutes", + convertToDbUnit: (v) => v, + aggregation: "sum", + isPrivacySensitive: true, + }, + // State of Mind (iOS 18+). The valence of a logged emotion or daily mood, + // −1 (very unpleasant) to 1 (very pleasant), stored as is. Labels and + // associations stay on the device. + HKDataTypeIdentifierStateOfMind: { + hkIdentifier: "HKDataTypeIdentifierStateOfMind", + measurementType: "STATE_OF_MIND_VALENCE", + hkUnit: "valence", + unitFixedReason: UNIT_FIXED_VALENCE, + dbUnit: "valence", + convertToDbUnit: (v) => v, + aggregation: "mean", + isPrivacySensitive: true, + }, + // AFib burden (iOS 16+, AFib History): the weekly share of time the watch + // estimated in AFib. Apple ships a 0..1 fraction and the wire is pinned to + // it: scaled strictly ×100. `percentFromFraction` repairs shipped clients + // that pre-scale SpO2 and body fat; no client pre-scales this type, and a + // small burden is exactly where a pre-scaled value cannot be told from a + // fraction, so a value above 1 is refused by the range guard (it lands + // above 100) rather than guessed at. + HKQuantityTypeIdentifierAtrialFibrillationBurden: { + hkIdentifier: "HKQuantityTypeIdentifierAtrialFibrillationBurden", + measurementType: "AFIB_BURDEN", + hkUnit: "%", + unitFixedReason: UNIT_FIXED_AFIB_FRACTION, + dbUnit: "%", + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + isPrivacySensitive: true, + }, + // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ──────────────── // Walking steadiness — iOS 15+ Mobility daily rollup. Apple ships // a 0..1 fraction; HealthLog stores 0..100 percent (same convention @@ -818,6 +901,12 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = // daily total is the meaningful reduction (SUM), matching the other // cumulative HK counts. "FALL_COUNT", + // v1.44 — the exercise and stand rings and mindful minutes are day + // totals of partial-day increments, like daylight minutes. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", ]); /** @@ -1095,9 +1184,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // v1.10.0 — `HeartRateRecoveryOneMinute` + `AppleSleepingWristTemperature` // moved into the mapping table (CARDIO_RECOVERY + WRIST_TEMPERATURE). "HKQuantityTypeIdentifierBasalEnergyBurned", - "HKQuantityTypeIdentifierAppleExerciseTime", // implied by Workout rows - "HKQuantityTypeIdentifierAppleStandTime", // implied by Workout rows - "HKCategoryTypeIdentifierAppleStandHour", // implied by Workout rows // Running / walking form — v1.5+ // v1.5.5 — `WalkingStepLength` + `WalkingSpeed` moved into the // mapping table (raw SI on the wire — metres and m/s respectively). @@ -1152,9 +1238,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierDietarySugar", "HKQuantityTypeIdentifierDietaryFiber", "HKQuantityTypeIdentifierDietarySodium", - // Mental-state / mindfulness — v1.5 (route to existing mood model) - "HKCategoryTypeIdentifierMindfulSession", - "HKDataTypeIdentifierStateOfMind", // Pregnancy bleeding — stays deferred (pregnancy-mode is a later // release). v1.15.0 promoted the cycle-tracking reproductive types // (MenstrualFlow / IntermenstrualBleeding / CervicalMucusQuality / @@ -1183,7 +1266,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // Cardiovascular / clinical (iOS 16+) — no HealthLog counterpart yet; // clinical decision-support territory, defer behind a stricter user // opt-in than the existing Health-share prompt. - "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the // mapping table in v1.4.30 (R-F T1.5). In v1.10.0 both remaining diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 924d06d82..0bff7c895 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -219,6 +219,17 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // Waist circumference + waist-to-height are anthropometric → body cluster. ["WAIST_CIRCUMFERENCE", "body"], ["WAIST_TO_HEIGHT", "body"], + // ── v1.44 — Apple Health activity rings, mindfulness, mood, AFib ── + // The exercise and stand rings, and mindful minutes (time given to a + // practice, the same day-total shape), sit with the activity cluster. + ["EXERCISE_MINUTES", "activity"], + ["STAND_MINUTES", "activity"], + ["STAND_HOURS", "activity"], + ["MINDFUL_MINUTES", "activity"], + // The State of Mind valence is a self-report, like the WHO-5 total. + ["STATE_OF_MIND_VALENCE", "scores"], + // AFib burden joins the rhythm notifications on the cardiovascular surface. + ["AFIB_BURDEN", "cardiovascular"], ]); /** diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index ff5c371b9..03a4382cd 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -333,6 +333,11 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet = new Set([ // grain for these day-cumulative counts. "ACTIVE_ENERGY_BURNED", "TIME_IN_DAYLIGHT", + // v1.44 — the minute rings, mindful minutes and stood hours. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", ]); /** diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 1e3c3bab2..d91f34fac 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -227,6 +227,8 @@ interface PreparedWorkout { externalId: string; externalSourceVersion: string | null; metadata: Prisma.JsonValue | null; + /** The `` of the workout's GPS track, when it has one. */ + routePath?: string; } type CumulativeSourceSubtotals = Map; @@ -331,13 +333,49 @@ export function hashCumulativeSourceIdentity( * Returns `null` for unparseable values so the caller can skip the * row rather than poisoning the batch. */ +/** Apple's symbolic `HKCategoryValueAppleStandHour` values in `export.xml`. */ +const STAND_HOUR_STOOD = "HKCategoryValueAppleStandHourStood"; +const STAND_HOUR_IDLE = "HKCategoryValueAppleStandHourIdle"; + +/** An idle stand hour: skipped on purpose, never a row and never "unparseable". */ +export function isIdleStandHour( + hkIdentifier: string, + rawValue: string | undefined, +): boolean { + return ( + hkIdentifier === "HKCategoryTypeIdentifierAppleStandHour" && + rawValue === STAND_HOUR_IDLE + ); +} + +function minutesBetween(startDate: string, endDate: string): number | null { + const start = new Date(startDate); + const end = new Date(endDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + return Math.max(0, (end.getTime() - start.getTime()) / 60_000); +} + export function parseRecordValue( hkIdentifier: string, rawValue: string | undefined, startDate: string, endDate: string, ): { value: number; sleepStage?: number } | null { + // Mindful sessions carry no quantity (Apple writes no `value`, or the + // not-applicable sentinel): the reading is the session length, minutes + // from the record's own dates, the same contract as sleep. + if (hkIdentifier === "HKCategoryTypeIdentifierMindfulSession") { + const minutes = minutesBetween(startDate, endDate); + return minutes === null ? null : { value: minutes }; + } if (rawValue === undefined) return null; + // A stood hour is one; an idle hour is dropped by the caller before it + // gets here (`isIdleStandHour`), so it never becomes a row. + if (hkIdentifier === "HKCategoryTypeIdentifierAppleStandHour") { + return rawValue === STAND_HOUR_STOOD ? { value: 1 } : null; + } if (hkIdentifier === "HKCategoryTypeIdentifierSleepAnalysis") { const stage = SLEEP_STAGE_NAME_TO_CODEPOINT[rawValue]; if (stage === undefined) return null; @@ -410,6 +448,14 @@ export interface StreamParseInput { * `WORKOUT_FLUSH_BATCH`. */ workoutBatchSize?: number; + /** + * Called once per workout whose `` references a GPX file, + * with the workout's `externalId` and the path Apple wrote. Only passed + * when the account keeps workout routes (`storeWorkoutRoutes`); without it + * the reference is not even read. The track is attached after the XML + * pass, from the archive (`src/lib/apple-health/route-import.ts`). + */ + onWorkoutRoute?: (ref: { externalId: string; path: string }) => void; } /** @@ -432,6 +478,7 @@ export async function streamParseExportXml( prisma, onProgress, onExportDate, + onWorkoutRoute, spotBatchSize = SPOT_FLUSH_BATCH, workoutBatchSize = WORKOUT_FLUSH_BATCH, } = input; @@ -1210,6 +1257,13 @@ export async function streamParseExportXml( return; } + if (isIdleStandHour(hkType, attrs.value)) { + // An hour without standing is the absence of a stood hour. Counted + // so the import summary still shows the archive carried it. + deferred[`${hkType}::idle`] = (deferred[`${hkType}::idle`] ?? 0) + 1; + return; + } + const parsedValue = parseRecordValue( hkType, attrs.value, @@ -1449,6 +1503,17 @@ export async function streamParseExportXml( return; } + if ( + name === "FileReference" && + onWorkoutRoute && + currentWorkout && + typeof attrs.path === "string" && + attrs.path.toLowerCase().endsWith(".gpx") + ) { + currentWorkout.routePath = attrs.path; + return; + } + if ( name === "Me" || name === "ActivitySummary" || @@ -1486,6 +1551,12 @@ export async function streamParseExportXml( } } if (tagName === "Workout" && currentWorkout) { + if (currentWorkout.routePath && onWorkoutRoute) { + onWorkoutRoute({ + externalId: currentWorkout.externalId, + path: currentWorkout.routePath, + }); + } workoutBatch.push(currentWorkout); currentWorkout = null; } diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index 32da24148..46a61d0a4 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -103,6 +103,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { // ── v1.27.9 — screening scores ── WHO5_SCORE: "measurements.typeWho5Score", SCI_SCORE: "measurements.typeSciScore", + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + STAND_HOURS: "measurements.typeStandHours", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence", + AFIB_BURDEN: "measurements.typeAfibBurden", GRIP_STRENGTH: "measurements.typeGripStrength", PAIN_NRS: "measurements.typePainNrs", WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference", diff --git a/src/lib/measurements/unit-label.ts b/src/lib/measurements/unit-label.ts index c6373967a..f1f6fcfa2 100644 --- a/src/lib/measurements/unit-label.ts +++ b/src/lib/measurements/unit-label.ts @@ -25,6 +25,7 @@ const TYPE_UNIT_LABEL_KEYS: Readonly> = { FALL_COUNT: "insights.units.falls", BREATHING_DISTURBANCES: "insights.units.breathingEvents", RESPIRATORY_RATE: "insights.units.respiratoryRate", + STAND_HOURS: "insights.units.standHours", }; /** Word-shaped tokens that read the same whatever the type. */ @@ -52,6 +53,8 @@ const UNITLESS_UNIT_TOKENS: ReadonlySet = new Set([ "event", "level", "ratio", + // State of Mind valence: a bare −1..1 number. + "valence", "unknown", ]); diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index 307a7173d..71761e35c 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -156,6 +156,16 @@ const unitMap: Record = { PAIN_NRS: "score", WAIST_CIRCUMFERENCE: "cm", WAIST_TO_HEIGHT: "ratio", + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + EXERCISE_MINUTES: "minutes", + STAND_MINUTES: "minutes", + // Stood hours, a whole-number tally of hours. + STAND_HOURS: "hours", + MINDFUL_MINUTES: "minutes", + // HealthKit's dimensionless −1..1 valence. + STATE_OF_MIND_VALENCE: "valence", + // Percent of the week after the ×100 scaling. + AFIB_BURDEN: "%", }; export function getUnitForType(type: string): string { diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts index 0f071e6f9..1ae492854 100644 --- a/src/lib/measurements/value-domain.ts +++ b/src/lib/measurements/value-domain.ts @@ -48,6 +48,7 @@ export const INTEGER_VALUED_MEASUREMENT_TYPES: ReadonlySet = new Set([ "ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "FALL_COUNT", + "STAND_HOURS", "BREATHING_DISTURBANCES", "SLEEP_DISTURBANCE_COUNT", // Category events — the row carries a fixed `value = 1` (one fired event). diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index 2ff632964..7975be709 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -122,7 +122,8 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> = * - Core clinical figures every account carries: weight, pulse, blood * pressure, BMI, body temperature. * - Body composition: fat / lean / muscle / bone / water / visceral. - * - Activity volume: steps, distance, flights, active energy. Owned by no + * - Activity volume: steps, distance, flights, active energy, and the + * exercise and stand rings. Owned by no * module — `workouts` gates workout SESSIONS, not ambient movement. * - Gait and mobility, incl. the fall / walk-test / stair metrics. * - Cardio-vascular readings with no module home: SpO2, respiratory rate, @@ -162,6 +163,11 @@ export const UNSCOPED_REVIEWED_TYPES: ReadonlySet = "FLIGHTS_CLIMBED", "ACTIVE_ENERGY_BURNED", "ENERGY_EXPENDITURE_KJ", + // v1.44 — the exercise and stand rings are ambient movement like steps, + // not workout sessions. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", // Gait / mobility. "WALKING_SPEED", "WALKING_STEADINESS", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index b2a953afa..a510c4fad 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -33,6 +33,7 @@ import { documentsAutoAiReadPatchSchema, injectionSitePrefsPatchSchema, labsLocalOcrPatchSchema, + workoutRoutesPatchSchema, unitPreferencePatchSchema, glucoseUnitPatchSchema, useCentralCodexPatchSchema, @@ -1000,6 +1001,16 @@ const labsLocalOcrResponse = z .object({ labsLocalOcrEnabled: z.boolean() }) .meta({ id: "LabsLocalOcrResponse" }); +const workoutRoutesPatchRequest = workoutRoutesPatchSchema.meta({ + id: "WorkoutRoutesPatchRequest", + description: + "Keep or stop keeping the GPS track of new workouts. Off by default.", +}); + +const workoutRoutesResponse = z + .object({ storeWorkoutRoutes: z.boolean() }) + .meta({ id: "WorkoutRoutesResponse" }); + const useCentralCodexPatchRequest = useCentralCodexPatchSchema.meta({ id: "UseCentralCodexPatchRequest", description: @@ -2254,6 +2265,56 @@ export const profilePaths: NonNullable = { }, }, }, + "/api/auth/me/workout-routes": { + get: { + tags: ["Auth"], + summary: "Read the workout-route opt-in", + description: + "Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with the route stored. No AI path ever reads a stored track.", + responses: { + "200": { + description: "The current flag.", + content: { + "application/json": { + schema: dataEnvelope( + workoutRoutesResponse, + "GetWorkoutRoutesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Set the workout-route opt-in", + description: + "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off stops new tracks; tracks already stored stay until their workout is deleted.", + requestBody: { + required: true, + content: { "application/json": { schema: workoutRoutesPatchRequest } }, + }, + responses: { + "200": { + description: "The resolved next flag.", + content: { + "application/json": { + schema: dataEnvelope( + workoutRoutesResponse, + "PatchWorkoutRoutesEnvelope", + ), + }, + }, + }, + "413": { + description: "Request body exceeds 1024 bytes. Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/use-central-codex": { get: { tags: ["Auth"], diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index ed0340fc5..dc8ecf4ad 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -103,6 +103,12 @@ export function getPRDirection( case "BONE_MASS": case "MUSCLE_MASS": case "TIME_IN_DAYLIGHT": + // v1.44 — the activity rings: more exercise, standing and mindful + // minutes (and more stood hours) are the goal, as Apple frames them. + case "EXERCISE_MINUTES": + case "STAND_MINUTES": + case "STAND_HOURS": + case "MINDFUL_MINUTES": // v1.4.30 — walking steadiness on a 0-100 scale; Apple's own // Mobility section treats higher as the achievement (the // recovery direction from a low-steadiness window). @@ -206,6 +212,11 @@ export function getPRDirection( // "personal record" for a health-notification event would be both // meaningless and alarming, so they explicitly have no PR direction. case "IRREGULAR_RHYTHM_NOTIFICATION": + // v1.44 — AFib burden is an awareness signal from a certified feature, + // and a State of Mind valence is a self-report of how a moment felt. + // Neither is a goal axis a record could celebrate. + case "AFIB_BURDEN": + case "STATE_OF_MIND_VALENCE": case "HIGH_HEART_RATE_EVENT": case "LOW_HEART_RATE_EVENT": case "WALKING_STEADINESS_EVENT": diff --git a/src/lib/query-keys/workouts.ts b/src/lib/query-keys/workouts.ts index de709a028..3bffcdfbc 100644 --- a/src/lib/query-keys/workouts.ts +++ b/src/lib/query-keys/workouts.ts @@ -22,4 +22,9 @@ export const workoutKeys = { sportType?: string; }) => ["workouts", "recent", opts] as const, workoutDetail: (id: string) => ["workouts", id] as const, + /** + * v1.44 — the workout-route opt-in. Its own root, so invalidating the + * workout caches after a sync never refetches the setting. + */ + workoutRoutesPref: () => ["workout-routes-pref"] as const, }; diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 6b270286d..4e3ba4439 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -53,9 +53,9 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { - expect(MEASUREMENT_LEAF_IDS).toHaveLength(77); + expect(MEASUREMENT_LEAF_IDS).toHaveLength(83); expect(STRUCTURED_LEAF_IDS).toHaveLength(18); - expect(ALL_LEAF_IDS).toHaveLength(95); + expect(ALL_LEAF_IDS).toHaveLength(101); }); it("resolves a label for every leaf", () => { @@ -145,13 +145,17 @@ describe("template purity", () => { expect(unknown).toEqual([]); }); - it("fences exactly the eight leaves the design names", () => { + it("fences exactly the ten leaves the design names", () => { expect([...SENSITIVE_LEAF_IDS].sort()).toEqual( [ "ANAMNESIS", "CYCLE", "FAMILY_HISTORY", "GAD7_SCORE", + // v1.44 — mindful minutes and the State of Mind valence are + // mental-health self-care and self-report. + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", "MOOD", "PHQ9_SCORE", "SCI_SCORE", diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index 8d622dd9f..f53f1287a 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -119,6 +119,7 @@ export const MEASUREMENT_LEAF_GROUP: Record = { IRREGULAR_RHYTHM_NOTIFICATION: "cardio", HIGH_HEART_RATE_EVENT: "cardio", LOW_HEART_RATE_EVENT: "cardio", + AFIB_BURDEN: "cardio", // ── body ── WEIGHT: "body", @@ -161,6 +162,9 @@ export const MEASUREMENT_LEAF_GROUP: Record = { STRAIN_SCORE: "activity", DAY_STRAIN: "activity", WORKOUT_STRAIN: "activity", + EXERCISE_MINUTES: "activity", + STAND_MINUTES: "activity", + STAND_HOURS: "activity", // ── mobility & gait ── WALKING_SPEED: "mobility", @@ -185,6 +189,11 @@ export const MEASUREMENT_LEAF_GROUP: Record = { BLOOD_GLUCOSE: "glucose", // ── fenced ── + // Mindful minutes and the State of Mind valence are mental-health + // self-care and self-report: fenced like the screening totals, so they + // reach a report only when the person picks them. + MINDFUL_MINUTES: "sensitive", + STATE_OF_MIND_VALENCE: "sensitive", PHQ9_SCORE: "sensitive", GAD7_SCORE: "sensitive", WHO5_SCORE: "sensitive", @@ -264,6 +273,9 @@ export const LEAF_MODULE: Partial> = { // Activity / movement series and the training-load scores. ACTIVITY_STEPS: "workouts", ACTIVE_ENERGY_BURNED: "workouts", + EXERCISE_MINUTES: "workouts", + STAND_MINUTES: "workouts", + STAND_HOURS: "workouts", ENERGY_EXPENDITURE_KJ: "workouts", FLIGHTS_CLIMBED: "workouts", WALKING_RUNNING_DISTANCE: "workouts", @@ -309,6 +321,10 @@ export const LEAF_MODULE: Partial> = { GAD7_SCORE: "mentalHealth", WHO5_SCORE: "mentalHealth", SCI_SCORE: "mentalHealth", + // The State of Mind valence and mindful minutes ride the mood module: a + // person who turned mood tracking off does not see them on a report. + STATE_OF_MIND_VALENCE: "mood", + MINDFUL_MINUTES: "mood", // Structured sections with an owning module. LAB_RESULTS: "labs", MOOD: "mood", diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index ef14ef361..905fa945d 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -262,5 +262,41 @@ "display": "Sleep Condition Indicator total score", "unit": "{score}", "category": "survey" + }, + "EXERCISE_MINUTES": { + "loinc": "HKQuantityTypeIdentifierAppleExerciseTime", + "display": "Exercise time", + "unit": "min", + "category": "activity" + }, + "STAND_MINUTES": { + "loinc": "HKQuantityTypeIdentifierAppleStandTime", + "display": "Stand time", + "unit": "min", + "category": "activity" + }, + "STAND_HOURS": { + "loinc": "HKCategoryTypeIdentifierAppleStandHour", + "display": "Stand hours", + "unit": "h", + "category": "activity" + }, + "MINDFUL_MINUTES": { + "loinc": "HKCategoryTypeIdentifierMindfulSession", + "display": "Mindful session", + "unit": "min", + "category": "activity" + }, + "STATE_OF_MIND_VALENCE": { + "loinc": null, + "display": "State of mind valence", + "unit": "1", + "category": "survey" + }, + "AFIB_BURDEN": { + "loinc": "HKQuantityTypeIdentifierAtrialFibrillationBurden", + "display": "Atrial fibrillation burden", + "unit": "%", + "category": "vital-signs" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 2b2c8ad4f..d889362a8 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -1778,6 +1778,142 @@ export const SIGNALS: Record = { mcp: false, }, }, + // ── v1.44 — Apple Health activity rings, mindfulness, State of Mind, AFib ── + // The three ring metrics are read through MCP like steps and daylight. The + // mindful minutes, the State of Mind valence and AFib burden stay off every + // AI-facing surface (Coach, MCP, correlations): mental-health self-reports + // and an AFib awareness signal are shared only where the person asks. + EXERCISE_MINUTES: { + key: "EXERCISE_MINUTES", + kind: "measurement", + source: { measurementType: "EXERCISE_MINUTES" }, + displayName: "Exercise minutes", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + // Apple's exercise ring default and the WHO weekly 150 minutes / 7. + normalRange: { low: 22, high: 60 }, + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleExerciseTime", + display: "Exercise time", + unit: "min", + category: "activity", + }, + }, + STAND_MINUTES: { + key: "STAND_MINUTES", + kind: "measurement", + source: { measurementType: "STAND_MINUTES" }, + displayName: "Standing minutes", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleStandTime", + display: "Stand time", + unit: "min", + category: "activity", + }, + }, + STAND_HOURS: { + key: "STAND_HOURS", + kind: "measurement", + source: { measurementType: "STAND_HOURS" }, + displayName: "Stand hours", + unit: "h/day", + direction: "higher-better", + archetype: "activity-fitness", + // Apple's stand-ring goal is 12 hours. + normalRange: { low: 12, high: 24 }, + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + fhir: { + loinc: "HKCategoryTypeIdentifierAppleStandHour", + display: "Stand hours", + unit: "h", + category: "activity", + }, + }, + MINDFUL_MINUTES: { + key: "MINDFUL_MINUTES", + kind: "measurement", + source: { measurementType: "MINDFUL_MINUTES" }, + displayName: "Mindful minutes", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKCategoryTypeIdentifierMindfulSession", + display: "Mindful session", + unit: "min", + category: "activity", + }, + }, + STATE_OF_MIND_VALENCE: { + key: "STATE_OF_MIND_VALENCE", + kind: "score", + source: { measurementType: "STATE_OF_MIND_VALENCE" }, + displayName: "State of mind (valence)", + unit: "valence", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + // A self-report on HealthKit's −1..1 scale; no LOINC concept. + loinc: null, + display: "State of mind valence", + unit: "1", + category: "survey", + }, + }, + AFIB_BURDEN: { + key: "AFIB_BURDEN", + kind: "measurement", + source: { measurementType: "AFIB_BURDEN" }, + displayName: "AFib burden", + unit: "%", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAtrialFibrillationBurden", + display: "Atrial fibrillation burden", + unit: "%", + category: "vital-signs", + }, + }, }; /** Type guard: narrow an arbitrary string to a registered signal key. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 957caaf9e..658568fae 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2175,6 +2175,7 @@ const accountSettingsBackupSchema = z coachPrefsJson: z.unknown().optional(), documentsAutoAiRead: z.boolean().optional(), labsLocalOcrEnabled: z.boolean().optional(), + storeWorkoutRoutes: z.boolean().optional(), onboardingCompletedAt: isoDateTime.nullable().optional(), onboardingTourCompleted: z.boolean().optional(), onboardingTourProgressJson: z.unknown().optional(), diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 9ac5f0ae8..7134461ac 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -145,6 +145,14 @@ export const measurementTypeEnum = z.enum([ // percentage (raw sum × 4); SCI stores the 0–32 total. "WHO5_SCORE", "SCI_SCORE", + // ── v1.44 — Apple Health activity rings, mindfulness, State of Mind and + // AFib burden. See the enum comments in `prisma/schema.prisma`. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "AFIB_BURDEN", ]); /** @@ -440,6 +448,18 @@ export const VALUE_RANGES: Record = { // SCI total 0–32 — both server-derived, higher = better. WHO5_SCORE: { min: 0, max: 100 }, SCI_SCORE: { min: 0, max: 32 }, + // ── v1.44 — the minute rings and mindful minutes are bounded by the day; + // a row is one sample or one day's total, never more than 1440 minutes. + EXERCISE_MINUTES: { min: 0, max: 1440 }, + STAND_MINUTES: { min: 0, max: 1440 }, + MINDFUL_MINUTES: { min: 0, max: 1440 }, + // Stood hours: 1 per hour sample, at most 24 on a day's total. + STAND_HOURS: { min: 0, max: 24 }, + // State of Mind valence: HealthKit's closed −1..1 scale. + STATE_OF_MIND_VALENCE: { min: -1, max: 1 }, + // AFib burden as a percent of the week, after the ×100 scaling. A client + // that already scaled sends 2 for 2 %, which lands at 200 and is refused. + AFIB_BURDEN: { min: 0, max: 100 }, // Grip strength kg — a child can read in the single digits, an elite athlete // tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise. GRIP_STRENGTH: { min: 0, max: 120 }, diff --git a/src/lib/validations/user-prefs.ts b/src/lib/validations/user-prefs.ts index 2f6200710..786926f62 100644 --- a/src/lib/validations/user-prefs.ts +++ b/src/lib/validations/user-prefs.ts @@ -41,6 +41,11 @@ export const labsLocalOcrPatchSchema = z.object({ labsLocalOcrEnabled: z.boolean(), }); +/** `PATCH /api/auth/me/workout-routes` — keep workout GPS tracks (opt-in). */ +export const workoutRoutesPatchSchema = z.object({ + storeWorkoutRoutes: z.boolean(), +}); + /** `PATCH /api/auth/me/use-central-codex` — the operator's shared Codex. */ export const useCentralCodexPatchSchema = z.object({ useCentralCodex: z.boolean(), diff --git a/src/lib/workouts/__tests__/insight-evidence.test.ts b/src/lib/workouts/__tests__/insight-evidence.test.ts index da3f7b11f..a19fcfceb 100644 --- a/src/lib/workouts/__tests__/insight-evidence.test.ts +++ b/src/lib/workouts/__tests__/insight-evidence.test.ts @@ -6,7 +6,6 @@ import { assertNumericLeaves, buildWorkoutInsightEvidence, narrowSportType, - routeClimbM, summariseHrShape, summariseOwnHistory, } from "../insight-evidence"; @@ -90,38 +89,6 @@ describe("summariseHrShape", () => { }); }); -describe("routeClimbM", () => { - it("sums only the positive altitude deltas", () => { - const geometry = { - type: "LineString", - coordinates: [ - [8, 50, 100], - [8, 50, 140], - [8, 50, 110], - [8, 50, 160], - ], - }; - // +40 then −30 then +50 → 90 climbed, not 60 net. - expect(routeClimbM(geometry)).toBe(90); - }); - - it("returns null when the geometry carries no altitude channel", () => { - // Withings ships static GPX with no altitudes. Reporting a confident 0 - // would be a claim about flat terrain that the data does not make. - expect( - routeClimbM({ - type: "LineString", - coordinates: [ - [8, 50], - [8.1, 50.1], - ], - }), - ).toBeNull(); - expect(routeClimbM(null)).toBeNull(); - expect(routeClimbM("not geometry")).toBeNull(); - }); -}); - describe("summariseOwnHistory", () => { const row = (durationSec: number, avgHeartRate: number | null) => ({ durationSec, @@ -176,7 +143,6 @@ describe("buildWorkoutInsightEvidence", () => { tz: "Europe/Berlin", hrSeries: null, zones: null, - routeGeometry: null, history: [], }; @@ -196,20 +162,7 @@ describe("buildWorkoutInsightEvidence", () => { expect(evidence.localDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); - it("prefers route-derived climb over the denormalised column", () => { - const evidence = buildWorkoutInsightEvidence({ - ...base, - routeGeometry: { - coordinates: [ - [8, 50, 100], - [8, 50, 130], - ], - }, - }); - expect(evidence.climbM).toBe(30); - }); - - it("falls back to the column when the route carries no altitudes", () => { + it("reads the climb from the stored column", () => { const evidence = buildWorkoutInsightEvidence(base); expect(evidence.climbM).toBe(210); }); @@ -240,7 +193,6 @@ describe("workoutInsightInputHash", () => { tz: "UTC", hrSeries: null, zones: null, - routeGeometry: null, history: [], }); @@ -372,7 +324,6 @@ describe("assertNumericLeaves", () => { }, ], }, - routeGeometry: null, history: [], }), ).toThrow(/non-numeric leaf/); @@ -394,15 +345,14 @@ describe("assertNumericLeaves", () => { tz: "UTC", hrSeries: series([120, 130, 140, 150, 145, 138]), zones: null, - routeGeometry: null, history: [], }); expect(evidence.hr).not.toBeNull(); expect(evidence.hr).not.toHaveProperty("source"); }); - it("never carries route geometry — only the one number derived from it", () => { - const evidence = buildWorkoutInsightEvidence({ + it("takes no route geometry at all", () => { + const input = { row: { sportType: "cycling", startedAt: new Date("2026-07-18T06:00:00.000Z"), @@ -412,19 +362,19 @@ describe("assertNumericLeaves", () => { avgHeartRate: null, maxHeartRate: null, minHeartRate: null, - elevationM: null, + elevationM: 50, }, tz: "UTC", hrSeries: null, zones: null, - routeGeometry: { - coordinates: [ - [8.1, 50.1, 100], - [8.2, 50.2, 150], - ], - }, history: [], - }); + }; + const withTrack = { + ...input, + // @ts-expect-error — the builder has no route input; a track cannot be handed to it. + routeGeometry: { coordinates: [[8.1, 50.1, 100]] }, + } satisfies Parameters[0]; + const evidence = buildWorkoutInsightEvidence(withTrack); // Location history is not a figure and may not ride a prompt. expect(JSON.stringify(evidence)).not.toContain("8.1"); expect(JSON.stringify(evidence)).not.toContain("coordinates"); diff --git a/src/lib/workouts/__tests__/route-climb.test.ts b/src/lib/workouts/__tests__/route-climb.test.ts new file mode 100644 index 000000000..f83681843 --- /dev/null +++ b/src/lib/workouts/__tests__/route-climb.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { routeClimbM } from "../route-climb"; + +describe("routeClimbM", () => { + it("sums only the positive altitude deltas", () => { + const geometry = { + type: "LineString", + coordinates: [ + [8, 50, 100], + [8, 50, 140], + [8, 50, 110], + [8, 50, 160], + ], + }; + // +40 then −30 then +50 → 90 climbed, not 60 net. + expect(routeClimbM(geometry)).toBe(90); + }); + + it("returns null when the geometry carries no altitude channel", () => { + // Withings ships static GPX with no altitudes. Reporting a confident 0 + // would be a claim about flat terrain that the data does not make. + expect( + routeClimbM({ + type: "LineString", + coordinates: [ + [8, 50], + [8.1, 50.1], + ], + }), + ).toBeNull(); + expect(routeClimbM(null)).toBeNull(); + expect(routeClimbM("not geometry")).toBeNull(); + }); +}); diff --git a/src/lib/workouts/insight-evidence.ts b/src/lib/workouts/insight-evidence.ts index ab68d2b91..3e47f8cce 100644 --- a/src/lib/workouts/insight-evidence.ts +++ b/src/lib/workouts/insight-evidence.ts @@ -221,34 +221,6 @@ export function summariseHrShape( }; } -/** - * Total metres climbed, summed from a GeoJSON LineString's altitude channel. - * - * Only positive deltas count — this is climb, not net elevation change, which - * is the figure a rider recognises. Returns null when the geometry carries no - * altitude channel at all (Withings ships static GPX without one), so the - * caller can fall back to the denormalised column instead of reporting a - * confident zero. - */ -export function routeClimbM(geometry: unknown): number | null { - if (!geometry || typeof geometry !== "object") return null; - const coords = (geometry as { coordinates?: unknown }).coordinates; - if (!Array.isArray(coords)) return null; - - let climb = 0; - let previous: number | null = null; - let seen = 0; - for (const point of coords) { - if (!Array.isArray(point) || point.length < 3) continue; - const alt = point[2]; - if (typeof alt !== "number" || !Number.isFinite(alt)) continue; - seen++; - if (previous !== null && alt > previous) climb += alt - previous; - previous = alt; - } - return seen >= 2 ? Math.round(climb) : null; -} - /** Slim row shape the own-history median reads. */ export interface OwnHistoryRow { durationSec: number; @@ -330,8 +302,6 @@ export interface BuildEvidenceInput { hrSeries: WorkoutHrSeries | null; /** Zones as the detail route computes them, or null. */ zones: WorkoutZones | null; - /** `WorkoutRoute.geometry`, or null when the source shipped no route. */ - routeGeometry: unknown; /** Same-sport sessions inside the lookback, excluding this workout. */ history: readonly OwnHistoryRow[]; } @@ -412,7 +382,9 @@ export function buildWorkoutInsightEvidence( localDate: userDayKey(row.startedAt, input.tz), durationSec: row.durationSec, distanceM: row.totalDistanceM, - climbM: routeClimbM(input.routeGeometry) ?? row.elevationM, + // The stored column only. A kept GPS track already filled it at write + // time (`route-climb.ts`); the track itself never reaches this builder. + climbM: row.elevationM, activeEnergyKcal: row.totalEnergyKcal, avgHr: row.avgHeartRate, maxHr: row.maxHeartRate, diff --git a/src/lib/workouts/route-climb.ts b/src/lib/workouts/route-climb.ts new file mode 100644 index 000000000..9e2b8bd03 --- /dev/null +++ b/src/lib/workouts/route-climb.ts @@ -0,0 +1,37 @@ +/** + * Metres climbed, derived from a workout's GPS track at write time. + * + * The workout batch route fills `Workout.elevationM` from a kept track when + * the source sent no elevation of its own, so the figure lives on the + * workout row. Readers, the workout insight among them, take the column and + * never open the track: no AI path reads route geometry (the egress guard + * freezes the readers). + */ + +/** + * Total metres climbed, summed from a GeoJSON LineString's altitude channel. + * + * Only positive deltas count — this is climb, not net elevation change, which + * is the figure a rider recognises. Returns null when the geometry carries no + * altitude channel at all (Withings ships static GPX without one), so the + * caller can fall back to the denormalised column instead of reporting a + * confident zero. + */ +export function routeClimbM(geometry: unknown): number | null { + if (!geometry || typeof geometry !== "object") return null; + const coords = (geometry as { coordinates?: unknown }).coordinates; + if (!Array.isArray(coords)) return null; + + let climb = 0; + let previous: number | null = null; + let seen = 0; + for (const point of coords) { + if (!Array.isArray(point) || point.length < 3) continue; + const alt = point[2]; + if (typeof alt !== "number" || !Number.isFinite(alt)) continue; + seen++; + if (previous !== null && alt > previous) climb += alt - previous; + previous = alt; + } + return seen >= 2 ? Math.round(climb) : null; +} From b76826a5f8baede791bb2ee60e51c525c1703bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 20:20:10 +0200 Subject: [PATCH 26/98] test(workouts): call the route opt-in handlers with their real arity --- .../me/workout-routes/__tests__/route.test.ts | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/src/app/api/auth/me/workout-routes/__tests__/route.test.ts b/src/app/api/auth/me/workout-routes/__tests__/route.test.ts index dad36584d..e3a0cbbaf 100644 --- a/src/app/api/auth/me/workout-routes/__tests__/route.test.ts +++ b/src/app/api/auth/me/workout-routes/__tests__/route.test.ts @@ -60,10 +60,7 @@ beforeEach(() => { describe("/api/auth/me/workout-routes", () => { it("reads off when the row carries no value", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - const res = await GET( - new Request("http://localhost/api/auth/me/workout-routes") as never, - { params: Promise.resolve({}) } as never, - ); + const res = await GET(); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ data: { storeWorkoutRoutes: false }, @@ -74,12 +71,7 @@ describe("/api/auth/me/workout-routes", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ storeWorkoutRoutes: false, } as never); - const res = await PATCH( - mkPatch({ storeWorkoutRoutes: true }) as never, - { - params: Promise.resolve({}), - } as never, - ); + const res = await PATCH(mkPatch({ storeWorkoutRoutes: true })); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ data: { storeWorkoutRoutes: true }, @@ -97,24 +89,14 @@ describe("/api/auth/me/workout-routes", () => { }); it("refuses a malformed body with 422 and writes nothing", async () => { - const res = await PATCH( - mkPatch({ storeWorkoutRoutes: "yes" }) as never, - { - params: Promise.resolve({}), - } as never, - ); + const res = await PATCH(mkPatch({ storeWorkoutRoutes: "yes" })); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); }); it("refuses an unauthenticated caller", async () => { vi.mocked(getSession).mockResolvedValue(null); - const res = await PATCH( - mkPatch({ storeWorkoutRoutes: true }) as never, - { - params: Promise.resolve({}), - } as never, - ); + const res = await PATCH(mkPatch({ storeWorkoutRoutes: true })); expect(res.status).toBe(401); expect(prisma.user.update).not.toHaveBeenCalled(); }); From cc8ea3dfbb1cd5825dd48e2f67f3506490c8d256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 20:26:15 +0200 Subject: [PATCH 27/98] feat(measurements): accept indoor CO2, temperature and humidity Room climate from a home automation system gets three types of its own: INDOOR_CO2 (ppm), INDOOR_TEMPERATURE (celsius, shown in Fahrenheit for imperial readers) and INDOOR_HUMIDITY (percent). They arrive through the existing ingest token (measurements:write, source EXTERNAL), sit in the environment cluster beside daylight, and the daily rollup carries their mean, low and high like any spot reading. They never mix with the body temperatures or with the outdoor environment channels. The Home Assistant page documents the three types, a rest_command that sends a room as one request, a single-room automation, and an importable blueprint (docs/integrations/blueprints) that skips sensors reporting unknown or non-numeric states, converts Fahrenheit, and stamps readings to the minute so a retried push is refused rather than written twice. --- docs/api/openapi.yaml | 33 ++++++- .../blueprints/healthlog-room-climate.yaml | 93 +++++++++++++++++++ docs/integrations/home-assistant.md | 80 ++++++++++++++++ messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/ko.json | 3 + messages/nl.json | 3 + messages/pl.json | 3 + .../0433_indoor_climate_types/migration.sql | 11 +++ prisma/schema.prisma | 8 ++ ...doctor-report-control-gating-guard.test.ts | 4 +- .../measurements/measurement-list-meta.ts | 7 ++ .../measurements/measurement-type-colors.ts | 4 + .../__tests__/report-selection-panel.test.tsx | 2 +- .../measurement-type-enum-coverage.test.ts | 6 +- src/lib/insights/chart-tokens.ts | 4 + .../__tests__/apple-health-mapping.test.ts | 4 + .../__tests__/indoor-climate.test.ts | 61 ++++++++++++ .../measurements/__tests__/unit-label.test.ts | 1 + src/lib/measurements/categories.ts | 4 + src/lib/measurements/display-transform.ts | 4 + src/lib/measurements/type-label-keys.ts | 3 + src/lib/measurements/unit-map.ts | 4 + src/lib/personal-records/pr-direction.ts | 4 + .../__tests__/catalogue-guard.test.ts | 4 +- src/lib/report-selection/catalogue.ts | 6 ++ .../fixtures/expected-measurement-loinc.json | 18 ++++ src/lib/signals/registry.ts | 72 ++++++++++++++ src/lib/validations/measurement.ts | 11 +++ 32 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 docs/integrations/blueprints/healthlog-room-climate.yaml create mode 100644 prisma/migrations/0433_indoor_climate_types/migration.sql create mode 100644 src/lib/measurements/__tests__/indoor-climate.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b390c93b3..35e6be480 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4321,6 +4321,9 @@ paths: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - in: query name: from schema: @@ -26000,7 +26003,7 @@ components: type: number const: 2 leaves: - maxItems: 101 + maxItems: 104 type: array items: type: string @@ -26060,7 +26063,7 @@ components: type: number const: 2 leaves: - maxItems: 101 + maxItems: 104 type: array items: type: string @@ -26901,6 +26904,9 @@ components: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY value: type: number measuredAt: @@ -28588,6 +28594,9 @@ components: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY biomarkerId: type: string minLength: 1 @@ -29087,7 +29096,7 @@ components: type: number const: 2 leaves: - maxItems: 101 + maxItems: 104 type: array items: type: string @@ -30264,6 +30273,9 @@ components: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -39046,6 +39058,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN value: type: number @@ -39792,6 +39807,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN - type: "null" biomarkerName: @@ -42273,6 +42291,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN dateKey: type: string @@ -43457,6 +43478,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN - type: "null" intervalDays: @@ -43675,6 +43699,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN metricSlot: type: diff --git a/docs/integrations/blueprints/healthlog-room-climate.yaml b/docs/integrations/blueprints/healthlog-room-climate.yaml new file mode 100644 index 000000000..205ad0708 --- /dev/null +++ b/docs/integrations/blueprints/healthlog-room-climate.yaml @@ -0,0 +1,93 @@ +# Push a room's CO2, temperature and humidity to HealthLog every few minutes. +# +# Needs the `healthlog_room_climate` rest_command from +# docs/integrations/home-assistant.md ("Room climate") and a measurement +# ingest token. Every sensor is optional; a sensor that reports `unknown`, +# `unavailable` or a non-number is left out of that push, and a push with +# nothing left is skipped. Temperatures in °F are converted to °C. +blueprint: + name: HealthLog room climate + description: >- + Sends indoor CO2 (ppm), temperature (°C) and relative humidity (%) to + HealthLog through a measurement ingest token. HealthLog keeps each + reading and shows the day's mean, low and high. + domain: automation + source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml + input: + co2_sensor: + name: CO2 sensor + description: A sensor reporting carbon dioxide in ppm. + default: "" + selector: + entity: + domain: sensor + device_class: carbon_dioxide + temperature_sensor: + name: Temperature sensor + description: A sensor reporting the room temperature (°C or °F). + default: "" + selector: + entity: + domain: sensor + device_class: temperature + humidity_sensor: + name: Humidity sensor + description: A sensor reporting relative humidity in %. + default: "" + selector: + entity: + domain: sensor + device_class: humidity + interval_minutes: + name: Interval + description: Minutes between pushes. 15 is plenty for a daily picture. + default: 15 + selector: + number: + min: 5 + max: 60 + step: 5 + unit_of_measurement: min + +mode: single + +variables: + co2_sensor: !input co2_sensor + temperature_sensor: !input temperature_sensor + humidity_sensor: !input humidity_sensor + interval_minutes: !input interval_minutes + # A list of readings (Home Assistant keeps a rendered list a list). + readings: >- + {% set at = now().replace(second=0, microsecond=0).isoformat() %} + {% set ns = namespace(items=[]) %} + {% for type, entity in [ + ('INDOOR_CO2', co2_sensor), + ('INDOOR_TEMPERATURE', temperature_sensor), + ('INDOOR_HUMIDITY', humidity_sensor) + ] %} + {% if entity and is_number(states(entity)) %} + {% set v = states(entity) | float %} + {% if type == 'INDOOR_TEMPERATURE' + and state_attr(entity, 'unit_of_measurement') == '°F' %} + {% set v = ((v - 32) * 5 / 9) | round(2) %} + {% endif %} + {% set ns.items = ns.items + + [{'type': type, 'value': v, 'measuredAt': at}] %} + {% endif %} + {% endfor %} + {{ ns.items }} + +trigger: + - platform: time_pattern + minutes: "/5" + +condition: + - condition: template + value_template: >- + {{ now().minute % (interval_minutes | int(15)) == 0 + and readings | count > 0 }} + +action: + - service: rest_command.healthlog_room_climate + data: + readings: "{{ readings }}" diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index 71d679ccc..d5423e650 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -149,6 +149,86 @@ Units are derived server-side from the type, so send the number in the unit above and do not send a `unit` field. The full enum is in `docs/api/openapi.yaml` under `MeasurementType`. +## Room climate + +Indoor CO₂, temperature and humidity are their own types, kept apart from the +body temperatures and from the outdoor weather the environment module pulls: + +| What the sensor reports | `type` | Unit expected | +| ----------------------- | -------------------- | ------------- | +| Carbon dioxide | `INDOOR_CO2` | ppm | +| Room temperature | `INDOOR_TEMPERATURE` | °C | +| Relative humidity | `INDOOR_HUMIDITY` | % | + +Each push is kept as a reading, and the day's mean, low and high are what the +charts and the daily views read, so a sensor sampled every 15 minutes gives a +clear picture without anything further on your side. Pushing every few seconds +adds rows, not detail. + +Add a second `rest_command` that takes a ready-made list of readings, so one +request carries all three: + +```yaml +rest_command: + healthlog_room_climate: + url: "https://healthlog.example.com/api/measurements" + method: POST + headers: + Authorization: !secret healthlog_token + Content-Type: "application/json" + payload: "{{ readings | to_json }}" +``` + +Then import the blueprint, either from +[`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml) +or by its raw GitHub URL under Settings → Automations & scenes → Blueprints → +Import blueprint, and create one automation per room. It asks for the three +sensors (each optional) and an interval. Every push: + +- leaves out a sensor that reports `unknown`, `unavailable` or anything that is + not a number, and skips the push when nothing is left; +- converts a temperature reported in °F to °C; +- stamps all readings with the current minute, so a retry of the same push is + refused with a 409 instead of written twice. + +If you would rather write the automation yourself, this is the same thing for a +single room without the blueprint: + +```yaml +automation: + - alias: "Push bedroom climate to HealthLog" + trigger: + - platform: time_pattern + minutes: "/15" + variables: + readings: >- + {% set at = now().replace(second=0, microsecond=0).isoformat() %} + {% set ns = namespace(items=[]) %} + {% for type, entity in [ + ('INDOOR_CO2', 'sensor.bedroom_co2'), + ('INDOOR_TEMPERATURE', 'sensor.bedroom_temperature'), + ('INDOOR_HUMIDITY', 'sensor.bedroom_humidity') + ] %} + {% if is_number(states(entity)) %} + {% set ns.items = ns.items + [{'type': type, + 'value': states(entity) | float, 'measuredAt': at}] %} + {% endif %} + {% endfor %} + {{ ns.items }} + condition: + - condition: template + value_template: "{{ readings | count > 0 }}" + action: + - service: rest_command.healthlog_room_climate + data: + readings: "{{ readings }}" +``` + +One request takes up to five readings, so a room is one call. Several rooms +are several automations: the readings carry no room name, and two rooms pushed +in the same minute collide on the 409 above. If you track more than one room, +pick the one you sleep in; that is the one worth reading next to your sleep. + ## Sending several readings at once A bare array writes up to five readings in one request — the shape to use for diff --git a/messages/de.json b/messages/de.json index 6b34c7498..9e4111523 100644 --- a/messages/de.json +++ b/messages/de.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Achtsamkeitsminuten", "typeStateOfMindValence": "Gemütszustand", "typeAfibBurden": "VHF-Last", + "typeIndoorCo2": "CO₂ (innen)", + "typeIndoorTemperature": "Raumtemperatur", + "typeIndoorHumidity": "Luftfeuchtigkeit (innen)", "durationReadAs": "Gelesen als {duration}", "durationHoursMinutes": "{hours} Std. {minutes} Min.", "durationHoursOnly": "{hours} Std.", diff --git a/messages/en.json b/messages/en.json index 60fe5c6be..6b5b799b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Mindful minutes", "typeStateOfMindValence": "State of mind", "typeAfibBurden": "AFib burden", + "typeIndoorCo2": "Indoor CO₂", + "typeIndoorTemperature": "Indoor temperature", + "typeIndoorHumidity": "Indoor humidity", "durationReadAs": "Read as {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/es.json b/messages/es.json index fcfada1b1..f107f380c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minutos de atención plena", "typeStateOfMindValence": "Estado de ánimo", "typeAfibBurden": "Carga de FA", + "typeIndoorCo2": "CO₂ interior", + "typeIndoorTemperature": "Temperatura interior", + "typeIndoorHumidity": "Humedad interior", "durationReadAs": "Se lee como {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/fr.json b/messages/fr.json index 843d5ebf6..2226dbcc2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minutes de pleine conscience", "typeStateOfMindValence": "État d’esprit", "typeAfibBurden": "Charge de FA", + "typeIndoorCo2": "CO₂ intérieur", + "typeIndoorTemperature": "Température intérieure", + "typeIndoorHumidity": "Humidité intérieure", "durationReadAs": "Compris comme {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/it.json b/messages/it.json index 15d737a1c..570087490 100644 --- a/messages/it.json +++ b/messages/it.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minuti di consapevolezza", "typeStateOfMindValence": "Stato d’animo", "typeAfibBurden": "Carico di FA", + "typeIndoorCo2": "CO₂ interna", + "typeIndoorTemperature": "Temperatura interna", + "typeIndoorHumidity": "Umidità interna", "durationReadAs": "Letto come {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/ko.json b/messages/ko.json index f23096369..92402257f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "마음 챙기기 시간(분)", "typeStateOfMindValence": "마음 상태", "typeAfibBurden": "심방세동 부담", + "typeIndoorCo2": "실내 CO₂", + "typeIndoorTemperature": "실내 온도", + "typeIndoorHumidity": "실내 습도", "durationReadAs": "{duration}(으)로 읽었어요", "durationHoursMinutes": "{hours}시간 {minutes}분", "durationHoursOnly": "{hours}시간", diff --git a/messages/nl.json b/messages/nl.json index 3f6fabe35..af65f651a 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Mindfulnessminuten", "typeStateOfMindValence": "Gemoedstoestand", "typeAfibBurden": "AF-belasting", + "typeIndoorCo2": "CO₂ binnen", + "typeIndoorTemperature": "Binnentemperatuur", + "typeIndoorHumidity": "Luchtvochtigheid binnen", "durationReadAs": "Gelezen als {duration}", "durationHoursMinutes": "{hours} u {minutes} min", "durationHoursOnly": "{hours} u", diff --git a/messages/pl.json b/messages/pl.json index 66982c68e..3f0e4089d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minuty uważności", "typeStateOfMindValence": "Stan umysłu", "typeAfibBurden": "Obciążenie AF", + "typeIndoorCo2": "CO₂ w pomieszczeniu", + "typeIndoorTemperature": "Temperatura w pomieszczeniu", + "typeIndoorHumidity": "Wilgotność w pomieszczeniu", "durationReadAs": "Odczytano jako {duration}", "durationHoursMinutes": "{hours} godz. {minutes} min", "durationHoursOnly": "{hours} godz.", diff --git a/prisma/migrations/0433_indoor_climate_types/migration.sql b/prisma/migrations/0433_indoor_climate_types/migration.sql new file mode 100644 index 000000000..c07e7c564 --- /dev/null +++ b/prisma/migrations/0433_indoor_climate_types/migration.sql @@ -0,0 +1,11 @@ +-- Extend `measurement_type` with room-climate readings pushed through the +-- ingest token (source EXTERNAL), for example by a Home Assistant +-- automation: indoor CO2 (ppm), indoor temperature (celsius) and indoor +-- relative humidity (percent). +-- +-- Purely additive. IF NOT EXISTS keeps a replay harmless; enum values cannot +-- be dropped in PostgreSQL, so there is no down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_CO2'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_TEMPERATURE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_HUMIDITY'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82129aac4..d6eeead54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1718,6 +1718,14 @@ enum MeasurementType { STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours. MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM. STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement. + // ── v1.44 — room climate from a home automation system ── + // Pushed through the ingest token (`measurements:write`, source EXTERNAL), + // e.g. by a Home Assistant automation. Spot readings: the daily rollup + // carries mean, min and max. Indoor, so never mixed with the outdoor + // environment channels. + INDOOR_CO2 // ppm — indoor carbon dioxide concentration. + INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type. + INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity. AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis. @@map("measurement_type") diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 1c06583ae..b0763a13f 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,8 +74,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(101); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(83); + expect(ALL_LEAF_IDS.length).toBe(104); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18); }); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 5be62c111..e0d6a8125 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -139,6 +139,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { MINDFUL_MINUTES: "measurements.typeMindfulMinutes", STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence", AFIB_BURDEN: "measurements.typeAfibBurden", + INDOOR_CO2: "measurements.typeIndoorCo2", + INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature", + INDOOR_HUMIDITY: "measurements.typeIndoorHumidity", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -282,6 +285,10 @@ export const MEASUREMENT_TYPE_ICONS: Record = { MINDFUL_MINUTES: Wind, STATE_OF_MIND_VALENCE: Gauge, AFIB_BURDEN: HeartPulse, + // Room climate: air, warmth, moisture. + INDOOR_CO2: Wind, + INDOOR_TEMPERATURE: Thermometer, + INDOOR_HUMIDITY: Droplets, }; // The colour map lives in its own icon-free module so a surface that only diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index 76a7d6803..98409b66f 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -138,4 +138,8 @@ export const MEASUREMENT_TYPE_COLORS: Record = { MINDFUL_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", STATE_OF_MIND_VALENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", AFIB_BURDEN: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + // Room climate takes the environment family of daylight (chart-2). + INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", }; diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index b3c162515..ca32f7ac3 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -198,7 +198,7 @@ describe(" — the first run", () => { expect(groupChip(html, "activity")).toBe("0/11"); expect(groupChip(html, "sleepRecovery")).toBe("0/16"); expect(groupChip(html, "mobility")).toBe("0/11"); - expect(groupChip(html, "environment")).toBe("0/4"); + expect(groupChip(html, "environment")).toBe("0/7"); }); it("disables Generate while the scope is empty, and says what to do", () => { diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 5ef5738b7..79c4093c6 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -120,10 +120,14 @@ const EXPECTED_TYPES = [ "MINDFUL_MINUTES", "STATE_OF_MIND_VALENCE", "AFIB_BURDEN", + // ── v1.44 — room climate pushed through the ingest token ── + "INDOOR_CO2", + "INDOOR_TEMPERATURE", + "INDOOR_HUMIDITY", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 83 canonical measurement types", () => { + it("exposes the 86 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 94df12acc..3b0fef932 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -145,6 +145,10 @@ export const ALLOWED_CHART_TOKENS = [ "metric:MINDFUL_MINUTES", "metric:STATE_OF_MIND_VALENCE", "metric:AFIB_BURDEN", + // v1.44 — room climate from the ingest token. + "metric:INDOOR_CO2", + "metric:INDOOR_TEMPERATURE", + "metric:INDOOR_HUMIDITY", "metric:GRIP_STRENGTH", "metric:PAIN_NRS", "metric:WAIST_CIRCUMFERENCE", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 86afc5d32..b1ffdc978 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -23,6 +23,10 @@ import type { MeasurementType } from "@/generated/prisma/client"; * silently failing the assertion. */ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ + // v1.44 — room climate arrives through the ingest token, not HealthKit. + "INDOOR_CO2", + "INDOOR_TEMPERATURE", + "INDOOR_HUMIDITY", // Withings-only metrics (no HK identifier ships any of these as a // first-class quantity — Apple delegates body-composition tail to // third-party devices via the BodyMass/BodyFat pair). diff --git a/src/lib/measurements/__tests__/indoor-climate.test.ts b/src/lib/measurements/__tests__/indoor-climate.test.ts new file mode 100644 index 000000000..a7ebc3d69 --- /dev/null +++ b/src/lib/measurements/__tests__/indoor-climate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { getUnitForType } from "@/lib/measurements/unit-map"; +import { getDisplayTransform } from "@/lib/measurements/display-transform"; +import { getMeasurementCategory } from "@/lib/measurements/categories"; +import { createMeasurementSchema } from "@/lib/validations/measurement"; + +/** + * Room climate as a Home Assistant automation pushes it: the documented + * three-reading array through the ingest token, no source, no unit. + */ +const at = new Date(Date.now() - 60_000).toISOString(); + +describe("indoor climate types", () => { + it("accepts the documented push shape for all three", () => { + for (const [type, value] of [ + ["INDOOR_CO2", 812], + ["INDOOR_TEMPERATURE", 21.4], + ["INDOOR_HUMIDITY", 47], + ] as const) { + expect( + createMeasurementSchema.safeParse({ type, value, measuredAt: at }) + .success, + type, + ).toBe(true); + } + }); + + it("refuses a reading no room produces", () => { + for (const [type, value] of [ + ["INDOOR_CO2", 12_000], + ["INDOOR_TEMPERATURE", 75], + ["INDOOR_HUMIDITY", 101], + ] as const) { + expect( + createMeasurementSchema.safeParse({ type, value, measuredAt: at }) + .success, + type, + ).toBe(false); + } + }); + + it("stores canonical units and shows temperature in the person's scale", () => { + expect(getUnitForType("INDOOR_CO2")).toBe("ppm"); + expect(getUnitForType("INDOOR_TEMPERATURE")).toBe("celsius"); + expect(getUnitForType("INDOOR_HUMIDITY")).toBe("%"); + expect( + getDisplayTransform("INDOOR_TEMPERATURE", "imperial").displayUnit, + ).toBe("°F"); + }); + + it("files all three under the environment cluster", () => { + for (const type of [ + "INDOOR_CO2", + "INDOOR_TEMPERATURE", + "INDOOR_HUMIDITY", + ] as const) { + expect(getMeasurementCategory(type)).toBe("environment"); + } + }); +}); diff --git a/src/lib/measurements/__tests__/unit-label.test.ts b/src/lib/measurements/__tests__/unit-label.test.ts index 3c07ae748..dfd947eba 100644 --- a/src/lib/measurements/__tests__/unit-label.test.ts +++ b/src/lib/measurements/__tests__/unit-label.test.ts @@ -67,6 +67,7 @@ const SHARED_SYMBOLS: ReadonlySet = new Set([ "°C", "°F", "dBA", + "ppm", "mL/(kg·min)", "kg/m²", "mL", diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 0bff7c895..0620110b2 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -230,6 +230,10 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["STATE_OF_MIND_VALENCE", "scores"], // AFib burden joins the rhythm notifications on the cardiovascular surface. ["AFIB_BURDEN", "cardiovascular"], + // ── v1.44 — room climate joins daylight in the environment cluster ── + ["INDOOR_CO2", "environment"], + ["INDOOR_TEMPERATURE", "environment"], + ["INDOOR_HUMIDITY", "environment"], ]); /** diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 03a4382cd..2dc71e322 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -179,6 +179,8 @@ const TRANSFORMS: Partial< BODY_TEMPERATURE: TEMPERATURE_TRANSFORM, SKIN_TEMPERATURE: TEMPERATURE_TRANSFORM, WRIST_TEMPERATURE: TEMPERATURE_TRANSFORM, + // v1.44 — room temperature reads in the person's own scale too. + INDOOR_TEMPERATURE: TEMPERATURE_TRANSFORM, // A signed baseline DEVIATION (Δ°C) — the °F imperial branch scales by // 1.8 but carries NO +32 offset. A one-degree-Celsius deviation is a // 1.8-degree-Fahrenheit deviation, not a 33.8 °F reading. @@ -338,6 +340,8 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet = new Set([ "STAND_MINUTES", "STAND_HOURS", "MINDFUL_MINUTES", + // CO2 sensors report whole ppm. + "INDOOR_CO2", ]); /** diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index 46a61d0a4..12e3cf186 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -110,6 +110,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { MINDFUL_MINUTES: "measurements.typeMindfulMinutes", STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence", AFIB_BURDEN: "measurements.typeAfibBurden", + INDOOR_CO2: "measurements.typeIndoorCo2", + INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature", + INDOOR_HUMIDITY: "measurements.typeIndoorHumidity", GRIP_STRENGTH: "measurements.typeGripStrength", PAIN_NRS: "measurements.typePainNrs", WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference", diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index 71761e35c..943a0fade 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -166,6 +166,10 @@ const unitMap: Record = { STATE_OF_MIND_VALENCE: "valence", // Percent of the week after the ×100 scaling. AFIB_BURDEN: "%", + // ── v1.44 — room climate ── + INDOOR_CO2: "ppm", + INDOOR_TEMPERATURE: "celsius", + INDOOR_HUMIDITY: "%", }; export function getUnitForType(type: string): string { diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index dc8ecf4ad..f2ff28f9a 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -217,6 +217,10 @@ export function getPRDirection( // Neither is a goal axis a record could celebrate. case "AFIB_BURDEN": case "STATE_OF_MIND_VALENCE": + // v1.44 — room climate describes a room, not a person's effort. + case "INDOOR_CO2": + case "INDOOR_TEMPERATURE": + case "INDOOR_HUMIDITY": case "HIGH_HEART_RATE_EVENT": case "LOW_HEART_RATE_EVENT": case "WALKING_STEADINESS_EVENT": diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 4e3ba4439..593bec93c 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -53,9 +53,9 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { - expect(MEASUREMENT_LEAF_IDS).toHaveLength(83); + expect(MEASUREMENT_LEAF_IDS).toHaveLength(86); expect(STRUCTURED_LEAF_IDS).toHaveLength(18); - expect(ALL_LEAF_IDS).toHaveLength(101); + expect(ALL_LEAF_IDS).toHaveLength(104); }); it("resolves a label for every leaf", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index f53f1287a..de1683fef 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -184,6 +184,9 @@ export const MEASUREMENT_LEAF_GROUP: Record = { AUDIO_EXPOSURE_HEADPHONE: "environment", AUDIO_EXPOSURE_EVENT: "environment", TIME_IN_DAYLIGHT: "environment", + INDOOR_CO2: "environment", + INDOOR_TEMPERATURE: "environment", + INDOOR_HUMIDITY: "environment", // ── glucose ── BLOOD_GLUCOSE: "glucose", @@ -311,6 +314,9 @@ export const LEAF_MODULE: Partial> = { AUDIO_EXPOSURE_HEADPHONE: "environment", AUDIO_EXPOSURE_EVENT: "environment", TIME_IN_DAYLIGHT: "environment", + INDOOR_CO2: "environment", + INDOOR_TEMPERATURE: "environment", + INDOOR_HUMIDITY: "environment", // Glucose: the raw series and the derived panel. BLOOD_GLUCOSE: "glucose", GLUCOSE_PANEL: "glucose", diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 905fa945d..c55d1200c 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -298,5 +298,23 @@ "display": "Atrial fibrillation burden", "unit": "%", "category": "vital-signs" + }, + "INDOOR_CO2": { + "loinc": null, + "display": "Indoor carbon dioxide", + "unit": "[ppm]", + "category": "activity" + }, + "INDOOR_TEMPERATURE": { + "loinc": null, + "display": "Indoor air temperature", + "unit": "Cel", + "category": "activity" + }, + "INDOOR_HUMIDITY": { + "loinc": null, + "display": "Indoor relative humidity", + "unit": "%", + "category": "activity" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index d889362a8..cf467c8be 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -1914,6 +1914,78 @@ export const SIGNALS: Record = { category: "vital-signs", }, }, + // ── v1.44 — room climate pushed through the ingest token ── + // Bands are the usual comfort guidance (CO2 under 1000 ppm as the long- + // standing ventilation threshold, 18–24 °C, 40–60 % RH); placement only. + INDOOR_CO2: { + key: "INDOOR_CO2", + kind: "measurement", + source: { measurementType: "INDOOR_CO2" }, + displayName: "Indoor CO2", + unit: "ppm", + direction: "target-band", + archetype: "environmental-exposure", + normalRange: { low: 400, high: 1000 }, + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + // A room reading, not a body observation; no LOINC concept applies. + loinc: null, + display: "Indoor carbon dioxide", + unit: "[ppm]", + category: "activity", + }, + }, + INDOOR_TEMPERATURE: { + key: "INDOOR_TEMPERATURE", + kind: "measurement", + source: { measurementType: "INDOOR_TEMPERATURE" }, + displayName: "Indoor temperature", + unit: "°C", + direction: "target-band", + archetype: "environmental-exposure", + normalRange: { low: 18, high: 24 }, + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + // A room reading, not a body observation; no LOINC concept applies. + loinc: null, + display: "Indoor air temperature", + unit: "Cel", + category: "activity", + }, + }, + INDOOR_HUMIDITY: { + key: "INDOOR_HUMIDITY", + kind: "measurement", + source: { measurementType: "INDOOR_HUMIDITY" }, + displayName: "Indoor humidity", + unit: "%", + direction: "target-band", + archetype: "environmental-exposure", + normalRange: { low: 40, high: 60 }, + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + // A room reading, not a body observation; no LOINC concept applies. + loinc: null, + display: "Indoor relative humidity", + unit: "%", + category: "activity", + }, + }, }; /** Type guard: narrow an arbitrary string to a registered signal key. */ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7134461ac..fa6a5692b 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -153,6 +153,10 @@ export const measurementTypeEnum = z.enum([ "MINDFUL_MINUTES", "STATE_OF_MIND_VALENCE", "AFIB_BURDEN", + // ── v1.44 — room climate pushed through the ingest token ── + "INDOOR_CO2", + "INDOOR_TEMPERATURE", + "INDOOR_HUMIDITY", ]); /** @@ -460,6 +464,13 @@ export const VALUE_RANGES: Record = { // AFib burden as a percent of the week, after the ×100 scaling. A client // that already scaled sends 2 for 2 %, which lands at 200 and is refused. AFIB_BURDEN: { min: 0, max: 100 }, + // ── v1.44 — room climate. CO2: outdoor air sits near 420 ppm and a closed + // bedroom can pass 3000; consumer sensors top out at 5000–10000 ppm. + INDOOR_CO2: { min: 0, max: 10000 }, + // Indoor air °C: a cold cellar to an overheated attic. + INDOOR_TEMPERATURE: { min: -20, max: 60 }, + // Relative humidity, percent. + INDOOR_HUMIDITY: { min: 0, max: 100 }, // Grip strength kg — a child can read in the single digits, an elite athlete // tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise. GRIP_STRENGTH: { min: 0, max: 120 }, From 846b0e5df006a9b827421f91073dd57889fe78fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 20:42:06 +0200 Subject: [PATCH 28/98] perf(measurements): insert fresh batch samples set-based; answer health while starting POST /api/measurements/batch ran one interactive reconciler transaction per fresh sample: a savepoint, three advisory locks, two candidate reads and the insert, about ten round trips. A 500-entry upload of new samples was 500 of those, the 2 to 15 second outliers. Fresh samples now land through INSERT ... ON CONFLICT DO NOTHING under the same per-account advisory lock. Both measurement identities are full unique indexes, so the statement inserts exactly what the reconciler would have inserted, and every skipped row still goes through the reconciler for its own verdict. Only rows whose external and natural keys each occur once in the batch are buffered, and the buffer is flushed before any other row is reconciled, so the order of effects stays the sequential one. The contract and every per-entry status are unchanged; a golden test runs each batch through both paths against the same table. /api/health took seconds after a start because register() awaited the whole pg-boss worker start (queues, schedules, boot discovery) before the server took a request. The worker, or a web process's producer, now starts in the background after the blocking boot checks; the probe answers at once with reason "starting" until it is up, and gives the database two seconds before calling it not answering. --- docs/api/openapi.yaml | 19 +- ...asurement-write-plausibility-guard.test.ts | 4 + .../measurement-write-tail-guard.test.ts | 1 + src/app/api/health/__tests__/route.test.ts | 50 +++ src/app/api/health/route.ts | 55 +++- .../__tests__/duplicate-prefilter.test.ts | 164 +++++++++- src/app/api/measurements/batch/route.ts | 303 +++++++++++++----- src/instrumentation.ts | 99 +++--- src/lib/boot/startup-state.ts | 23 ++ .../reconcile-external-measurement.ts | 10 +- src/lib/openapi/routes/health.ts | 15 +- .../measurements-batch-perf.test.ts | 255 +++++++++++++++ 12 files changed, 835 insertions(+), 163 deletions(-) create mode 100644 src/lib/boot/startup-state.ts create mode 100644 tests/integration/measurements-batch-perf.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 35e6be480..932209f27 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -22852,8 +22852,9 @@ paths: - $ref: "#/components/schemas/HealthAdminResponse" - $ref: "#/components/schemas/HealthPublicResponse" "503": - description: "Degraded — the database did not answer, or a role this process runs is not up. Same body shape as the 200, - with `status: \"degraded\"`. A restart loop reports this rather than failing to answer, so a monitor can + description: "Degraded — the database did not answer within two seconds, or a role this process runs is not up. Same + body shape as the 200, with `status: \"degraded\"`, and `reason: \"starting\"` while the background role is + still coming up after a start. A restart loop reports this rather than failing to answer, so a monitor can tell 'starting' from 'gone'." content: application/json: @@ -84210,14 +84211,18 @@ components: when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing." HealthReason: - description: "Present only when the process refuses to serve for a reason a person has to fix. - `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at - boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs - are not started. Fix: restore the original key, point the server at the matching database, or start with an - empty one; then restart." + description: "Why a `degraded` process is degraded, when it is something other than a component failure. + `encryption_key_mismatch`: the process refuses to serve for a reason a person has to fix; the configured + encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with + `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, + point the server at the matching database, or start with an empty one; then restart. `starting`: the database + answers but the background role (the pg-boss worker, or the producer of a web-only process) is still coming up + after a start. Transient; nothing to fix, poll again. The probe answers at once while it lasts rather than + waiting for the start to finish." type: string enum: - encryption_key_mismatch + - starting HealthPublicResponse: type: object properties: diff --git a/src/__tests__/measurement-write-plausibility-guard.test.ts b/src/__tests__/measurement-write-plausibility-guard.test.ts index 9b3cf83e0..4ff24ab5c 100644 --- a/src/__tests__/measurement-write-plausibility-guard.test.ts +++ b/src/__tests__/measurement-write-plausibility-guard.test.ts @@ -73,6 +73,10 @@ const WRITERS: Record = { validatedIn: "lib/validations/measurement.ts", }, "app/api/import/route.ts": { kind: "validator" }, + // v1.44 — the set-based insert writes rows that already passed the + // route's `validateMeasurementRange` per entry; everything else goes + // through the reconciler, which gates itself. + "app/api/measurements/batch/route.ts": { kind: "validator" }, "lib/mcp/writes.ts": { kind: "validator" }, "lib/measurements/create-from-telegram.ts": { kind: "validator" }, "lib/measurements/import-apple-health-export.ts": { kind: "validator" }, diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index da045a6ed..6242c8a5b 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -44,6 +44,7 @@ const WRITE_SITES: Record = { "app/api/measurements/bulk-delete/route.ts": TAIL, "app/api/measurements/by-external-ids/route.ts": TAIL, "app/api/measurements/restore/route.ts": TAIL, + "app/api/measurements/batch/route.ts": TAIL, "app/api/measurements/route.ts": TAIL, "app/api/mental-health/assessments/route.ts": TAIL, "lib/insights/score-row.ts": TAIL, diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts index d3865a248..9c2ce8a16 100644 --- a/src/app/api/health/__tests__/route.test.ts +++ b/src/app/api/health/__tests__/route.test.ts @@ -163,3 +163,53 @@ describe("GET /api/health under ENCRYPTION_KEY_CHECK=warn", () => { } }); }); + +describe("GET /api/health while the background role starts", () => { + it("says `starting` at once, and `ok` when the role is up", async () => { + const { markStartup } = await import("@/lib/boot/startup-state"); + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + markStartup("starting"); + try { + const starting = await health(); + expect(starting.response.status).toBe(503); + expect(starting.body).toEqual({ status: "degraded", reason: "starting" }); + + getGlobalBoss.mockReturnValue({}); + markStartup("ready"); + const ready = await health(); + expect(ready.response.status).toBe(200); + expect(ready.body).toEqual({ status: "ok" }); + } finally { + markStartup("ready"); + } + }); + + it("does not call a failed start `starting`", async () => { + const { markStartup } = await import("@/lib/boot/startup-state"); + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + markStartup("failed"); + try { + const { response, body } = await health(); + expect(response.status).toBe(503); + expect(body).toEqual({ status: "degraded" }); + } finally { + markStartup("ready"); + } + }); + + it("does not wait on a database that does not answer", async () => { + vi.useFakeTimers(); + try { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + getGlobalBoss.mockReturnValue({}); + queryRaw.mockImplementation(() => new Promise(() => undefined)); + const pending = health(); + await vi.advanceTimersByTimeAsync(2_000); + const { response, body } = await pending; + expect(response.status).toBe(503); + expect(body).toEqual({ status: "degraded" }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 2e7764036..43b8ff1f8 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -11,19 +11,41 @@ import { isKeyMismatch, KEY_MISMATCH_HEALTH_REASON, } from "@/lib/boot/key-mismatch-state"; +import { startupPhase } from "@/lib/boot/startup-state"; + +/** + * How long the probe waits for `SELECT 1`. During a boot storm the pool can + * be saturated by the worker's start-up work; a probe that waits its turn + * held a monitor for seconds. Past this the database counts as not answering + * and the probe says `degraded` at once. + */ +const DB_PROBE_TIMEOUT_MS = 2_000; + +/** Reported while the background role is still coming up. */ +const STARTING_HEALTH_REASON = "starting"; + +async function probeDatabase(): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + prisma.$queryRaw`SELECT 1`.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), DB_PROBE_TIMEOUT_MS); + }), + ]); + } catch { + return false; + } finally { + if (timer) clearTimeout(timer); + } +} export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { annotate({ action: { name: "health.check" } }); - let dbOk = true; - - try { - await prisma.$queryRaw`SELECT 1`; - } catch { - dbOk = false; - } + const dbOk = await probeDatabase(); const worker = getWorkerStatus(); const producerReady = getGlobalBoss() !== null; @@ -31,20 +53,23 @@ export const GET = apiHandler(async () => { // other route; the probe says so in a word every caller may see, because // it is the one place a catalog install's status page reads. const keyMismatch = isKeyMismatch(); - const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined; + // Otherwise, while the background role is still starting, the 503 below + // says so: a transient state, not a fault, and not a hang. + const roleDown = + (shouldRunWeb() && !producerReady) || + (shouldRunWorker() && !worker.running); + const reason = keyMismatch + ? KEY_MISMATCH_HEALTH_REASON + : dbOk && roleDown && startupPhase() === "starting" + ? STARTING_HEALTH_REASON + : undefined; // Under ENCRYPTION_KEY_CHECK=warn the same finding does not refuse; it is // named as a warning and leaves the status alone. const warning = !keyMismatch && getKeyMismatchWarning() ? KEY_MISMATCH_HEALTH_REASON : undefined; - const status = - !keyMismatch && - dbOk && - (!shouldRunWeb() || producerReady) && - (!shouldRunWorker() || worker.running) - ? "ok" - : "degraded"; + const status = !keyMismatch && dbOk && !roleDown ? "ok" : "degraded"; const statusCode = status === "ok" ? 200 : 503; const cacheHeaders = { diff --git a/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts b/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts index 09ed7da58..7ce59b9f5 100644 --- a/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts +++ b/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts @@ -9,6 +9,11 @@ * prefilter's read answering nothing, which is the pre-v1.42 path where every * row went through the reconciler. Statuses, reasons and counters must match * exactly; only the transaction count may differ. + * + * v1.44 — the golden run is also blind to the set-based insert: its + * `INSERT … ON CONFLICT DO NOTHING` stand-in lands nothing, so every row + * reaches the reconciler one at a time, the sequential path. The shipped run + * takes the set insert, and the two must still leave the same table. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; @@ -29,6 +34,8 @@ const store = vi.hoisted(() => ({ rows: [] as StoredRow[], /** Golden mode: the prefilter's read answers nothing. */ prefilterBlind: false, + /** Golden mode: the set-based insert lands nothing. */ + setInsertBlind: false, nextId: 1, })); @@ -81,12 +88,70 @@ vi.mock("@/lib/db", () => { }, ), }, - $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn({})), + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => + fn({ + measurement: { + // `INSERT … ON CONFLICT DO NOTHING` over both unique identities, + // in VALUES order, returning the rows that landed. + createManyAndReturn: async (args: { + data: Array<{ + userId: string; + type: string; + source: string; + externalId: string; + measuredAt: Date; + sleepStage: string | null; + value: number; + }>; + }) => { + if (store.setInsertBlind) return []; + const landed: Array<{ + type: string; + source: string; + externalId: string; + }> = []; + for (const d of args.data) { + const scope = store.rows.filter( + (r) => + r.userId === d.userId && + r.type === d.type && + r.source === d.source, + ); + const conflict = scope.some( + (r) => + r.externalId === d.externalId || + (sameInstant(r.measuredAt, d.measuredAt) && + r.sleepStage === (d.sleepStage ?? null)), + ); + if (conflict) continue; + store.rows.push({ + id: `m${store.nextId++}`, + userId: d.userId, + type: d.type, + source: d.source, + externalId: d.externalId, + measuredAt: d.measuredAt, + sleepStage: d.sleepStage ?? null, + value: d.value, + deletedAt: null, + }); + landed.push({ + type: d.type, + source: d.source, + externalId: d.externalId, + }); + } + return landed; + }, + }, + }), + ), }; return { prisma }; }); vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + lockMeasurementUser: async () => undefined, reconcileExternalMeasurement: async ( _tx: unknown, desired: { @@ -283,6 +348,7 @@ async function golden(setup: () => void, entries: Entry[]) { store.nextId = 1; setup(); store.prefilterBlind = true; + store.setInsertBlind = true; vi.mocked(prisma.$transaction).mockClear(); const old = await post(entries); const oldTransactions = vi.mocked(prisma.$transaction).mock.calls.length; @@ -292,6 +358,7 @@ async function golden(setup: () => void, entries: Entry[]) { store.nextId = 1; setup(); store.prefilterBlind = false; + store.setInsertBlind = false; vi.mocked(prisma.$transaction).mockClear(); const next = await post(entries); const transactions = vi.mocked(prisma.$transaction).mock.calls.length; @@ -304,6 +371,7 @@ beforeEach(() => { vi.clearAllMocks(); store.rows = []; store.prefilterBlind = false; + store.setInsertBlind = false; vi.mocked(getSession).mockResolvedValue({ session: { id: "s", expiresAt: new Date(NOW + HOUR) }, user: { id: USER, username: "u", role: "USER" as const }, @@ -334,7 +402,9 @@ describe("exact-duplicate prefilter (v1.42)", () => { entries, ); expect(next.duplicates).toBe(500); - expect(oldTransactions).toBe(500); + // Sequentially: two set-insert statements that land nothing, then one + // reconciler transaction per row. + expect(oldTransactions).toBe(502); expect(transactions).toBe(0); }); @@ -366,7 +436,8 @@ describe("exact-duplicate prefilter (v1.42)", () => { weight("uuid-move", new Date(NOW - DAY)), ]); expect(next.entries[0].status).toBe("updated"); - expect(transactions).toBe(1); + // The set insert skips it on the external identity, the reconciler moves it. + expect(transactions).toBe(2); }); it("4: the same id with another sleep stage still reconciles", async () => { @@ -389,7 +460,7 @@ describe("exact-duplicate prefilter (v1.42)", () => { sleepStage: 3, }; const { transactions } = await golden(setup, [entry]); - expect(transactions).toBe(1); + expect(transactions).toBe(2); }); it("5: `stats:` rows are never prefiltered; a second snapshot is superseded", async () => { @@ -615,3 +686,88 @@ describe("folded_window guard (v1.42)", () => { expect(data.entries[0].status).toBe("inserted"); }); }); + +describe("set-based insert (v1.44)", () => { + it("500 fresh samples land in two statements, as the reconciler would have", async () => { + const entries = Array.from({ length: 500 }, (_, i) => + pulse(`fresh-${i}`, new Date(NOW - DAY - i * 60_000), 60 + (i % 30)), + ); + const { next, oldTransactions, transactions } = await golden( + () => undefined, + entries, + ); + expect(next.inserted).toBe(500); + expect(oldTransactions).toBe(502); + expect(transactions).toBe(2); + }); + + it("a mix of fresh, moved and resurrected rows leaves the same table", async () => { + const at = (h: number) => new Date(NOW - DAY - h * HOUR); + const setup = () => { + // Moves: the stored row sits at another instant. + seed({ type: "WEIGHT", externalId: "moved", measuredAt: at(9) }); + // Resurrects: a tombstone at a different instant under the same id. + seed({ + type: "WEIGHT", + externalId: "back", + measuredAt: at(8), + deletedAt: new Date(NOW - HOUR), + }); + // Natural-identity collision under another id: reconciled, not inserted. + seed({ type: "WEIGHT", externalId: "other", measuredAt: at(3) }); + }; + const { next, transactions } = await golden(setup, [ + weight("fresh-a", at(1)), + weight("moved", at(2)), + weight("collides", at(3)), + weight("fresh-b", at(4)), + weight("back", at(5)), + weight("fresh-c", at(6)), + ]); + expect(next.entries.map((e) => e.status)).toEqual([ + "inserted", + "updated", + "updated", + "inserted", + "updated", + "inserted", + ]); + // One set statement, then the three rows it skipped. + expect(transactions).toBe(4); + }); + + it("a row sharing an identity in the batch flushes the buffer first", async () => { + const at = new Date(NOW - DAY); + const { next } = await golden( + () => undefined, + [ + weight("solo", new Date(at.getTime() - HOUR)), + weight("twice", at), + weight("twice", at), + ], + ); + expect(next.entries.map((e) => e.status)).toEqual([ + "inserted", + "inserted", + "duplicate", + ]); + }); + + it("keeps the order when a later reconcile would claim a buffered row's slot", async () => { + // Stored X at t1. C moves X to t5; D and E share t1, so neither is + // buffered. Sequentially C frees t1 before D arrives: D inserts, E + // merges into D. Reconciling D before C would instead take X's row for + // D and leave C to insert, a different table with the same rows. + const t1 = new Date(NOW - DAY - HOUR); + const t5 = new Date(NOW - DAY - 5 * HOUR); + const { next } = await golden( + () => seed({ type: "WEIGHT", externalId: "X", measuredAt: t1 }), + [weight("X", t5), weight("Y", t1), weight("Z", t1)], + ); + expect(next.entries.map((e) => e.status)).toEqual([ + "updated", + "inserted", + "updated", + ]); + }); +}); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 501b03fea..cda75930f 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -66,7 +66,11 @@ import { oppositeMergeSource, type MergeCandidate, } from "@/lib/measurements/cross-source-merge"; -import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; +import { + lockMeasurementUser, + reconcileExternalMeasurement, + type MeasurementReconciliationVerdict, +} from "@/lib/measurements/reconcile-external-measurement"; import { classifyRangeRejection } from "@/lib/measurements/plausibility-gate"; import { FOLDED_WINDOW_REASON, @@ -134,6 +138,12 @@ const MAX_BATCH_ENTRIES = 500; // The cap is a DoS ceiling, not a tight bound. const MAX_BODY_BYTES = 4 * 1024 * 1024; +/** + * Rows per set-based insert statement. 500 entries fit two statements; each + * row binds about fifteen parameters, far under PostgreSQL's 65 535. + */ +const SET_INSERT_CHUNK = 250; + // v1.4.25 (security H-2): cap batch ingest at 60 // batches per user per minute. Healthy iOS sync drains its // HealthKit observer queue in well under one batch per minute (the @@ -635,6 +645,8 @@ async function postBatch(request: NextRequest): Promise { let updatedCount = 0; let duplicateCount = 0; let failedCount = 0; + // v1.44 — subset of `insertedCount` written by the set-based insert. + let setInsertedCount = 0; // v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the // MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain // composite-key duplicate). Surfaced as a dedicated wide-event count so @@ -855,6 +867,188 @@ async function postBatch(request: NextRequest): Promise { [...foldedIndexes].map((index) => prepared[index]), ); + /** The reconciler's input for one prepared row. */ + const desiredFor = (p: Prepared) => { + const statsRow = isStatsExternalId(p.row.externalId as string); + return { + userId: user.id, + type: p.row.type as MeasurementType, + value: p.row.value as number, + valueMin: p.row.valueMin as number | null, + valueMax: p.row.valueMax as number | null, + unit: p.row.unit as string, + source: p.row + .source as Prisma.MeasurementUncheckedCreateInput["source"], + measuredAt: p.row.measuredAt as Date, + externalId: p.row.externalId as string, + externalSourceVersion: p.row.externalSourceVersion as string | null, + glucoseContext: p.row + .glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"], + sleepStage: p.row + .sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"], + rhythmClassification: p.row + .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], + deviceType: p.row.deviceType as string | null, + aggregationProvenance: statsRow + ? ("HEALTHKIT_STATISTICS" as const) + : null, + }; + }; + + const pushMergeCandidate = (p: Prepared) => { + if (isMergeableSource(p.row.source as string)) { + inBatchCandidates.push({ + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }); + } + }; + + /** + * Record one row's verdict. `candidate` is false for a row whose merge + * candidate was already pushed when it was buffered (see below). + */ + const applyVerdict = ( + p: Prepared, + verdict: Pick & + Partial<{ + dirtyIdentities: Array<{ type: MeasurementType; measuredAt: Date }>; + error: { code?: string }; + }>, + candidate: boolean, + ) => { + switch (verdict.status) { + case "inserted": + results[p.index] = { index: p.index, status: "inserted" }; + insertedCount++; + insertedPrepared.push(p); + writtenIdentities.push({ + type: p.row.type as MeasurementType, + measuredAt: p.row.measuredAt as Date, + }); + break; + case "updated": + case "resurrected": + results[p.index] = { index: p.index, status: "updated" }; + updatedCount++; + writtenIdentities.push( + { + type: p.row.type as MeasurementType, + measuredAt: p.row.measuredAt as Date, + }, + ...(verdict.dirtyIdentities ?? []), + ); + break; + case "duplicate": + results[p.index] = { index: p.index, status: "duplicate" }; + duplicateCount++; + break; + case "failed": + results[p.index] = { + index: p.index, + status: "failed", + reason: verdict.error?.code ?? "write_failed", + }; + failedCount++; + return; + case "rejected_range": + // The reconciler carries the same plausibility gate this route + // already applied above, so reaching here means the two disagreed — + // report it exactly as the earlier guard would have, and let the + // wide-event tally record which end refused. + results[p.index] = outOfRangeResult( + p.index, + p.row.type as string, + p.row.value as number, + ); + return; + } + if (candidate) pushMergeCandidate(p); + }; + + const reconcileRow = (p: Prepared) => + prisma.$transaction((tx) => + reconcileExternalMeasurement(tx, desiredFor(p), { + exactExternalMatch: isStatsExternalId(p.row.externalId as string) + ? "update" + : "duplicate", + }), + ); + + // v1.44 — set-based inserts. A fresh sample used to cost one interactive + // transaction of about ten round trips (savepoint, three advisory locks, + // two candidate reads, the insert), so a 500-entry batch of new samples + // was 500 of those in a row: the 2–15 s outliers. + // + // The reconciler inserts exactly when no row, live or tombstoned, holds + // the entry's external identity `(type, source, externalId)` or its + // natural identity `(type, measuredAt, source, sleepStage)`. Both are + // full unique indexes (the natural one NULLS NOT DISTINCT), so + // `INSERT … ON CONFLICT DO NOTHING` inserts exactly those rows and skips + // every other; a skipped row then goes through the reconciler as before + // and gets its own verdict (update, resurrect, duplicate). The insert + // runs under the same per-account advisory lock the reconciler takes, so + // the two serialise like two reconcilers would. + // + // Only a row whose external and natural key each occur once in the batch + // is buffered, and the buffer is flushed before any other row reaches the + // reconciler. Such a row shares no identity with another entry, so no + // other entry's reconcile can create, move or retire a row at its + // identity, and inserting it out of turn changes nothing. Rows the insert + // skips are reconciled in their original order. A buffered row's merge + // candidate is pushed when it is buffered, as the sequential loop pushed + // it after a successful write; the only case that differs is a write that + // fails outright, where the candidate stays. + const pendingInserts: Prepared[] = []; + const flushPendingInserts = async () => { + if (pendingInserts.length === 0) return; + const batch = pendingInserts.splice(0, pendingInserts.length); + const landed = new Set(); + try { + for (let i = 0; i < batch.length; i += SET_INSERT_CHUNK) { + const slice = batch.slice(i, i + SET_INSERT_CHUNK); + const rows = await prisma.$transaction(async (tx) => { + await lockMeasurementUser(tx, user.id); + return tx.measurement.createManyAndReturn({ + data: slice.map((p) => ({ + ...desiredFor(p), + sleepStage: (p.row.sleepStage ?? + null) as Prisma.MeasurementUncheckedCreateInput["sleepStage"], + deletedAt: null, + })), + skipDuplicates: true, + select: { type: true, source: true, externalId: true }, + }); + }); + const keys = new Set( + rows.map((r) => `${r.type}::${r.source}::${r.externalId}`), + ); + for (const p of slice) { + if (keys.has(`${p.row.type}::${p.row.source}::${p.row.externalId}`)) + landed.add(p); + } + } + } catch (err) { + // Whatever did not land goes through the reconciler below, which + // turns a real database fault into a per-entry `failed` verdict. + annotate({ + action: { name: "measurement.batch.set_insert_fallback" }, + meta: { + error_class: err instanceof Error ? err.name : "unknown", + landed: landed.size, + pending: batch.length, + }, + }); + } + setInsertedCount += landed.size; + for (const p of batch) { + if (landed.has(p)) applyVerdict(p, { status: "inserted" }, false); + else applyVerdict(p, await reconcileRow(p), false); + } + }; + for (const p of prepared) { const externalId = p.row.externalId as string; const statsRow = isStatsExternalId(externalId); @@ -895,101 +1089,29 @@ async function postBatch(request: NextRequest): Promise { : { index: p.index, status: "duplicate" }; duplicateCount++; if (folded) foldedWindowCount++; - if (isMergeableSource(p.row.source as string)) { - inBatchCandidates.push({ - type: p.row.type as MeasurementType, - source: p.row.source as string, - value: p.row.value as number, - measuredAt: p.row.measuredAt as Date, - }); - } + pushMergeCandidate(p); continue; } - const verdict = await prisma.$transaction((tx) => - reconcileExternalMeasurement( - tx, - { - userId: user.id, - type: p.row.type as MeasurementType, - value: p.row.value as number, - valueMin: p.row.valueMin as number | null, - valueMax: p.row.valueMax as number | null, - unit: p.row.unit as string, - source: p.row - .source as Prisma.MeasurementUncheckedCreateInput["source"], - measuredAt: p.row.measuredAt as Date, - externalId, - externalSourceVersion: p.row.externalSourceVersion as string | null, - glucoseContext: p.row - .glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"], - sleepStage: p.row - .sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"], - rhythmClassification: p.row - .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], - deviceType: p.row.deviceType as string | null, - aggregationProvenance: statsRow ? "HEALTHKIT_STATISTICS" : null, - }, - { exactExternalMatch: statsRow ? "update" : "duplicate" }, - ), + const naturalKey = naturalKeyOf( + p.row.type, + p.row.source, + p.row.measuredAt as Date, + p.row.sleepStage, ); - - switch (verdict.status) { - case "inserted": - results[p.index] = { index: p.index, status: "inserted" }; - insertedCount++; - insertedPrepared.push(p); - writtenIdentities.push({ - type: p.row.type as MeasurementType, - measuredAt: p.row.measuredAt as Date, - }); - break; - case "updated": - case "resurrected": - results[p.index] = { index: p.index, status: "updated" }; - updatedCount++; - writtenIdentities.push( - { - type: p.row.type as MeasurementType, - measuredAt: p.row.measuredAt as Date, - }, - ...(verdict.dirtyIdentities ?? []), - ); - break; - case "duplicate": - results[p.index] = { index: p.index, status: "duplicate" }; - duplicateCount++; - break; - case "failed": - results[p.index] = { - index: p.index, - status: "failed", - reason: verdict.error.code ?? "write_failed", - }; - failedCount++; - continue; - case "rejected_range": - // The reconciler carries the same plausibility gate this route - // already applied above, so reaching here means the two disagreed — - // report it exactly as the earlier guard would have, and let the - // wide-event tally record which end refused. - results[p.index] = outOfRangeResult( - p.index, - p.row.type as string, - p.row.value as number, - ); - continue; + if ( + externalKeyCounts.get(key) === 1 && + naturalKeyCounts.get(naturalKey) === 1 + ) { + pendingInserts.push(p); + pushMergeCandidate(p); + continue; } - if (isMergeableSource(p.row.source as string)) { - inBatchCandidates.push({ - type: p.row.type as MeasurementType, - source: p.row.source as string, - value: p.row.value as number, - measuredAt: p.row.measuredAt as Date, - }); - } + await flushPendingInserts(); + applyVerdict(p, await reconcileRow(p), true); } + await flushPendingInserts(); } // v1.43 — the hours, after the measurements. One write per slot: the last @@ -1342,6 +1464,9 @@ async function postBatch(request: NextRequest): Promise { // v1.42 — duplicates refused because a live `stats:` row already // covers the sample's hour or day (`folded-window.ts`). folded_window: foldedWindowCount, + // v1.44 — inserts that took the set-based path rather than one + // reconciler transaction each. + set_inserted: setInsertedCount, }, }); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 01de8ed80..3f9cca282 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -79,57 +79,76 @@ export async function register() { // A diagnostic must never break boot. } - const { shouldRunWorker } = await import("@/lib/process-type"); + // The background role starts after the server is up. Awaiting it here + // held every request, `/api/health` included, for the seconds pg-boss + // needs to start, provision its queues and run the boot discovery; the + // probe now answers `starting` meanwhile. A request that enqueues while + // the boss is not bound yet takes the same path as during a producer + // reconnect: a garnish skips its enqueue (the owning cron catches up), + // and a route that needs the worker answers 503 until it is up. + const [{ shouldRunWorker }, { markStartup }] = await Promise.all([ + import("@/lib/process-type"), + import("@/lib/boot/startup-state"), + ]); + markStartup("starting"); if (!shouldRunWorker()) { // Split deployment: web requests still produce queue jobs, but this // process owns no schedules, supervision, or consumers. - try { - const { startGlobalBossProducer } = - await import("@/lib/jobs/boss-instance"); - const boss = await startGlobalBossProducer(process.env.DATABASE_URL); - if (boss) { - const { registerShutdownHandlers } = - await import("@/lib/jobs/reminder/worker-lifecycle"); - registerShutdownHandlers(boss); + void (async () => { + try { + const { startGlobalBossProducer } = + await import("@/lib/jobs/boss-instance"); + const boss = await startGlobalBossProducer(process.env.DATABASE_URL); + if (boss) { + const { registerShutdownHandlers } = + await import("@/lib/jobs/reminder/worker-lifecycle"); + registerShutdownHandlers(boss); + } + markStartup(boss ? "ready" : "failed"); + } catch (err) { + markStartup("failed"); + // Queue-backed garnishes degrade independently; never make the web + // server unavailable because its producer connection failed. + console.error( + "[instrumentation] Failed to start pg-boss producer:", + err, + ); } - } catch (err) { - // Queue-backed garnishes degrade independently; never make the web - // server unavailable because its producer connection failed. - console.error( - "[instrumentation] Failed to start pg-boss producer:", - err, - ); - } + })(); return; } - const { WideEventBuilder } = await import("@/lib/logging/event-builder"); - const { emitIfSampled } = await import("@/lib/logging/transports"); + void (async () => { + const { WideEventBuilder } = await import("@/lib/logging/event-builder"); + const { emitIfSampled } = await import("@/lib/logging/transports"); - // Emit startup event - const startupEvt = new WideEventBuilder("background"); - startupEvt.setBackground({ task_name: "startup" }); + // Emit startup event + const startupEvt = new WideEventBuilder("background"); + startupEvt.setBackground({ task_name: "startup" }); - try { - const { startReminderWorker } = - await import("@/lib/jobs/reminder-worker"); - await startReminderWorker(); + try { + const { startReminderWorker } = + await import("@/lib/jobs/reminder-worker"); + await startReminderWorker(); + markStartup("ready"); - startupEvt.addMeta("reminder_worker", "started"); - startupEvt.finish(); - emitIfSampled(startupEvt.toJSON()); - } catch (err) { - startupEvt.setError(err); - startupEvt.addMeta("reminder_worker", "failed"); - startupEvt.finish(); - emitIfSampled(startupEvt.toJSON()); + startupEvt.addMeta("reminder_worker", "started"); + startupEvt.finish(); + emitIfSampled(startupEvt.toJSON()); + } catch (err) { + markStartup("failed"); + startupEvt.setError(err); + startupEvt.addMeta("reminder_worker", "failed"); + startupEvt.finish(); + emitIfSampled(startupEvt.toJSON()); - // Still log to stderr for container health checks - console.error( - "[instrumentation] CRITICAL: Failed to start reminder worker:", - err, - ); - } + // Still log to stderr for container health checks + console.error( + "[instrumentation] CRITICAL: Failed to start reminder worker:", + err, + ); + } + })(); } } diff --git a/src/lib/boot/startup-state.ts b/src/lib/boot/startup-state.ts new file mode 100644 index 000000000..59cd80a22 --- /dev/null +++ b/src/lib/boot/startup-state.ts @@ -0,0 +1,23 @@ +/** + * Where this process is in bringing up its background role. + * + * The pg-boss worker (or, in a web-only process, the send-only producer) + * starts after the HTTP server is already serving: starting it inside + * `register()` held every request, `/api/health` included, for the seconds + * pg-boss needs to start, provision its queues and run the boot discovery. + * The probe reads this to say `starting` while that is under way rather than + * hanging, and the operator sees a 503 that names a transient state. + * + * `null` means no background role was started in this process at all. + */ +export type StartupPhase = "starting" | "ready" | "failed"; + +const KEY = Symbol.for("healthlog.boot.startupPhase"); + +export function markStartup(phase: StartupPhase): void { + (globalThis as Record)[KEY] = phase; +} + +export function startupPhase(): StartupPhase | null { + return ((globalThis as Record)[KEY] as StartupPhase) ?? null; +} diff --git a/src/lib/measurements/reconcile-external-measurement.ts b/src/lib/measurements/reconcile-external-measurement.ts index d4fb50784..0fdd7b773 100644 --- a/src/lib/measurements/reconcile-external-measurement.ts +++ b/src/lib/measurements/reconcile-external-measurement.ts @@ -140,7 +140,13 @@ function lockKeys(desired: ExternalMeasurementWrite): string[] { ].sort(); } -async function lockUser( +/** + * The per-account advisory lock every cooperative measurement writer takes + * first. Exported for the batch route's set-based insert, which takes it + * around its `INSERT … ON CONFLICT DO NOTHING` so it serialises with the + * reconciler exactly as a second reconciler would. + */ +export async function lockMeasurementUser( tx: PrismaTypes.TransactionClient, userId: string, ): Promise { @@ -259,7 +265,7 @@ export async function reconcileExternalMeasurement( await tx.$executeRawUnsafe(`SAVEPOINT ${SAVEPOINT}`); try { - await lockUser(tx, desired.userId); + await lockMeasurementUser(tx, desired.userId); for (const key of lockKeys(desired)) { await tx.$queryRaw( Prisma.sql` diff --git a/src/lib/openapi/routes/health.ts b/src/lib/openapi/routes/health.ts index 2540e8fe6..90d9221ba 100644 --- a/src/lib/openapi/routes/health.ts +++ b/src/lib/openapi/routes/health.ts @@ -26,11 +26,14 @@ const healthStatusEnum = z.enum(["ok", "degraded"]).meta({ "`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing.", }); -const healthReason = z.enum(["encryption_key_mismatch"]).optional().meta({ - id: "HealthReason", - description: - "Present only when the process refuses to serve for a reason a person has to fix. `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, point the server at the matching database, or start with an empty one; then restart.", -}); +const healthReason = z + .enum(["encryption_key_mismatch", "starting"]) + .optional() + .meta({ + id: "HealthReason", + description: + "Why a `degraded` process is degraded, when it is something other than a component failure. `encryption_key_mismatch`: the process refuses to serve for a reason a person has to fix; the configured encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, point the server at the matching database, or start with an empty one; then restart. `starting`: the database answers but the background role (the pg-boss worker, or the producer of a web-only process) is still coming up after a start. Transient; nothing to fix, poll again. The probe answers at once while it lasts rather than waiting for the start to finish.", + }); const healthPublicResponse = z .object({ status: healthStatusEnum, reason: healthReason }) @@ -92,7 +95,7 @@ export const healthPaths: NonNullable = { }, "503": { description: - "Degraded — the database did not answer, or a role this process runs is not up. Same body shape as the 200, with `status: \"degraded\"`. A restart loop reports this rather than failing to answer, so a monitor can tell 'starting' from 'gone'.", + "Degraded — the database did not answer within two seconds, or a role this process runs is not up. Same body shape as the 200, with `status: \"degraded\"`, and `reason: \"starting\"` while the background role is still coming up after a start. A restart loop reports this rather than failing to answer, so a monitor can tell 'starting' from 'gone'.", content: { "application/json": { schema: z.union([healthAdminResponse, healthPublicResponse]), diff --git a/tests/integration/measurements-batch-perf.test.ts b/tests/integration/measurements-batch-perf.test.ts new file mode 100644 index 000000000..900a4e4d1 --- /dev/null +++ b/tests/integration/measurements-batch-perf.test.ts @@ -0,0 +1,255 @@ +/** + * `POST /api/measurements/batch` under a realistic large batch. + * + * Production saw this route take 2 s and more, up to 15 s, on full + * 500-entry uploads. The cost was one interactive reconciler transaction + * (about ten round trips) per fresh sample. Since v1.44 fresh samples land + * through a set-based `INSERT … ON CONFLICT DO NOTHING` and only rows that + * hit an existing identity reach the reconciler. + * + * This suite pins the shape of the work rather than a machine's speed: + * - a fresh 500-entry batch opens a handful of transactions, not 500; + * - a re-post of the same batch opens none (the duplicate prefilter); + * - the rollup leg runs once per request, not once per row; + * - the wall-clock bound is generous, a tripwire for a regression back to + * per-row work, not a benchmark. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-batch-perf-test"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const rollupCalls = vi.hoisted(() => ({ count: 0 })); +vi.mock("@/lib/rollups/after-measurement-mutation", async () => { + const actual = await vi.importActual< + typeof import("@/lib/rollups/after-measurement-mutation") + >("@/lib/rollups/after-measurement-mutation"); + return { + ...actual, + afterMeasurementMutation: ( + ...args: Parameters + ) => { + rollupCalls.count += 1; + return actual.afterMeasurementMutation(...args); + }, + }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + rollupCalls.count = 0; + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "batch-perf", + email: "batch-perf@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +/** + * What a phone sends after a day offline: dense heart rate, a few HRV and + * oxygen readings, a night of sleep stages, and the day's cumulative totals. + */ +function realisticBatch(dayOffset = 2): Array> { + const day = new Date(Date.now() - dayOffset * 86_400_000); + day.setUTCHours(0, 0, 0, 0); + const at = (minutes: number) => + new Date(day.getTime() + minutes * 60_000).toISOString(); + const entries: Array> = []; + for (let i = 0; i < 400; i++) { + entries.push({ + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value: 55 + (i % 40), + unit: "count/min", + startDate: at(i * 3), + endDate: at(i * 3), + externalId: `perf-hr-${dayOffset}-${i}`, + }); + } + for (let i = 0; i < 40; i++) { + entries.push({ + hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", + value: 30 + i, + unit: "ms", + startDate: at(i * 30 + 1), + endDate: at(i * 30 + 1), + externalId: `perf-hrv-${dayOffset}-${i}`, + }); + entries.push({ + hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation", + value: 0.96, + unit: "%", + startDate: at(i * 30 + 2), + endDate: at(i * 30 + 2), + externalId: `perf-spo2-${dayOffset}-${i}`, + }); + } + for (let i = 0; i < 16; i++) { + entries.push({ + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 30, + unit: "min", + startDate: at(-480 + i * 30), + endDate: at(-450 + i * 30), + externalId: `perf-sleep-${dayOffset}-${i}`, + sleepStage: 3 + (i % 3), + }); + } + const dayKey = day.toISOString().slice(0, 10); + for (const [hk, unit, value] of [ + ["HKQuantityTypeIdentifierStepCount", "count", 9_431], + ["HKQuantityTypeIdentifierActiveEnergyBurned", "kcal", 512], + ["HKQuantityTypeIdentifierAppleExerciseTime", "min", 42], + ["HKQuantityTypeIdentifierAppleStandTime", "min", 180], + ] as const) { + entries.push({ + hkIdentifier: hk, + value, + unit, + startDate: at(0), + endDate: at(18 * 60), + externalId: `stats:${hk}:${dayKey}`, + }); + } + return entries; +} + +async function post(entries: Array>) { + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ entries }), + }), + ); + expect(res.status).toBe(200); + return ( + (await res.json()) as { + data: { + inserted: number; + updated: number; + duplicates: number; + failed: number; + skipped: number; + }; + } + ).data; +} + +describe("POST /api/measurements/batch — large-batch cost (real Postgres)", () => { + it("writes a fresh 500-entry batch in a handful of transactions", async () => { + const { prisma } = await import("@/lib/db"); + const entries = realisticBatch(); + expect(entries).toHaveLength(500); + + const transaction = vi.spyOn(prisma, "$transaction"); + const started = performance.now(); + const result = await post(entries); + const elapsedMs = performance.now() - started; + const transactions = transaction.mock.calls.length; + transaction.mockRestore(); + + expect(result.failed).toBe(0); + expect(result.inserted).toBe(500); + // Two set statements (250 rows each) plus the hour-bucket and checkpoint + // writes. Before v1.44 this was one transaction per row: 500. + expect(transactions).toBeLessThanOrEqual(10); + expect(rollupCalls.count).toBe(1); + expect( + await getPrismaClient().measurement.count({ + where: { userId: TEST_USER_ID }, + }), + ).toBe(500); + // A tripwire, not a benchmark: per-row work on a local container sits + // well above this, the set path well below. + expect(elapsedMs).toBeLessThan(10_000); + console.info( + `[batch-perf] fresh 500: ${Math.round(elapsedMs)} ms, ${transactions} transactions`, + ); + }); + + it("answers a re-post of the same batch without a transaction", async () => { + const { prisma } = await import("@/lib/db"); + const entries = realisticBatch(); + await post(entries); + rollupCalls.count = 0; + + const transaction = vi.spyOn(prisma, "$transaction"); + const started = performance.now(); + const result = await post(entries); + const elapsedMs = performance.now() - started; + const transactions = transaction.mock.calls.length; + transaction.mockRestore(); + + // The four `stats:` rows carry overwrite semantics and reconcile; every + // sample is an exact duplicate and never reaches a transaction. + expect(result.inserted).toBe(0); + expect(result.duplicates + result.updated).toBe(500); + expect(transactions).toBeLessThanOrEqual(8); + expect(elapsedMs).toBeLessThan(10_000); + console.info( + `[batch-perf] re-post 500: ${Math.round(elapsedMs)} ms, ${transactions} transactions`, + ); + }); + + it("keeps the set path's verdicts when the batch overlaps stored rows", async () => { + const entries = realisticBatch(); + await post(entries.slice(0, 250)); + // Move half of the stored heart-rate rows: same id, another instant. + const moved = entries.slice(0, 100).map((e) => ({ + ...e, + startDate: new Date( + Date.parse(e.endDate as string) + 1_000, + ).toISOString(), + endDate: new Date(Date.parse(e.endDate as string) + 1_000).toISOString(), + })); + const result = await post([...moved, ...entries.slice(250)]); + expect(result.failed).toBe(0); + expect(result.updated).toBeGreaterThanOrEqual(100); + expect(result.inserted).toBe(250); + expect( + await getPrismaClient().measurement.count({ + where: { userId: TEST_USER_ID, deletedAt: null }, + }), + ).toBe(500); + }); +}); From 81e1f4cad139cc0e54a14fcddfd1d1d73d2c452b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 20:51:13 +0200 Subject: [PATCH 29/98] fix(workouts): keep tracks and climb for accounts that already had them Migration 0434 turns the route opt-in on for every account that already holds a stored workout track, so its tracks keep arriving after the upgrade; only accounts without one start off. Workouts stored before this release with a track but no elevation get their metres climbed written into Workout.elevationM by a one-time background pass. It runs detached at worker boot, reads one track at a time with a pause between batches of 100, and keeps its position in app_settings, so a restart resumes and a finished pass ends at once. The "starting" health reason is bounded: after five minutes the probe says plain degraded and the process logs one line, and the time the background role took to come up is logged either way. The compose healthcheck's ten-minute start period already keeps those probes from counting; its comment now says so. --- docker-compose.yml | 6 +- .../migration.sql | 21 +++ prisma/schema.prisma | 4 + .../ai-egress-capability-guard.test.ts | 2 + src/app/api/health/__tests__/route.test.ts | 16 ++ src/instrumentation.ts | 29 +++- src/lib/boot/startup-state.ts | 44 +++++- src/lib/jobs/reminder/register-maintenance.ts | 28 ++++ .../workouts/__tests__/climb-backfill.test.ts | 137 ++++++++++++++++++ src/lib/workouts/climb-backfill.ts | 99 +++++++++++++ .../workout-routes-opt-in-migration.test.ts | 86 +++++++++++ 11 files changed, 460 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/0434_workout_routes_keep_and_climb_cursor/migration.sql create mode 100644 src/lib/workouts/__tests__/climb-backfill.test.ts create mode 100644 src/lib/workouts/climb-backfill.ts create mode 100644 tests/integration/workout-routes-opt-in-migration.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index af4954609..c3aee3175 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -522,7 +522,11 @@ services: # minutes (docs/ops/deploy.md), and failed probes in that time must not # read as a dead container. The first probe that answers still ends # the start period, so a normal boot is healthy within 30 seconds of - # listening. + # listening. After it listens, the pg-boss worker starts in the + # background and /api/health answers 503 with reason "starting" until it + # is up (10–60 s under load, reported as "starting" for at most five + # minutes); inside the start period those probes do not count, so a slow + # worker start cannot mark the container unhealthy. start_period: 10m # Optional dedicated worker container — uncomment + set diff --git a/prisma/migrations/0434_workout_routes_keep_and_climb_cursor/migration.sql b/prisma/migrations/0434_workout_routes_keep_and_climb_cursor/migration.sql new file mode 100644 index 000000000..31087e330 --- /dev/null +++ b/prisma/migrations/0434_workout_routes_keep_and_climb_cursor/migration.sql @@ -0,0 +1,21 @@ +-- Nobody who already keeps workout tracks loses them with 0432's opt-in. +-- +-- 0432 added `users.store_workout_routes`, off by default. An account that +-- already holds at least one stored track has been syncing routes all along; +-- for it the setting starts ON, so its tracks keep arriving after the +-- upgrade. Only accounts without any stored track start OFF. Runs once, at +-- the upgrade; a person who turns it off afterwards stays off. +UPDATE "users" AS u +SET "store_workout_routes" = true +WHERE EXISTS ( + SELECT 1 + FROM "workout_routes" AS r + JOIN "workouts" AS w ON w."id" = r."workout_id" + WHERE w."user_id" = u."id" +); + +-- Cursor of the one-time climb backfill (`src/lib/workouts/climb-backfill.ts`): +-- workouts stored before this release with a track but no elevation get their +-- metres climbed from the track, in id order, resumable across restarts. +-- NULL = not started, 'done' = finished. +ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "workout_climb_backfill_cursor" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d6eeead54..3ad51ba8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5421,6 +5421,10 @@ model AppSettings { /// Last dry-run status and result (account ordinals only, no ids); the apply /// step's precondition and the admin card's result. pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json") + /// @internal: progress marker of the one-time workout climb backfill + /// (`src/lib/workouts/climb-backfill.ts`): the last workout id scanned, + /// `done` when finished, NULL before it starts. Operational, never shown. + workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor") @@map("app_settings") } diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 140c21f96..a63ae2a69 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -323,6 +323,8 @@ describe("workout route geometry never reaches an AI path", () => { "lib/jobs/free-text-encryption-backfill.ts": "Seals legacy readable tracks in place.", "lib/workouts/route-geometry-cipher.ts": "The cipher itself.", + "lib/workouts/climb-backfill.ts": + "One-time backfill: derives metres climbed into Workout.elevationM.", }; /** Directories whose code feeds a model. */ diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts index 9c2ce8a16..f2f91a004 100644 --- a/src/app/api/health/__tests__/route.test.ts +++ b/src/app/api/health/__tests__/route.test.ts @@ -213,3 +213,19 @@ describe("GET /api/health while the background role starts", () => { } }); }); + +describe("GET /api/health — the `starting` window is bounded", () => { + it("drops the reason once a start outlasts any real boot", async () => { + const { markStartup, STARTING_MAX_MS } = + await import("@/lib/boot/startup-state"); + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + markStartup("starting", Date.now() - STARTING_MAX_MS - 1_000); + try { + const { response, body } = await health(); + expect(response.status).toBe(503); + expect(body).toEqual({ status: "degraded" }); + } finally { + markStartup("ready"); + } + }); +}); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 3f9cca282..f152e67ca 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -86,11 +86,30 @@ export async function register() { // the boss is not bound yet takes the same path as during a producer // reconnect: a garnish skips its enqueue (the owning cron catches up), // and a route that needs the worker answers 503 until it is up. - const [{ shouldRunWorker }, { markStartup }] = await Promise.all([ + const [ + { shouldRunWorker }, + { markStartup, startupPhase, startupElapsedMs, STARTING_MAX_MS }, + ] = await Promise.all([ import("@/lib/process-type"), import("@/lib/boot/startup-state"), ]); markStartup("starting"); + // One line when the start takes longer than any real boot; from then on + // the probe reports plain `degraded` instead of `starting`. + setTimeout(() => { + if (startupPhase() === "stalled") { + console.warn( + `[boot] The background role is still not up after ${Math.round(STARTING_MAX_MS / 1000)} s; /api/health now reports degraded without "starting".`, + ); + } + }, STARTING_MAX_MS + 1_000).unref(); + const ready = (up: boolean) => { + const elapsed = startupElapsedMs(); + markStartup(up ? "ready" : "failed"); + console.info( + `[boot] background role ${up ? "ready" : "failed"} after ${elapsed ?? 0} ms`, + ); + }; if (!shouldRunWorker()) { // Split deployment: web requests still produce queue jobs, but this // process owns no schedules, supervision, or consumers. @@ -104,9 +123,9 @@ export async function register() { await import("@/lib/jobs/reminder/worker-lifecycle"); registerShutdownHandlers(boss); } - markStartup(boss ? "ready" : "failed"); + ready(boss !== null); } catch (err) { - markStartup("failed"); + ready(false); // Queue-backed garnishes degrade independently; never make the web // server unavailable because its producer connection failed. console.error( @@ -130,13 +149,13 @@ export async function register() { const { startReminderWorker } = await import("@/lib/jobs/reminder-worker"); await startReminderWorker(); - markStartup("ready"); + ready(true); startupEvt.addMeta("reminder_worker", "started"); startupEvt.finish(); emitIfSampled(startupEvt.toJSON()); } catch (err) { - markStartup("failed"); + ready(false); startupEvt.setError(err); startupEvt.addMeta("reminder_worker", "failed"); startupEvt.finish(); diff --git a/src/lib/boot/startup-state.ts b/src/lib/boot/startup-state.ts index 59cd80a22..b7dfafd26 100644 --- a/src/lib/boot/startup-state.ts +++ b/src/lib/boot/startup-state.ts @@ -8,16 +8,48 @@ * The probe reads this to say `starting` while that is under way rather than * hanging, and the operator sees a 503 that names a transient state. * + * The window is bounded. A start that has not finished after + * `STARTING_MAX_MS` is no longer reported as `starting`: it reads `stalled`, + * the probe drops the reason and says plainly `degraded`, and the process + * logs one line saying so. An uptime monitor therefore never sees the + * "transient" label for longer than a real boot takes. + * * `null` means no background role was started in this process at all. */ -export type StartupPhase = "starting" | "ready" | "failed"; +export type StartupPhase = "starting" | "ready" | "failed" | "stalled"; -const KEY = Symbol.for("healthlog.boot.startupPhase"); +/** Longest a start is still called `starting`. Prod boots take 10–60 s. */ +export const STARTING_MAX_MS = 5 * 60_000; -export function markStartup(phase: StartupPhase): void { - (globalThis as Record)[KEY] = phase; +const KEY = Symbol.for("healthlog.boot.startupState"); + +interface State { + phase: Exclude; + since: number; } -export function startupPhase(): StartupPhase | null { - return ((globalThis as Record)[KEY] as StartupPhase) ?? null; +function state(): State | undefined { + return (globalThis as Record)[KEY] as State | undefined; +} + +export function markStartup( + phase: Exclude, + now: number = Date.now(), +): void { + (globalThis as Record)[KEY] = { phase, since: now }; +} + +export function startupPhase(now: number = Date.now()): StartupPhase | null { + const current = state(); + if (!current) return null; + if (current.phase === "starting" && now - current.since > STARTING_MAX_MS) { + return "stalled"; + } + return current.phase; +} + +/** Milliseconds since the current phase began, or null before any start. */ +export function startupElapsedMs(now: number = Date.now()): number | null { + const current = state(); + return current ? now - current.since : null; } diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 4b32a0724..b0e02c042 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -15,6 +15,7 @@ * `intake-slot-dedup-queue`, `tls-pin-monitor-queue`, and `geo-backfill` * guards read THIS module. */ +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { PgBoss } from "pg-boss"; import { GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON } from "@/lib/jobs/geo-backfill"; import { @@ -2166,4 +2167,31 @@ export async function enqueueMaintenanceBootDiscovery(): Promise { err, ); } + + // v1.44 — one-time climb backfill for workouts stored with a track but no + // elevation (`climb-backfill.ts`). Detached: it reads one track at a time + // with a pause between batches and resumes from its cursor on the next + // boot, so it never holds the boot or the heap. A finished pass returns at + // once. + trackBackgroundTask( + (async () => { + const [{ prisma }, { runWorkoutClimbBackfill }] = await Promise.all([ + import("@/lib/db"), + import("@/lib/workouts/climb-backfill"), + ]); + const result = await runWorkoutClimbBackfill({ prisma }); + if (result.scanned > 0) { + workerLog( + "info", + `[workout-climb-backfill] scanned=${result.scanned} filled=${result.filled} done=${result.done}`, + ); + } + })().catch((err) => { + workerLog( + "error", + "[workout-climb-backfill] failed; the next boot resumes from the cursor", + err, + ); + }), + ); } diff --git a/src/lib/workouts/__tests__/climb-backfill.test.ts b/src/lib/workouts/__tests__/climb-backfill.test.ts new file mode 100644 index 000000000..26094032d --- /dev/null +++ b/src/lib/workouts/__tests__/climb-backfill.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; + +import { + CLIMB_BACKFILL_DONE, + runWorkoutClimbBackfill, +} from "../climb-backfill"; + +/** + * An in-memory stand-in: workouts with an optional readable track (the + * legacy column, which `readRouteGeometry` falls back to) and the cursor. + */ +function fake( + workouts: Array<{ + id: string; + elevationM: number | null; + coords?: number[][]; + }>, + cursor: string | null = null, +) { + const state = { cursor, reads: 0 }; + const prisma = { + appSettings: { + findUnique: async () => ({ workoutClimbBackfillCursor: state.cursor }), + upsert: async (args: { + update: { workoutClimbBackfillCursor: string }; + }) => { + state.cursor = args.update.workoutClimbBackfillCursor; + return {}; + }, + }, + workout: { + findMany: async (args: { + where: { id?: { gt: string } }; + take: number; + }) => + workouts + .filter((w) => w.elevationM === null && w.coords) + .filter((w) => !args.where.id || w.id > args.where.id.gt) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, args.take) + .map((w) => ({ id: w.id })), + updateMany: async (args: { + where: { id: string }; + data: { elevationM: number }; + }) => { + const w = workouts.find((x) => x.id === args.where.id)!; + if (w.elevationM !== null) return { count: 0 }; + w.elevationM = args.data.elevationM; + return { count: 1 }; + }, + }, + workoutRoute: { + findUnique: async (args: { where: { workoutId: string } }) => { + state.reads += 1; + const w = workouts.find((x) => x.id === args.where.workoutId); + return w?.coords + ? { + geometry: { type: "LineString", coordinates: w.coords }, + geometryEncrypted: null, + } + : null; + }, + }, + }; + return { prisma: prisma as never, state, workouts }; +} + +describe("runWorkoutClimbBackfill", () => { + it("fills the climb from the track and marks the pass done", async () => { + const f = fake([ + { + id: "a", + elevationM: null, + coords: [ + [8, 50, 100], + [8, 50, 160], + [8, 50, 140], + ], + }, + { + id: "b", + elevationM: 12, + coords: [ + [8, 50, 0], + [8, 50, 500], + ], + }, + { + id: "c", + elevationM: null, + coords: [ + [8, 50], + [8.1, 50.1], + ], + }, + { id: "d", elevationM: null }, + ]); + const result = await runWorkoutClimbBackfill({ + prisma: f.prisma, + pauseMs: 0, + }); + expect(result).toEqual({ scanned: 2, filled: 1, done: true }); + expect(f.workouts.find((w) => w.id === "a")?.elevationM).toBe(60); + // A stored elevation is never overwritten; no altitude stays NULL. + expect(f.workouts.find((w) => w.id === "b")?.elevationM).toBe(12); + expect(f.workouts.find((w) => w.id === "c")?.elevationM).toBeNull(); + expect(f.state.cursor).toBe(CLIMB_BACKFILL_DONE); + }); + + it("resumes after the cursor and never re-reads what it passed", async () => { + const many = Array.from({ length: 250 }, (_, i) => ({ + id: `w${String(i).padStart(4, "0")}`, + elevationM: null as number | null, + coords: [[8, 50]] as number[][], + })); + const f = fake(many); + const first = await runWorkoutClimbBackfill({ + prisma: f.prisma, + pauseMs: 0, + maxBatches: 1, + }); + expect(first).toEqual({ scanned: 100, filled: 0, done: false }); + expect(f.state.cursor).toBe("w0099"); + const rest = await runWorkoutClimbBackfill({ + prisma: f.prisma, + pauseMs: 0, + }); + expect(rest).toEqual({ scanned: 150, filled: 0, done: true }); + expect(f.state.reads).toBe(250); + const again = await runWorkoutClimbBackfill({ + prisma: f.prisma, + pauseMs: 0, + }); + expect(again).toEqual({ scanned: 0, filled: 0, done: true }); + expect(f.state.reads).toBe(250); + }); +}); diff --git a/src/lib/workouts/climb-backfill.ts b/src/lib/workouts/climb-backfill.ts new file mode 100644 index 000000000..c66983c84 --- /dev/null +++ b/src/lib/workouts/climb-backfill.ts @@ -0,0 +1,99 @@ +/** + * One-time backfill: metres climbed for workouts stored before v1.44. + * + * Since v1.44 a kept GPS track fills `Workout.elevationM` when the workout is + * written (`route-climb.ts`), and the workout insight reads only that column. + * Workouts stored earlier with a track but no elevation used to get their + * climb from the track when the insight was built; this pass writes it into + * the column once so they keep it. + * + * Bounded and resumable: workouts are taken in id order, `BATCH` ids at a + * time, and each track is opened, reduced to one number and dropped before + * the next is read, so memory holds one track at most. The last id is stored + * in `AppSettings.workoutClimbBackfillCursor` after every batch; a restart + * continues from there, and `done` ends it for good. A track without an + * altitude channel leaves the column NULL, as before; the cursor has moved + * past it, so it is never read again. Workouts written after the upgrade + * already carry their climb, so one pass in id order is complete. + * + * Not an AI path: the number lands on the workout row, nothing else. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; +import { routeClimbM } from "@/lib/workouts/route-climb"; + +export const CLIMB_BACKFILL_DONE = "done"; +const BATCH = 100; +const SETTINGS_ID = "singleton"; + +export interface ClimbBackfillResult { + scanned: number; + filled: number; + done: boolean; +} + +type Client = Pick; + +export async function runWorkoutClimbBackfill(input: { + prisma: Client; + /** Pause between batches so the pass never crowds out request traffic. */ + pauseMs?: number; + /** Upper bound on batches in one run; the next run resumes. */ + maxBatches?: number; +}): Promise { + const { + prisma, + pauseMs = 250, + maxBatches = Number.POSITIVE_INFINITY, + } = input; + const settings = await prisma.appSettings.findUnique({ + where: { id: SETTINGS_ID }, + select: { workoutClimbBackfillCursor: true }, + }); + let cursor = settings?.workoutClimbBackfillCursor ?? null; + const result: ClimbBackfillResult = { scanned: 0, filled: 0, done: false }; + if (cursor === CLIMB_BACKFILL_DONE) return { ...result, done: true }; + + for (let batch = 0; batch < maxBatches; batch += 1) { + const ids = await prisma.workout.findMany({ + where: { + elevationM: null, + route: { isNot: null }, + ...(cursor ? { id: { gt: cursor } } : {}), + }, + orderBy: { id: "asc" }, + take: BATCH, + select: { id: true }, + }); + for (const { id } of ids) { + result.scanned += 1; + const route = await prisma.workoutRoute.findUnique({ + where: { workoutId: id }, + select: { geometry: true, geometryEncrypted: true }, + }); + const climb = route ? routeClimbM(readRouteGeometry(route)) : null; + if (climb !== null) { + // Only where the column is still empty: a sync that wrote an + // elevation in the meantime wins. + const { count } = await prisma.workout.updateMany({ + where: { id, elevationM: null }, + data: { elevationM: climb }, + }); + result.filled += count; + } + } + const finished = ids.length < BATCH; + cursor = finished ? CLIMB_BACKFILL_DONE : ids[ids.length - 1].id; + await prisma.appSettings.upsert({ + where: { id: SETTINGS_ID }, + create: { id: SETTINGS_ID, workoutClimbBackfillCursor: cursor }, + update: { workoutClimbBackfillCursor: cursor }, + }); + if (finished) { + result.done = true; + return result; + } + if (pauseMs > 0) await new Promise((r) => setTimeout(r, pauseMs)); + } + return result; +} diff --git a/tests/integration/workout-routes-opt-in-migration.test.ts b/tests/integration/workout-routes-opt-in-migration.test.ts new file mode 100644 index 000000000..7a31bd846 --- /dev/null +++ b/tests/integration/workout-routes-opt-in-migration.test.ts @@ -0,0 +1,86 @@ +/** + * Migration 0434: an account that already holds a stored workout track keeps + * the route opt-in ON after the upgrade; an account without one starts OFF. + * + * The container applies every migration before any row exists, so the test + * seeds both kinds of account and runs the migration's UPDATE again, read + * from the migration file itself rather than copied, so it tests the shipped + * statement. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION = join( + process.cwd(), + "prisma/migrations/0434_workout_routes_keep_and_climb_cursor/migration.sql", +); + +function shippedUpdate(): string { + const sql = readFileSync(MIGRATION, "utf8"); + const match = /UPDATE "users"[\s\S]*?;\n/.exec(sql); + if (!match) throw new Error("0434 carries no UPDATE of users"); + return match[0]; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("0434 — route opt-in for accounts that already keep tracks", () => { + it("turns it on only where a track is stored", async () => { + const prisma = getPrismaClient(); + for (const id of ["with-route", "without-route", "workout-no-route"]) { + await prisma.user.create({ + data: { id, username: id, email: `${id}@example.test` }, + }); + } + const started = new Date("2026-09-01T07:00:00Z"); + const ended = new Date("2026-09-01T08:00:00Z"); + const withRoute = await prisma.workout.create({ + data: { + userId: "with-route", + sportType: "running", + startedAt: started, + endedAt: ended, + durationSec: 3600, + source: "APPLE_HEALTH", + externalId: "w-1", + }, + }); + await prisma.workoutRoute.create({ + data: { + workoutId: withRoute.id, + geometryEncrypted: Buffer.from("sealed"), + }, + }); + await prisma.workout.create({ + data: { + userId: "workout-no-route", + sportType: "running", + startedAt: started, + endedAt: ended, + durationSec: 3600, + source: "APPLE_HEALTH", + externalId: "w-2", + }, + }); + + await prisma.$executeRawUnsafe(shippedUpdate()); + + const flags = Object.fromEntries( + ( + await prisma.user.findMany({ + select: { id: true, storeWorkoutRoutes: true }, + }) + ).map((u) => [u.id, u.storeWorkoutRoutes]), + ); + expect(flags).toEqual({ + "with-route": true, + "without-route": false, + "workout-no-route": false, + }); + }); +}); From 30248efaf1081e159e7729ea3fba3c32ff9d851a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:12:23 +0200 Subject: [PATCH 30/98] fix(workouts): count a session synced twice once in strain, briefing and day view The training impulse summed every stored workout row of the day, and the briefing's workout tally and the day view's workout section listed every row, while the workout list collapses a session recorded by the watch and synced again through a second service. All three now run the same cross-source picker. The strain engine keeps the heart-rate series of a dropped twin when the picked row has none, so the collapse never turns a scored session into an unscored one. --- .../workout-canonical-identity-guard.test.ts | 6 + src/lib/day/records.ts | 35 ++++-- src/lib/insights/__tests__/features.test.ts | 24 ++++ .../insights/__tests__/strain-score.test.ts | 96 +++++++++++++++- src/lib/insights/feature-blocks.ts | 38 +++++-- src/lib/insights/strain-score.ts | 104 +++++++++++++++--- .../pick-canonical-workout-rows.ts | 9 ++ 7 files changed, 277 insertions(+), 35 deletions(-) diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 416faa7e5..dfbc5cfbe 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -52,6 +52,12 @@ const IDENTITY_OWNER: Record = { // about to insert, so a duplicate inside one batch is dropped by the rule // the list later applies. "app/api/workouts/batch/route.ts": "app/api/workouts/batch/route.ts", + // The day-total training impulse, the briefing's workout tally and the day + // view's workout section: each counted a session synced by two services + // twice while the list showed it once. + "lib/insights/strain-score.ts": "lib/insights/strain-score.ts", + "lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts", + "lib/day/records.ts": "lib/day/records.ts", }; /** diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index b3581cac9..4d1cd2bf4 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -42,6 +42,7 @@ import { effectiveMoodTz } from "@/lib/mood/date-key"; import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { visitPath } from "@/lib/encounters/visit-links"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -904,14 +905,32 @@ async function assessmentsPart(frame: DayFrame): Promise { /* ─── workouts, cycle ─────────────────────────────────────────────────────── */ async function workoutsPart(frame: DayFrame): Promise { - const rows = await prisma.workout.findMany({ - where: { - userId: frame.userId, - startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, - }, - select: { id: true, sportType: true, startedAt: true, durationSec: true }, - orderBy: { startedAt: "asc" }, - }); + const [stored, account] = await Promise.all([ + prisma.workout.findMany({ + where: { + userId: frame.userId, + startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + }), + prisma.user.findUnique({ + where: { id: frame.userId }, + select: { sourcePriorityJson: true }, + }), + ]); + // One session synced by two services is one workout on the day, as in the + // workout list it links to. + const rows = pickCanonicalWorkoutRows( + stored, + account?.sourcePriorityJson ?? null, + ); return { running: [], events: rows.map((row) => diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 82480c191..f38e096f0 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -557,6 +557,30 @@ describe("extractFeatures — v1.22 workouts block", () => { expect(f.workouts?.last7.totalDistanceKm).toBe(5); expect(f.workouts?.latest?.sportType).toBe("running"); }); + + it("counts a session synced by two services once", async () => { + const startedAt = new Date(Date.now() - 1 * DAY_MS); + prismaMock.workout.findMany.mockResolvedValue([ + { + source: "APPLE_HEALTH", + sportType: "running", + startedAt, + durationSec: 1800, + totalDistanceM: 5000, + }, + { + source: "STRAVA", + sportType: "running", + startedAt: new Date(startedAt.getTime() + 30_000), + durationSec: 1795, + totalDistanceM: 5010, + }, + ]); + const f = await extractFeatures("user-1", false); + expect(f.workouts?.last7.count).toBe(1); + expect(f.workouts?.last7.totalDurationMin).toBe(30); + expect(f.workouts?.last7.totalDistanceKm).toBe(5); + }); }); describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () => { diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 6ac225af0..fcf7173fb 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -32,6 +32,7 @@ import { percentile, resolvePersonalReference, persistStrainScore, + canonicalWorkoutTrimps, STRAIN_SCORE_EXTERNAL_ID_PREFIX, STRAIN_TRIMP_REFERENCE, STRAIN_MIN_TRAINING_DAYS, @@ -152,7 +153,14 @@ describe("saturateToScore", () => { function makePrisma(opts: { energy?: number[]; - workouts?: Array<{ id: string; samples: { samples: unknown } | null }>; + workouts?: Array<{ + id: string; + samples: { samples: unknown } | null; + source?: string; + sportType?: string; + startedAt?: Date; + durationSec?: number; + }>; rhr?: number | null; gender?: string; dateOfBirth?: Date | null; @@ -172,7 +180,17 @@ function makePrisma(opts: { const measurementFindFirst = vi .fn() .mockResolvedValue(opts.rhr == null ? null : { value: opts.rhr }); - const workoutFindMany = vi.fn().mockResolvedValue(opts.workouts ?? []); + // Distinct sessions by default: each fixture workout starts an hour after + // the previous one, so the cross-source picker keeps every row. + const workoutFindMany = vi.fn().mockResolvedValue( + (opts.workouts ?? []).map((w, i) => ({ + source: "APPLE_HEALTH", + sportType: "RUNNING", + startedAt: new Date(Date.UTC(2026, 5, 1, 6 + i)), + durationSec: 1800, + ...w, + })), + ); const upsert = vi.fn().mockResolvedValue({}); const findUnique = vi.fn().mockResolvedValue({ dateOfBirth: @@ -519,3 +537,77 @@ describe("persistStrainScore — personal anchor + cache", () => { expect(cacheArg.create.refPersonal).toBeLessThan(80); }); }); + +describe("canonicalWorkoutTrimps — one impulse per session", () => { + const start = new Date("2026-06-01T07:00:00.000Z"); + const row = ( + id: string, + source: string, + trimp: number, + offsetMin = 0, + sportType = "RUNNING", + ) => ({ + id, + source: source as "APPLE_HEALTH", + sportType, + startedAt: new Date(start.getTime() + offsetMin * 60_000), + durationSec: 1800, + trimp, + }); + + it("counts a session synced by two services once", () => { + const rows = [row("a", "APPLE_HEALTH", 40), row("s", "STRAVA", 38, 1)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([40]); + }); + + it("takes the dropped twin's impulse when the picked row has no series", () => { + // Apple ranks first on the default ladder; its copy carries no HR here. + const rows = [row("a", "APPLE_HEALTH", 0), row("w", "WHOOP", 55, 2)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([55]); + }); + + it("keeps two distinct sessions apart", () => { + const rows = [ + row("a", "APPLE_HEALTH", 40), + row("b", "APPLE_HEALTH", 20, 120), + row("c", "APPLE_HEALTH", 15, 1, "CYCLING"), + ]; + expect( + canonicalWorkoutTrimps(rows, null, (r) => r.trimp).sort((x, y) => x - y), + ).toEqual([15, 20, 40]); + }); + + it("is empty when nothing carries an impulse", () => { + expect( + canonicalWorkoutTrimps([row("a", "APPLE_HEALTH", 0)], null, () => 0), + ).toEqual([]); + }); +}); + +describe("persistStrainScore — duplicate sessions", () => { + it("scores a session stored twice the same as the session stored once", async () => { + const series = { + samples: [ + { t: "2026-06-01T08:00:00.000Z", hr: 150 }, + { t: "2026-06-01T08:30:00.000Z", hr: 155 }, + { t: "2026-06-01T09:00:00.000Z", hr: 150 }, + ], + }; + const startedAt = new Date("2026-06-01T08:00:00.000Z"); + const once = makePrisma({ + rhr: 50, + workouts: [{ id: "w1", samples: series, startedAt }], + }); + const twice = makePrisma({ + rhr: 50, + workouts: [ + { id: "w1", samples: series, startedAt }, + { id: "w2", samples: series, startedAt, source: "STRAVA" }, + ], + }); + const a = await persistStrainScore(once.prisma, "user-1", NOW); + const b = await persistStrainScore(twice.prisma, "user-1", NOW); + expect(a.score).toBeGreaterThan(0); + expect(b.score).toBe(a.score); + }); +}); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 8e5af6078..5e3916870 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -28,6 +28,7 @@ import { readRollupBuckets, ROLLUP_FOLD_WINDOW_MS, } from "@/lib/rollups/measurement-rollups"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import type { AggregatedFeatures, @@ -535,17 +536,32 @@ export async function readWorkoutsBlock( now: number, ): Promise { const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); - const rows = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: { startedAt: "desc" }, - take: 2000, - select: { - sportType: true, - startedAt: true, - durationSec: true, - totalDistanceM: true, - }, - }); + const [stored, account] = await Promise.all([ + prisma.workout.findMany({ + where: { userId, startedAt: { gte: since } }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + take: 2000, + select: { + source: true, + sportType: true, + startedAt: true, + durationSec: true, + totalDistanceM: true, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); + // One session synced by two services (or re-sent by HealthKit) is one + // workout: the same collapse the workout list applies, so the count and the + // minutes here match what the person sees there. The picker returns its rows + // oldest first; the block reads the newest at index 0. + const rows = pickCanonicalWorkoutRows( + stored, + account?.sourcePriorityJson ?? null, + ).reverse(); if (rows.length === 0) return undefined; const tally = (windowDays: number) => { diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 3cfee08f0..52e02f51a 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -72,6 +72,11 @@ import { upsertScoreRow, } from "@/lib/insights/score-row"; import { shiftDateKey } from "@/lib/tz/format"; +import { + pickCanonicalWorkoutRows, + workoutDuplicateSlotKey, + type WorkoutPickerRow, +} from "@/lib/measurements/pick-canonical-workout-rows"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -413,12 +418,28 @@ export async function computeStrainScore( }); const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0); - // The day's workouts + their HR series. - const workouts = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, - select: { id: true, samples: { select: { samples: true } } }, - take: 100, - }); + // The day's workouts + their HR series, with the session identity the + // cross-source picker needs: one run recorded by the watch and synced again + // through a second service is one training impulse, not two. + const [workouts, account] = await Promise.all([ + prisma.workout.findMany({ + where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + samples: { select: { samples: true } }, + }, + take: 100, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); const hrMax = profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; @@ -426,15 +447,24 @@ export async function computeStrainScore( let dayTrimp = 0; let workoutsWithSeries = 0; if (canTrimp) { - for (const w of workouts) { + const trimpOf = (w: (typeof workouts)[number]): number => { const raw = w.samples?.samples; - if (!Array.isArray(raw)) continue; - const series = raw as unknown as HrSample[]; - const trimp = banisterTrimp(series, hrRest!, hrMax!, profile.sex); - if (trimp > 0) { - dayTrimp += trimp; - workoutsWithSeries += 1; - } + if (!Array.isArray(raw)) return 0; + return banisterTrimp( + raw as unknown as HrSample[], + hrRest!, + hrMax!, + profile.sex, + ); + }; + const sessions = canonicalWorkoutTrimps( + workouts, + account?.sourcePriorityJson ?? null, + trimpOf, + ); + for (const trimp of sessions) { + dayTrimp += trimp; + workoutsWithSeries += 1; } } @@ -499,6 +529,52 @@ export async function computeStrainScore( }; } +/** + * One training impulse per workout SESSION, not per stored row. + * + * The same session can be stored more than once: the watch writes it to Apple + * Health and a second service (Strava, WHOOP, a ring) syncs it again, or + * HealthKit re-sends it under a new identifier. The workout list collapses + * those through `pickCanonicalWorkoutRows`; summing every row here counted the + * session twice in the day's TRIMP while the list showed it once. + * + * The picker chooses the row by source order, which says nothing about heart + * rate: the winning row can be one without a series while its dropped twin + * carries one. So the impulse of a slot is the sum over its picked rows, and + * only when that is zero the largest impulse among the slot's dropped rows. + * Returns the positive impulses, one per session. Pure; exported for tests. + */ +export function canonicalWorkoutTrimps< + T extends WorkoutPickerRow & { id: string }, +>( + rows: readonly T[], + priorityJson: unknown, + trimpOf: (row: T) => number, +): number[] { + // By id: the picker may hand back a copy of a row (it fills a missing heart + // rate from a WHOOP twin). + const picked = new Set( + pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), + ); + const slots = new Map(); + for (const row of rows) { + const key = workoutDuplicateSlotKey(row); + const slot = slots.get(key) ?? { picked: 0, dropped: 0 }; + const trimp = trimpOf(row); + if (trimp > 0) { + if (picked.has(row.id)) slot.picked += trimp; + else slot.dropped = Math.max(slot.dropped, trimp); + } + slots.set(key, slot); + } + const out: number[] = []; + for (const slot of slots.values()) { + const trimp = slot.picked > 0 ? slot.picked : slot.dropped; + if (trimp > 0) out.push(trimp); + } + return out; +} + /** * Build the Strain profile (age + sex) and resting HR from the user row + * the latest resting-HR reading. `prisma` is the (worker) client. diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index 50fda8fdf..d426cb1c0 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -127,6 +127,15 @@ function deduplicateWinningRows(rows: T[]): T[] { */ const BUCKET_SLOT_MS = 5 * 60 * 1000; +/** + * The cross-source duplicate slot a workout falls in. Exported for the one + * reader that has to look past the picked row: the strain engine, whose winner + * may carry no heart-rate series while its dropped twin does. + */ +export function workoutDuplicateSlotKey(row: WorkoutPickerRow): string { + return bucketKey(row); +} + function bucketKey(row: WorkoutPickerRow): string { const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS); return `${row.userId ?? ""}:${slot}:${row.sportType}`; From e0eaed2251d22171c64ab533df801e872bc4cc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:18:33 +0200 Subject: [PATCH 31/98] fix(integrations): one provider account per record, borrowed apps return A guardian finishing a connect for a managed profile could attach their own provider account to the profile. Every OAuth callback (Withings, WHOOP, Fitbit, Google Health, Strava, Polar, Oura) now refuses a provider account another record already holds with reason already_linked, shown as its own message on the integrations page. WHOOP's owner_conflict tag becomes already_linked as well. Oura keeps the account id of a grant (personal_info, migration 0441) so the check has something to compare; existing connections fill it at their next connect. A disconnect leaves Withings webhook subscriptions and the Strava app authorisation in place while another record holds the same account, and the Withings webhook routes an account held twice to the record that linked it first instead of whichever row a lookup returned. The provider app copied from a guardian onto a managed profile is now marked (migration 0442) and removed again on disconnect or when that guardian no longer holds a live MANAGE grant: revoke, renounce, removal, handover, account deletion and data wipe. Each removal is audited as integration.client_app.removed. --- messages/de.json | 21 ++- messages/en.json | 21 ++- messages/es.json | 21 ++- messages/fr.json | 21 ++- messages/it.json | 21 ++- messages/ko.json | 21 ++- messages/nl.json | 21 ++- messages/pl.json | 21 ++- .../0441_oura_user_id/migration.sql | 9 + .../0442_client_app_shared_by/migration.sql | 12 ++ prisma/schema.prisma | 20 ++- src/app/api/fitbit/callback/route.ts | 9 + src/app/api/google-health/callback/route.ts | 15 ++ .../api/oura/callback/__tests__/route.test.ts | 21 ++- src/app/api/oura/callback/route.ts | 19 ++- src/app/api/oura/credentials/route.ts | 1 + .../polar/callback/__tests__/route.test.ts | 20 ++- src/app/api/polar/callback/route.ts | 11 ++ src/app/api/settings/data/route.ts | 13 +- .../strava/callback/__tests__/route.test.ts | 27 ++- src/app/api/strava/callback/route.ts | 9 + .../strava/disconnect/__tests__/route.test.ts | 10 +- .../whoop/callback/__tests__/route.test.ts | 4 +- src/app/api/whoop/callback/route.ts | 9 +- .../whoop/disconnect/__tests__/route.test.ts | 1 + .../withings/callback/__tests__/route.test.ts | 38 +++++ src/app/api/withings/callback/route.ts | 24 +++ .../webhook/[token]/__tests__/route.test.ts | 21 ++- .../withings/webhook/__tests__/route.test.ts | 149 ++++++++++------ .../__tests__/oauth-return-outcome.test.ts | 21 +++ .../integrations/connections-panel.tsx | 3 + src/lib/data-wipe/wipe-plan.ts | 2 + src/lib/export/backup-plan.ts | 4 + .../__tests__/connect-target.test.ts | 77 ++++++++- .../disconnect-shared-account.test.ts | 88 ++++++++++ .../integrations/oauth-kit/account-link.ts | 114 +++++++++++++ .../integrations/oauth-kit/connect-target.ts | 113 ++++++++++++- src/lib/integrations/oauth-kit/disconnect.ts | 67 ++++++-- src/lib/managed-profiles/lifecycle.ts | 14 +- src/lib/oura/client.ts | 30 ++++ src/lib/sharing/grants.ts | 7 + src/lib/withings/webhook-handler.ts | 15 +- .../managed-profile-oauth-connect.test.ts | 160 ++++++++++++++++++ 43 files changed, 1168 insertions(+), 157 deletions(-) create mode 100644 prisma/migrations/0441_oura_user_id/migration.sql create mode 100644 prisma/migrations/0442_client_app_shared_by/migration.sql create mode 100644 src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts create mode 100644 src/lib/integrations/oauth-kit/account-link.ts diff --git a/messages/de.json b/messages/de.json index a72dcb006..925dd1686 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6553,7 +6553,8 @@ "nocode": "Withings hat keinen Freigabe-Code übermittelt. Versuche es erneut und erlaube den Zugriff im Withings-Dialog.", "nocreds": "Es sind keine Withings-API-Zugangsdaten hinterlegt. Speichere zuerst Client-ID und Client-Secret, dann verbinde erneut.", "token": "Der Token-Austausch mit Withings ist fehlgeschlagen. Prüfe Client-ID und Client-Secret und versuche es erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut.", + "already_linked": "Dieses Withings-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Withings mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "withingsSynchronize": "Synchronisieren", "withingsDisconnect": "Trennen", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im WHOOP-Dialog.", "nocreds": "WHOOP ist nicht konfiguriert. Hinterlege oben deine WHOOP-OAuth-Zugangsdaten und versuche es erneut.", "token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses WHOOP-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei WHOOP mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.", "nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.", "token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Google-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Google mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "fitbitOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.", "token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", - "retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest." + "retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest.", + "already_linked": "Dieses Fitbit-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Fitbit mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.", "token": "Der Token-Austausch mit Polar ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Polar-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Polar mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "oura": "Oura", "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.", @@ -7058,7 +7063,8 @@ "token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", "issuer": "Die Anmeldeantwort kam nicht vom Autorisierungsserver von Oura, deshalb wurde die Verbindung zu deiner Sicherheit abgebrochen. Bitte starte die Verbindung erneut. Wenn das wieder passiert, sag deinem Administrator Bescheid.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Oura-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Oura mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "integrationSetupGuide": "Einrichtungsanleitung", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Strava-API-Zugangsdaten hinzuzufügen.", "token": "Der Token-Austausch mit Strava ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Strava-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Strava mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..275e51c39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6553,7 +6553,8 @@ "nocode": "Withings did not return an authorization code. Try again and allow access in the Withings dialog.", "nocreds": "No Withings API credentials are stored. Save the client ID and client secret first, then connect again.", "token": "The token exchange with Withings failed. Check the client ID and client secret and try again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Withings account is already connected to another HealthLog profile. Sign in to Withings with the account that belongs to this profile, or disconnect it from the other profile first." }, "withingsSynchronize": "Synchronize", "withingsDisconnect": "Disconnect", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP did not return an authorization code. Try again and allow access in the WHOOP dialog.", "nocreds": "WHOOP is not configured. Add your WHOOP OAuth credentials above and try again.", "token": "The token exchange with WHOOP failed. Please start the connection again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This WHOOP account is already connected to another HealthLog profile. Sign in to WHOOP with the account that belongs to this profile, or disconnect it from the other profile first." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.", "nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.", "token": "The token exchange with Google failed. Please start the connection again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Google account is already connected to another HealthLog profile. Sign in to Google with the account that belongs to this profile, or disconnect it from the other profile first." }, "fitbitOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.", "token": "The token exchange with Fitbit failed. Please start the connection again.", "generic": "Unknown error while connecting. Please start the connection again.", - "retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices." + "retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices.", + "already_linked": "This Fitbit account is already connected to another HealthLog profile. Sign in to Fitbit with the account that belongs to this profile, or disconnect it from the other profile first." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.", "token": "The token exchange with Polar failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Polar account is already connected to another HealthLog profile. Sign in to Polar with the account that belongs to this profile, or disconnect it from the other profile first." }, "oura": "Oura", "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, heart-rate variability, and daily activity.", @@ -7058,7 +7063,8 @@ "token": "The token exchange with Oura failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", "issuer": "The sign-in answer did not come from Oura's own authorization server, so the connection was stopped for your safety. Please start the connection again. If this keeps happening, tell your administrator.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Oura account is already connected to another HealthLog profile. Sign in to Oura with the account that belongs to this profile, or disconnect it from the other profile first." }, "integrationSetupGuide": "Setup guide", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava is not configured on this server. Ask your administrator to add the Strava API credentials.", "token": "The token exchange with Strava failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Strava account is already connected to another HealthLog profile. Sign in to Strava with the account that belongs to this profile, or disconnect it from the other profile first." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/es.json b/messages/es.json index 3239be811..2b08e49a8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6553,7 +6553,8 @@ "nocode": "Withings no devolvió ningún código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Withings.", "nocreds": "No hay credenciales de la API de Withings guardadas. Guarda primero el Client ID y el Client Secret y vuelve a conectar.", "token": "El intercambio de tokens con Withings falló. Comprueba el Client ID y el Client Secret e inténtalo de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Withings ya está conectada a otro perfil de HealthLog. Inicia sesión en Withings con la cuenta de este perfil o desconéctala primero del otro perfil." }, "withingsSynchronize": "Sincronizar", "withingsDisconnect": "Desconectar", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de WHOOP.", "nocreds": "WHOOP no está configurado. Añade tus credenciales OAuth de WHOOP arriba e inténtalo de nuevo.", "token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de WHOOP ya está conectada a otro perfil de HealthLog. Inicia sesión en WHOOP con la cuenta de este perfil o desconéctala primero del otro perfil." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit y Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y concede el acceso en el diálogo de Google.", "nocreds": "Google Health no está configurado. Añade arriba tus credenciales de OAuth de Google e inténtalo de nuevo.", "token": "El intercambio de token con Google falló. Vuelve a iniciar la conexión.", - "generic": "Error desconocido al conectar. Vuelve a iniciar la conexión." + "generic": "Error desconocido al conectar. Vuelve a iniciar la conexión.", + "already_linked": "Esta cuenta de Google ya está conectada a otro perfil de HealthLog. Inicia sesión en Google con la cuenta de este perfil o desconéctala primero del otro perfil." }, "fitbitOauthError": { "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.", "token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", - "retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos." + "retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos.", + "already_linked": "Esta cuenta de Fitbit ya está conectada a otro perfil de HealthLog. Inicia sesión en Fitbit con la cuenta de este perfil o desconéctala primero del otro perfil." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.", "token": "El intercambio de token con Polar falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Polar ya está conectada a otro perfil de HealthLog. Inicia sesión en Polar con la cuenta de este perfil o desconéctala primero del otro perfil." }, "oura": "Oura", "ouraDescription": "Conecta tu cuenta de Oura para sincronizar disposición, fases de sueño, variabilidad cardíaca y actividad diaria.", @@ -7058,7 +7063,8 @@ "token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", "issuer": "La respuesta de inicio de sesión no procedía del servidor de autorización de Oura, así que la conexión se detuvo por tu seguridad. Vuelve a iniciar la conexión. Si sigue ocurriendo, avisa a tu administrador.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Oura ya está conectada a otro perfil de HealthLog. Inicia sesión en Oura con la cuenta de este perfil o desconéctala primero del otro perfil." }, "integrationSetupGuide": "Guía de configuración", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava no está configurado en este servidor. Pide a tu administrador que añada las credenciales de API de Strava.", "token": "El intercambio de tokens con Strava falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Strava ya está conectada a otro perfil de HealthLog. Inicia sesión en Strava con la cuenta de este perfil o desconéctala primero del otro perfil." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..7df4a74fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6553,7 +6553,8 @@ "nocode": "Withings n'a renvoyé aucun code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Withings.", "nocreds": "Aucun identifiant d'API Withings n'est enregistré. Enregistrez d'abord le Client ID et le Client Secret, puis reconnectez.", "token": "L'échange de jetons avec Withings a échoué. Vérifiez le Client ID et le Client Secret, puis réessayez.", - "generic": "Erreur inconnue lors de la connexion. Relancez la connexion." + "generic": "Erreur inconnue lors de la connexion. Relancez la connexion.", + "already_linked": "Ce compte Withings est déjà connecté à un autre profil HealthLog. Connecte-toi à Withings avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "withingsSynchronize": "Synchroniser", "withingsDisconnect": "Déconnecter", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre WHOOP.", "nocreds": "WHOOP n'est pas configuré. Ajoutez vos identifiants OAuth WHOOP ci-dessus et réessayez.", "token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.", - "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "already_linked": "Ce compte WHOOP est déjà connecté à un autre profil HealthLog. Connecte-toi à WHOOP avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit et Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google n'a pas renvoyé de code d'autorisation. Réessaie et autorise l'accès dans la fenêtre Google.", "nocreds": "Google Health n'est pas configuré. Ajoute tes identifiants OAuth Google ci-dessus et réessaie.", "token": "L'échange de jeton avec Google a échoué. Relance la connexion.", - "generic": "Erreur inconnue lors de la connexion. Relance la connexion." + "generic": "Erreur inconnue lors de la connexion. Relance la connexion.", + "already_linked": "Ce compte Google est déjà connecté à un autre profil HealthLog. Connecte-toi à Google avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "fitbitOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.", "token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", - "retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils." + "retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils.", + "already_linked": "Ce compte Fitbit est déjà connecté à un autre profil HealthLog. Connecte-toi à Fitbit avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.", "token": "L'échange de jeton avec Polar a échoué. Veuillez relancer la connexion.", "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", - "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "already_linked": "Ce compte Polar est déjà connecté à un autre profil HealthLog. Connecte-toi à Polar avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "oura": "Oura", "ouraDescription": "Connecte ton compte Oura pour synchroniser readiness, phases de sommeil, variabilité cardiaque et activité quotidienne.", @@ -7058,7 +7063,8 @@ "token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.", "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", "issuer": "La réponse de connexion ne provenait pas du serveur d'autorisation d'Oura, la connexion a donc été interrompue par sécurité. Veuillez relancer la connexion. Si cela se reproduit, prévenez votre administrateur.", - "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "already_linked": "Ce compte Oura est déjà connecté à un autre profil HealthLog. Connecte-toi à Oura avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "integrationSetupGuide": "Guide de configuration", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava n'est pas configuré sur ce serveur. Demande à ton administrateur d'ajouter les identifiants API Strava.", "token": "L'échange de jeton avec Strava a échoué. Relance la connexion.", "rate_limited": "Trop de tentatives de connexion. Attends un instant et réessaie.", - "generic": "Erreur inconnue lors de la connexion. Relance la connexion." + "generic": "Erreur inconnue lors de la connexion. Relance la connexion.", + "already_linked": "Ce compte Strava est déjà connecté à un autre profil HealthLog. Connecte-toi à Strava avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/it.json b/messages/it.json index 85e890708..6dd7ff8a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6553,7 +6553,8 @@ "nocode": "Withings non ha restituito alcun codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Withings.", "nocreds": "Non sono salvate credenziali API di Withings. Salva prima Client ID e Client Secret, poi ricollega.", "token": "Lo scambio di token con Withings non è riuscito. Controlla Client ID e Client Secret e riprova.", - "generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento." + "generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento.", + "already_linked": "Questo account Withings è già collegato a un altro profilo HealthLog. Accedi a Withings con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "withingsSynchronize": "Sincronizza", "withingsDisconnect": "Disconnetti", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di WHOOP.", "nocreds": "WHOOP non è configurato. Aggiungi le tue credenziali OAuth di WHOOP qui sopra e riprova.", "token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account WHOOP è già collegato a un altro profilo HealthLog. Accedi a WHOOP con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit e Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google non ha restituito un codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Google.", "nocreds": "Google Health non è configurato. Aggiungi qui sopra le tue credenziali OAuth di Google e riprova.", "token": "Lo scambio del token con Google non è riuscito. Riavvia la connessione.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Google è già collegato a un altro profilo HealthLog. Accedi a Google con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "fitbitOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.", "token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", - "retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi." + "retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi.", + "already_linked": "Questo account Fitbit è già collegato a un altro profilo HealthLog. Accedi a Fitbit con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.", "token": "Lo scambio del token con Polar è fallito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Polar è già collegato a un altro profilo HealthLog. Accedi a Polar con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "oura": "Oura", "ouraDescription": "Collega il tuo account Oura per sincronizzare prontezza, fasi del sonno, variabilità cardiaca e attività giornaliera.", @@ -7058,7 +7063,8 @@ "token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", "issuer": "La risposta di accesso non proveniva dal server di autorizzazione di Oura, quindi la connessione è stata interrotta per la tua sicurezza. Avvia di nuovo la connessione. Se succede ancora, avvisa il tuo amministratore.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Oura è già collegato a un altro profilo HealthLog. Accedi a Oura con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "integrationSetupGuide": "Guida alla configurazione", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali API di Strava.", "token": "Lo scambio del token con Strava non è riuscito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Strava è già collegato a un altro profilo HealthLog. Accedi a Strava con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..5b88aee4e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6553,7 +6553,8 @@ "nocode": "Withings가 인증 코드를 보내지 않았어요. 다시 시도하고 Withings 창에서 접근을 허용해 주세요.", "nocreds": "저장된 Withings API 자격 증명이 없어요. 클라이언트 ID와 클라이언트 시크릿을 먼저 저장한 뒤 다시 연결해 주세요.", "token": "Withings와의 토큰 교환에 실패했어요. 클라이언트 ID와 클라이언트 시크릿을 확인하고 다시 시도해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Withings 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Withings 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "withingsSynchronize": "동기화", "withingsDisconnect": "연결 해제", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP이 인증 코드를 보내지 않았어요. 다시 시도하고 WHOOP 창에서 접근을 허용해 주세요.", "nocreds": "WHOOP이 설정되지 않았어요. 위에 WHOOP OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "WHOOP과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 WHOOP 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 WHOOP 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google이 인증 코드를 보내지 않았어요. 다시 시도하고 Google 창에서 접근을 허용해 주세요.", "nocreds": "Google Health가 설정되지 않았어요. 위에 Google OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "Google과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Google 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Google 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "fitbitOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit이 설정되지 않았어요. 위에 Fitbit OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "Fitbit과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", - "retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요." + "retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요.", + "already_linked": "이 Fitbit 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Fitbit 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "이 서버에는 Polar가 설정돼 있지 않아요. 관리자에게 Polar OAuth 자격 증명을 추가해 달라고 요청하세요.", "token": "Polar와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", - "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Polar 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Polar 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "oura": "Oura", "ouraDescription": "Oura 계정을 연결해 준비도, 수면 단계, 심박변이도, 일일 활동을 동기화해요.", @@ -7058,7 +7063,8 @@ "token": "Oura와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", "issuer": "로그인 응답이 Oura의 인증 서버에서 오지 않아서 안전을 위해 연결을 중단했어요. 연결을 다시 시작해 주세요. 계속 발생하면 관리자에게 알려 주세요.", - "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Oura 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Oura 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "integrationSetupGuide": "설정 가이드", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "이 서버에는 Strava가 설정돼 있지 않아요. 관리자에게 Strava API 자격 증명을 추가해 달라고 요청하세요.", "token": "Strava와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", - "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Strava 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Strava 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/nl.json b/messages/nl.json index c44560507..72dc4a3d4 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6553,7 +6553,8 @@ "nocode": "Withings heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Withings-venster.", "nocreds": "Er zijn geen Withings-API-inloggegevens opgeslagen. Sla eerst de client-ID en het clientgeheim op en koppel daarna opnieuw.", "token": "De tokenuitwisseling met Withings is mislukt. Controleer de client-ID en het clientgeheim en probeer het opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Withings-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Withings in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "withingsSynchronize": "Synchroniseren", "withingsDisconnect": "Ontkoppelen", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het WHOOP-venster.", "nocreds": "WHOOP is niet geconfigureerd. Voeg hierboven je WHOOP-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met WHOOP is mislukt. Start de koppeling opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit WHOOP-account is al gekoppeld aan een ander HealthLog-profiel. Log bij WHOOP in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Google-venster.", "nocreds": "Google Health is niet geconfigureerd. Voeg hierboven je Google-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met Google is mislukt. Start de koppeling opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Google-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Google in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "fitbitOauthError": { "csrf1": "De beveiligingscontrole is mislukt omdat de browsercookie ontbrak. Start de koppeling opnieuw in dezelfde browser.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit is niet geconfigureerd. Voeg hierboven je Fitbit-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met Fitbit is mislukt. Start de koppeling opnieuw.", "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", - "retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest." + "retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest.", + "already_linked": "Dit Fitbit-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Fitbit in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar is niet geconfigureerd op deze server. Vraag je beheerder om de Polar-OAuth-inloggegevens toe te voegen.", "token": "De tokenuitwisseling met Polar is mislukt. Start de koppeling opnieuw.", "rate_limited": "Te veel koppelingspogingen. Wacht even en probeer het opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Polar-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Polar in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "oura": "Oura", "ouraDescription": "Koppel je Oura-account om paraatheid, slaapfasen, hartslagvariabiliteit en dagelijkse activiteit te synchroniseren.", @@ -7058,7 +7063,8 @@ "token": "De tokenuitwisseling met Oura is mislukt. Start de koppeling opnieuw.", "rate_limited": "Te veel koppelingspogingen. Wacht even en probeer het opnieuw.", "issuer": "Het inlogantwoord kwam niet van de eigen autorisatieserver van Oura, daarom is de koppeling voor je veiligheid gestopt. Start de koppeling opnieuw. Als dit blijft gebeuren, laat het dan je beheerder weten.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Oura-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Oura in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "integrationSetupGuide": "Installatiehandleiding", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava is niet geconfigureerd op deze server. Vraag je beheerder om de Strava-API-inloggegevens toe te voegen.", "token": "Het uitwisselen van het token met Strava is mislukt. Start het koppelen opnieuw.", "rate_limited": "Te veel koppelpogingen. Wacht even en probeer het opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start het koppelen opnieuw." + "generic": "Onbekende fout bij het koppelen. Start het koppelen opnieuw.", + "already_linked": "Dit Strava-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Strava in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/pl.json b/messages/pl.json index c1812715f..f1b04e0bc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6553,7 +6553,8 @@ "nocode": "Withings nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Withings.", "nocreds": "Brak zapisanych danych dostępowych API Withings. Najpierw zapisz Client ID i Client Secret, potem połącz ponownie.", "token": "Wymiana tokenów z Withings nie powiodła się. Sprawdź Client ID i Client Secret i spróbuj ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Withings jest już połączone z innym profilem HealthLog. Zaloguj się do Withings kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "withingsSynchronize": "Synchronizuj", "withingsDisconnect": "Rozłącz", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie WHOOP.", "nocreds": "WHOOP nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth WHOOP i spróbuj ponownie.", "token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto WHOOP jest już połączone z innym profilem HealthLog. Zaloguj się do WHOOP kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit i Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.", "nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.", "token": "Wymiana tokenu z Google nie powiodła się. Rozpocznij połączenie ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Google jest już połączone z innym profilem HealthLog. Zaloguj się do Google kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "fitbitOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.", "token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", - "retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń." + "retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń.", + "already_linked": "To konto Fitbit jest już połączone z innym profilem HealthLog. Zaloguj się do Fitbit kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.", "token": "Wymiana tokena z Polar nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Polar jest już połączone z innym profilem HealthLog. Zaloguj się do Polar kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "oura": "Oura", "ouraDescription": "Połącz konto Oura, aby synchronizować gotowość, fazy snu, zmienność rytmu serca i dzienną aktywność.", @@ -7058,7 +7063,8 @@ "token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", "issuer": "Odpowiedź logowania nie pochodziła z serwera autoryzacji Oura, więc połączenie zostało przerwane dla Twojego bezpieczeństwa. Rozpocznij połączenie ponownie. Jeśli to się powtarza, powiadom administratora.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Oura jest już połączone z innym profilem HealthLog. Zaloguj się do Oura kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "integrationSetupGuide": "Przewodnik konfiguracji", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych API Strava.", "token": "Wymiana tokenu ze Strava nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Strava jest już połączone z innym profilem HealthLog. Zaloguj się do Strava kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "appleHealth": { "title": "Apple Health", diff --git a/prisma/migrations/0441_oura_user_id/migration.sql b/prisma/migrations/0441_oura_user_id/migration.sql new file mode 100644 index 000000000..d0752839a --- /dev/null +++ b/prisma/migrations/0441_oura_user_id/migration.sql @@ -0,0 +1,9 @@ +-- The Oura account id behind a connection. +-- +-- Read from `personal_info` at connect, so a connect that would attach an +-- Oura account another record already holds can be refused. Nullable and +-- unset for every existing row: a current connection is never asked to +-- reconnect, the id fills in at its next connect. + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_user_id" TEXT; +CREATE INDEX IF NOT EXISTS "users_oura_user_id_idx" ON "users" ("oura_user_id"); diff --git a/prisma/migrations/0442_client_app_shared_by/migration.sql b/prisma/migrations/0442_client_app_shared_by/migration.sql new file mode 100644 index 000000000..c59acb756 --- /dev/null +++ b/prisma/migrations/0442_client_app_shared_by/migration.sql @@ -0,0 +1,12 @@ +-- Which provider apps on a record were copied from a guardian, and from whom. +-- +-- A guardian connecting a provider for a managed profile lends the profile +-- their own provider app (client id and secret, still sealed). The marker lets +-- the copy be removed when the provider is disconnected or the guardian's +-- MANAGE grant ends, instead of the secret staying on the profile for good. +-- +-- Nullable and unset for every existing row. A copy made before this column +-- existed carries no marker and is left as it is: there is no reliable way to +-- tell it from an app the record holds of its own. + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "client_app_shared_by" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 254c052d1..203479c7a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -296,6 +296,18 @@ model User { /// v1.44 — the Oura history backfill finished (self-converging discovery /// predicate `oura_backfill_completed_at IS NULL`). Cleared on connect. ouraBackfillCompletedAt DateTime? @map("oura_backfill_completed_at") + /// v1.44 — the Oura account id (`personal_info.id`) of the current grant, + /// read at connect when the `personal` scope is granted. A connect whose + /// account another record already holds is refused + /// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect. + ouraUserId String? @map("oura_user_id") + /// v1.44 — which provider apps on this record were copied from a + /// guardian at connect, and from whom: `{ "": "" }`. + /// A managed profile has no settings page for its own app, so a guardian's is + /// borrowed (`shareGuardianClientApp`). The copy is removed again when the + /// provider is disconnected or that guardian no longer holds a live MANAGE + /// grant on the record (`src/lib/integrations/oauth-kit/connect-target.ts`). + clientAppSharedBy Json? @map("client_app_shared_by") // v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app // level). Strava caps every newly-created API app at single-player mode @@ -5438,16 +5450,16 @@ model AppSettings { /// Active pulse day-mean method (CHECK in migration 0407). Readers: every /// process's mirror (`pulse-day-mean-method.ts`) and the rollup tier /// (`storedHourWeightSql`); the admin status route. - pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method") + pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method") /// When the admin applied the current method; shown on the admin card. - pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at") + pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at") /// Last dry-run status and result (account ordinals only, no ids); the apply /// step's precondition and the admin card's result. - pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json") + pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json") /// @internal: progress marker of the one-time workout climb backfill /// (`src/lib/workouts/climb-backfill.ts`): the last workout id scanned, /// `done` when finished, NULL before it starts. Operational, never shown. - workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor") + workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor") @@map("app_settings") } diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 84af494a0..c553c0aff 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -163,6 +167,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const profile = await fetchProfile(tokens.access_token); const fitbitUserId = resolveFitbitUserId(profile); + // One Fitbit account, one record. + if (await accountLinkedElsewhere("fitbit", fitbitUserId, user.id)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); await prisma.fitbitConnection.upsert({ diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 71d6cf67b..821c81530 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -176,6 +180,17 @@ export const GET = apiHandler(async (request: NextRequest) => { const profile = await fetchProfile(tokens.access_token); const googleUserId = resolveGoogleHealthUserId(profile); + // One Google account, one record. + if ( + await accountLinkedElsewhere( + "google-health", + googleUserId, + recordUserId, + ) + ) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); const scope = tokens.scope ?? getGoogleHealthScopeString(); diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 342c6d1df..215b7a481 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -38,7 +38,7 @@ vi.mock("@/lib/api-handler", () => ({ })); vi.mock("@/lib/db", () => ({ - prisma: { user: { update: vi.fn() } }, + prisma: { user: { update: vi.fn(), findMany: vi.fn(async () => []) } }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) })); @@ -79,6 +79,7 @@ vi.mock("@/lib/oura/client", async (importOriginal) => ({ await importOriginal() ).isAcceptedOuraIssuer, exchangeCode: exchangeMock, + fetchOuraAccountId: vi.fn(async () => "oura-account-1"), })); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, @@ -179,8 +180,26 @@ describe("GET /api/oura/callback", () => { userId: "u1", actorUserId: null, }); + expect(data.ouraUserId).toBe("oura-account-1"); expect(res.headers.get("location")).toContain("oura=connected"); }); + + it("refuses an Oura account another record already holds", async () => { + const { prisma } = await import("@/lib/db"); + vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ + { id: "guardian-1" }, + ] as never); + const res = await GET( + makeReq({ code: "auth-code", state: "S", cookie: "S" }), + ); + expect(res.headers.get("location")).toContain("reason=already_linked"); + expect(prisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { ouraUserId: "oura-account-1", id: { not: "u1" } }, + }), + ); + expect(userUpdate).not.toHaveBeenCalled(); + }); }); // Oura's current authorization server names itself on the callback diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 253ac4f60..ec67933d4 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -6,7 +6,15 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode, isAcceptedOuraIssuer } from "@/lib/oura/client"; +import { + exchangeCode, + fetchOuraAccountId, + isAcceptedOuraIssuer, +} from "@/lib/oura/client"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, @@ -133,6 +141,14 @@ async function completeCallback( const tokens = await exchangeCode(code, creds, iss); + // One Oura account, one record: a guardian signed in to their own Oura + // account must not attach it to the profile they manage. + const ouraUserId = await fetchOuraAccountId(tokens.access_token); + if (await accountLinkedElsewhere("oura", ouraUserId, userId)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } + // The token response names the granted scopes when Oura sends them; a // permission the person unticked on the consent screen is known from it // at once. Without them, the first sync finds out per collection. @@ -146,6 +162,7 @@ async function completeCallback( data: { ouraAccessTokenEncrypted: encrypt(tokens.access_token), ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token), + ouraUserId, ouraScopeDenied: missing && missing.length > 0 ? missing : Prisma.DbNull, // Open the history backfill again: a first connect has none yet, diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index d6b696c67..327faee25 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -98,6 +98,7 @@ export const DELETE = apiHandler(async () => { ouraAccessTokenEncrypted: null, ouraRefreshTokenEncrypted: null, ouraScopeDenied: Prisma.DbNull, + ouraUserId: null, }, }); await clearOuraClientCredentials(user.id); diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index 21d925ad3..84c255e1a 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -58,7 +58,9 @@ vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); -vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { update: vi.fn(), findMany: vi.fn(async () => []) } }, +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: () => ({ @@ -68,7 +70,10 @@ vi.mock("@/lib/logging/context", () => ({ }), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); -vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock })); vi.mock("@/lib/polar/client", () => ({ exchangeCode: exchangeCodeMock, @@ -123,6 +128,17 @@ describe("GET /api/polar/callback", () => { expect(markReconnectedMock).toHaveBeenCalledWith("u1", "polar"); }); + it("refuses a Polar member another record already holds, before registering it", async () => { + const { prisma } = await import("@/lib/db"); + vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ + { id: "guardian-1", polarUserIdEncrypted: "enc:42" }, + ] as never); + const res = await run(makeReq()); + expect(res.headers.get("location")).toContain("reason=already_linked"); + expect(registerUserMock).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it("rejects a CSRF cookie/param mismatch before exchanging the code", async () => { stateMatchesCookieMock.mockReturnValue(false); const res = await run(makeReq()); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index f67765b43..6bb84b67e 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -123,6 +127,13 @@ async function completeCallback( } const polarUserId = String(tokens.x_user_id); + // One Polar member, one record. Checked before the registration so a + // refused connect registers nothing. + if (await accountLinkedElsewhere("polar", polarUserId, userId)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } + // Register the user with the Polar app (idempotent; 409 = already linked). await registerUser(tokens.access_token, polarUserId); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index bc9485205..d18710799 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -1,3 +1,4 @@ +import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { @@ -92,7 +93,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // through AccountGrant removal. A queued Guardian egress claim thus // rechecks after this wipe commits instead of sending from a grant // this transaction has already deleted. - await protectManagedProfilesDuringDataWipe(tx, userId); + const affectedProfiles = await protectManagedProfilesDuringDataWipe( + tx, + userId, + ); const perModel: Record = {}; for (const model of WIPE_MODELS) { @@ -114,6 +118,13 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // the objects and removes it. await requestOffhostPurge(tx, userId, "data_wiped"); + // A guardian's wipe ends their grants; a provider app a profile + // borrowed from them goes with those grants. + for (const profileId of affectedProfiles) { + if (profileId !== userId) + await releaseUnbackedClientApps(profileId, tx); + } + return perModel; }, { diff --git a/src/app/api/strava/callback/__tests__/route.test.ts b/src/app/api/strava/callback/__tests__/route.test.ts index 2594d1adb..a2f0f15de 100644 --- a/src/app/api/strava/callback/__tests__/route.test.ts +++ b/src/app/api/strava/callback/__tests__/route.test.ts @@ -13,6 +13,7 @@ const { exchangeCodeMock, getCredsMock, userFindUnique, + userFindMany, userUpdate, markReconnectedMock, } = vi.hoisted(() => ({ @@ -21,6 +22,7 @@ const { exchangeCodeMock: vi.fn(), getCredsMock: vi.fn(), userFindUnique: vi.fn(), + userFindMany: vi.fn(), userUpdate: vi.fn(async () => ({})), markReconnectedMock: vi.fn(async () => {}), })); @@ -62,7 +64,13 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: userFindUnique, update: userUpdate } }, + prisma: { + user: { + findUnique: userFindUnique, + findMany: userFindMany, + update: userUpdate, + }, + }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) })); vi.mock("@/lib/logging/context", () => ({ @@ -116,6 +124,7 @@ beforeEach(() => { athlete: { id: 42 }, }); userFindUnique.mockResolvedValue({ stravaAthleteId: null }); + userFindMany.mockResolvedValue([]); getGlobalBossMock.mockReturnValue({ send: bossSend }); }); @@ -142,6 +151,22 @@ describe("GET /api/strava/callback — S-1 backfill enqueue", () => { }); }); +describe("GET /api/strava/callback — one athlete, one record", () => { + it("refuses an athlete another record already holds", async () => { + userFindMany.mockResolvedValueOnce([{ id: "guardian-1" }]); + + const res = await callback(); + + expect(res.headers.get("location")).toContain("reason=already_linked"); + expect(userFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { stravaAthleteId: "42", id: { not: "u1" } }, + }), + ); + expect(userUpdate).not.toHaveBeenCalled(); + }); +}); + describe("GET /api/strava/callback — claim only what can be exchanged", () => { it("does not claim the state for a callback without a code", async () => { const res = await callback("state=signed-state&error=access_denied"); diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index b6d184849..9e0c4c08e 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -130,6 +134,11 @@ async function completeCallback( annotate({ meta: { reason: "cross_user" } }); return ERR("cross_user"); } + // One Strava athlete, one record. + if (await accountLinkedElsewhere("strava", athleteId, userId)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } await prisma.user.update({ where: { id: userId }, diff --git a/src/app/api/strava/disconnect/__tests__/route.test.ts b/src/app/api/strava/disconnect/__tests__/route.test.ts index ee03d9dd0..25d4450bb 100644 --- a/src/app/api/strava/disconnect/__tests__/route.test.ts +++ b/src/app/api/strava/disconnect/__tests__/route.test.ts @@ -11,7 +11,15 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); -vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + update: vi.fn(), + findUnique: vi.fn(async () => null), + findMany: vi.fn(async () => []), + }, + }, +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: () => null, diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index c7750844f..7ceec9334 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -165,7 +165,7 @@ describe("GET /api/whoop/callback custom-scheme redirect", () => { const res = await GET(makeReq("nonce-owner")); expect(res.headers.get("location")).toBe( - "https://app.example/settings/integrations?whoop=error&reason=owner_conflict", + "https://app.example/settings/integrations?whoop=error&reason=already_linked", ); expect(res.headers.get("location")).not.toContain("42"); expect(connUpsert).not.toHaveBeenCalled(); @@ -188,7 +188,7 @@ describe("GET /api/whoop/callback custom-scheme redirect", () => { const res = await GET(makeReq("nonce-race")); expect(res.headers.get("location")).toBe( - "dev.healthlog.app://whoop?whoop=error&reason=owner_conflict", + "dev.healthlog.app://whoop?whoop=error&reason=already_linked", ); expect(res.headers.get("location")).not.toContain("42"); }); diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 89bc61364..fdf5fb1cd 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { ALREADY_LINKED_REASON } from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -213,8 +214,8 @@ export const GET = apiHandler(async (request: NextRequest) => { select: { userId: true }, }); if (owner && owner.userId !== userId) { - annotate({ meta: { reason: "owner_conflict" } }); - return outcomeRedirect(returnScheme, "error", "owner_conflict"); + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return outcomeRedirect(returnScheme, "error", ALREADY_LINKED_REASON); } try { @@ -239,8 +240,8 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } catch (err) { if (isP2002(err)) { - annotate({ meta: { reason: "owner_conflict" } }); - return outcomeRedirect(returnScheme, "error", "owner_conflict"); + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return outcomeRedirect(returnScheme, "error", ALREADY_LINKED_REASON); } throw err; } diff --git a/src/app/api/whoop/disconnect/__tests__/route.test.ts b/src/app/api/whoop/disconnect/__tests__/route.test.ts index f385e835d..d3f6dcf4c 100644 --- a/src/app/api/whoop/disconnect/__tests__/route.test.ts +++ b/src/app/api/whoop/disconnect/__tests__/route.test.ts @@ -8,6 +8,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { whoopConnection: { findUnique: vi.fn(), delete: vi.fn() }, + user: { findUnique: vi.fn(async () => null) }, }, })); diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts index 89ff7a13e..98b9b96c1 100644 --- a/src/app/api/withings/callback/__tests__/route.test.ts +++ b/src/app/api/withings/callback/__tests__/route.test.ts @@ -67,6 +67,7 @@ vi.mock("@/lib/db", () => { }, withingsConnection: { upsert: vi.fn(), + findMany: vi.fn(async () => []), }, oAuthCallbackClaim: { createMany: vi.fn(async () => ({ count: 1 })) }, $transaction: async (run: (tx: unknown) => unknown) => run(prisma), @@ -207,6 +208,43 @@ describe("withings/callback atomic nonce consumption (M3)", () => { expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "replay" } }); }); + it("a Withings account another record already holds is refused with reason=already_linked, nothing stored", async () => { + vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce({ + nonce: NONCE, + userId: "user-1", + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + createdAt: new Date(), + } as never); + const { getUserWithingsCredentials } = + await import("@/lib/withings/credentials"); + vi.mocked(getUserWithingsCredentials).mockResolvedValue({ + clientId: "cid", + clientSecret: "csec", + } as never); + const { exchangeCode } = await import("@/lib/withings/client"); + vi.mocked(exchangeCode).mockResolvedValue({ + userid: "wuid", + access_token: "atok", + refresh_token: "rtok", + expires_in: 3600, + } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { userId: "guardian-1" }, + ] as never); + + const res = await GET(callbackRequest(NONCE, NONCE)); + + expect(res.headers.get("location")).toBe( + `${APP_URL}/settings/integrations?withings=error&reason=already_linked`, + ); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { withingsUserId: "wuid", userId: { not: "user-1" } }, + }), + ); + expect(prisma.withingsConnection.upsert).not.toHaveBeenCalled(); + }); + it("non-P2025 delete failure (e.g. connection drop) surfaces a Wide-Event warning carrying err.name only, and bounces to the error page", async () => { // A real infra failure on the consume-delete must not be // silently swallowed. diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index a151b2250..07522df24 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -207,6 +211,26 @@ export const GET = apiHandler(async (request: NextRequest) => { const tokens = await exchangeCode(code, creds); + // One Withings account, one record. A guardian signed in to their own + // Health Mate account must not attach it to the profile they manage: + // its webhooks would route to one of the two rows, and a disconnect on + // either would unsubscribe the other. + if ( + await accountLinkedElsewhere( + "withings", + String(tokens.userid), + recordUserId, + ) + ) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return NextResponse.redirect( + new URL( + `/settings/integrations?withings=error&reason=${ALREADY_LINKED_REASON}`, + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); const subscriptionRetryAt = new Date(); diff --git a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts index 7980c43e0..d29f7207f 100644 --- a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts @@ -9,7 +9,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { - withingsConnection: { findFirst: vi.fn() }, + withingsConnection: { findMany: vi.fn() }, }, })); @@ -54,6 +54,7 @@ const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([]); process.env.WITHINGS_WEBHOOK_SECRET = "test-secret"; vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -92,10 +93,12 @@ function paramsFor(token: string) { describe("POST /api/withings/webhook/[token] (path-segment secret)", () => { it("returns 200 + triggers sync when the path token matches WITHINGS_WEBHOOK_SECRET", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-42", - withingsUserId: "wu-1", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-42", + withingsUserId: "wu-1", + }, + ] as never); const res = await POST( jsonRequest("test-secret", { userid: "wu-1" }), @@ -105,9 +108,9 @@ describe("POST /api/withings/webhook/[token] (path-segment secret)", () => { const body = (await res.json()) as { status: string }; expect(body.status).toBe("ok"); - expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({ - where: { withingsUserId: "wu-1" }, - }); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { withingsUserId: "wu-1" } }), + ); expect(syncUserMeasurements).toHaveBeenCalledTimes(1); expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); }); @@ -119,7 +122,7 @@ describe("POST /api/withings/webhook/[token] (path-segment secret)", () => { ); expect(res.status).toBe(401); expect(syncUserMeasurements).not.toHaveBeenCalled(); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); }); it("returns 401 when the path token is the empty string (missing)", async () => { diff --git a/src/app/api/withings/webhook/__tests__/route.test.ts b/src/app/api/withings/webhook/__tests__/route.test.ts index 7a66495ff..f64a7b267 100644 --- a/src/app/api/withings/webhook/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/__tests__/route.test.ts @@ -9,7 +9,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { - withingsConnection: { findFirst: vi.fn() }, + withingsConnection: { findMany: vi.fn() }, }, })); @@ -58,6 +58,7 @@ const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([]); process.env.WITHINGS_WEBHOOK_SECRET = "test-secret"; vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -113,7 +114,7 @@ describe("POST /api/withings/webhook", () => { const res = await POST(jsonRequest({ userid: "wu-1" })); expect(res.status).toBe(401); expect(syncUserMeasurements).not.toHaveBeenCalled(); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); }); it("returns 401 when the X-Withings-Webhook-Secret header is wrong", async () => { @@ -145,10 +146,12 @@ describe("POST /api/withings/webhook", () => { }); it("happy path: valid header secret + JSON body triggers sync for the resolved user", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-42", - withingsUserId: "wu-1", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-42", + withingsUserId: "wu-1", + }, + ] as never); const res = await POST( jsonRequest( @@ -160,23 +163,47 @@ describe("POST /api/withings/webhook", () => { const body = (await res.json()) as { status: string }; expect(body.status).toBe("ok"); - expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({ - where: { withingsUserId: "wu-1" }, - }); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { withingsUserId: "wu-1" } }), + ); expect(syncUserMeasurements).toHaveBeenCalledTimes(1); expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); }); + it("an account two records hold routes to the record that linked it first, ordered by the query", async () => { + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { userId: "first-holder" }, + { userId: "second-holder" }, + ] as never); + + const res = await POST( + jsonRequest( + { userid: "wu-1" }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ); + expect(syncUserMeasurements).toHaveBeenCalledTimes(1); + expect(syncUserMeasurements).toHaveBeenCalledWith("first-holder"); + }); + it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => { const addWarning = vi.fn(); vi.mocked(getEvent).mockReturnValue({ setAuth: vi.fn(), addWarning, } as never); - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-99", - withingsUserId: "wu-2", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-99", + withingsUserId: "wu-2", + }, + ] as never); const res = await POST( jsonRequest({ userid: "wu-2" }, {}, "?secret=test-secret"), @@ -194,10 +221,12 @@ describe("POST /api/withings/webhook", () => { }); it("accepts form-encoded payloads (Withings legacy delivery format)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-form", - withingsUserId: "wu-form", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-form", + withingsUserId: "wu-form", + }, + ] as never); const res = await POST( formRequest( @@ -216,12 +245,12 @@ describe("POST /api/withings/webhook", () => { expect(res.status).toBe(200); const body = (await res.json()) as { status: string }; expect(body.status).toBe("ignored"); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); expect(syncUserMeasurements).not.toHaveBeenCalled(); }); it("returns 200 unknown_user when no WithingsConnection matches the wire userid", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce(null); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([]); const res = await POST( jsonRequest( { userid: "wu-ghost" }, @@ -248,16 +277,18 @@ describe("POST /api/withings/webhook", () => { ); expect(res.status).toBe(429); expect(syncUserMeasurements).not.toHaveBeenCalled(); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); }); }); describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { it("appli=16 enqueues onto withings-activity-sync (not the measure path)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-act", - withingsUserId: "wu-act", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-act", + withingsUserId: "wu-act", + }, + ] as never); const send = vi.fn().mockResolvedValue(undefined); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); @@ -276,10 +307,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=44 enqueues onto withings-sleep-sync (not the measure path)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-sleep", - withingsUserId: "wu-sleep", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-sleep", + withingsUserId: "wu-sleep", + }, + ] as never); const send = vi.fn().mockResolvedValue(undefined); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); @@ -297,10 +330,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=16 falls back to inline activity sync when pg-boss is unavailable", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-act-inline", - withingsUserId: "wu-act-inline", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-act-inline", + withingsUserId: "wu-act-inline", + }, + ] as never); vi.mocked(getGlobalBoss).mockReturnValue(null); const res = await POST( @@ -315,10 +350,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=44 falls back to inline sleep sync when pg-boss is unavailable", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-sleep-inline", - withingsUserId: "wu-sleep-inline", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-sleep-inline", + withingsUserId: "wu-sleep-inline", + }, + ] as never); vi.mocked(getGlobalBoss).mockReturnValue(null); const res = await POST( @@ -333,10 +370,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=4 (BP) keeps the legacy measure path", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-bp", - withingsUserId: "wu-bp", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-bp", + withingsUserId: "wu-bp", + }, + ] as never); const res = await POST( formRequest( @@ -351,10 +390,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("a missing appli falls back to the measure path (legacy Withings subscriptions)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-legacy", - withingsUserId: "wu-legacy", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-legacy", + withingsUserId: "wu-legacy", + }, + ] as never); const res = await POST( formRequest( @@ -366,10 +407,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { expect(syncUserMeasurements).toHaveBeenCalledWith("user-legacy"); }); it("returns retryable non-2xx when the ECG event cannot be durably enqueued", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({ - userId: "user-ecg", - withingsUserId: "wu-ecg", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([ + { + userId: "user-ecg", + withingsUserId: "wu-ecg", + }, + ] as never); const send = vi.fn().mockRejectedValue(new Error("queue unavailable")); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); @@ -390,10 +433,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("uses one logical ECG job identity for a provider replay", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({ - userId: "user-ecg", - withingsUserId: "wu-ecg", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([ + { + userId: "user-ecg", + withingsUserId: "wu-ecg", + }, + ] as never); const send = vi .fn() .mockResolvedValueOnce("job-1") diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts index caa988bdb..3e4b729a9 100644 --- a/src/components/settings/__tests__/oauth-return-outcome.test.ts +++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts @@ -80,6 +80,27 @@ describe("oauthReasonKey", () => { ); }); + it("names an account already held by another record for every provider", async () => { + const en = (await import("../../../../messages/en.json")) + .default as unknown as { + settings: Record>; + }; + for (const provider of [ + "polar", + "oura", + "whoop", + "fitbit", + "googleHealth", + "strava", + ] as const) { + const key = oauthReasonKey(provider, "already_linked"); + expect(key.endsWith(".already_linked")).toBe(true); + const [, base] = key.split("."); + expect(en.settings[base!]!.already_linked).toBeTruthy(); + } + expect(en.settings.withingsOauthError!.already_linked).toBeTruthy(); + }); + it("falls back to generic for an unknown tag", () => { expect(oauthReasonKey("whoop", "totally_unknown")).toBe( "settings.whoopOauthError.generic", diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index b79f1e442..f44a30aaf 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -57,6 +57,7 @@ const WITHINGS_OAUTH_ERROR_KEYS: Record = { nocode: "settings.withingsOauthError.nocode", nocreds: "settings.withingsOauthError.nocreds", token: "settings.withingsOauthError.token", + already_linked: "settings.withingsOauthError.already_linked", }; type WithingsOauthOutcome = @@ -126,6 +127,8 @@ const OAUTH_OUTCOME_REASONS = new Set([ "token", "rate_limited", "expired", + // The provider account is already connected to another record. + "already_linked", ]); /** diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 54d59b8d9..5395b35b5 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -465,6 +465,8 @@ export const USER_RESET = { ouraScopeDenied: Prisma.DbNull, ouraBackfillProgress: Prisma.DbNull, ouraBackfillCompletedAt: null, + ouraUserId: null, + clientAppSharedBy: Prisma.DbNull, stravaClientIdEncrypted: null, stravaClientSecretEncrypted: null, stravaAccessTokenEncrypted: null, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 527b43b74..c4a1137fd 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -940,6 +940,10 @@ export const USER_COLUMN_BACKUP_CLASS = { // What the Oura grant on this host does not cover; meaningless beside a // grant that does not travel, and found out again by the first sync. ouraScopeDenied: "CREDENTIAL", + ouraUserId: "CREDENTIAL", + // Names the guardian whose provider app the record borrows; the app itself + // does not travel, so neither does the marker. + clientAppSharedBy: "CREDENTIAL", stravaClientIdEncrypted: "CREDENTIAL", stravaClientSecretEncrypted: "CREDENTIAL", stravaAccessTokenEncrypted: "CREDENTIAL", diff --git a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts index 19ec6a345..e83fc2082 100644 --- a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts +++ b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts @@ -19,7 +19,13 @@ vi.mock("@/lib/sharing/grants", () => ({ (need !== "manage" || grant.access === "MANAGE"), })); -import { shareGuardianClientApp, verifyCallbackActor } from "../connect-target"; +import { + releaseSharedClientApp, + releaseUnbackedClientApps, + shareGuardianClientApp, + verifyCallbackActor, +} from "../connect-target"; +import { Prisma } from "@/generated/prisma/client"; const MANAGED = { managedProfileAt: new Date("2026-01-01T00:00:00Z") }; const ADULT = { managedProfileAt: null }; @@ -127,6 +133,7 @@ describe("shareGuardianClientApp", () => { data: { ouraClientIdEncrypted: "sealed-id", ouraClientSecretEncrypted: "sealed-secret", + clientAppSharedBy: { oura: "guardian" }, }, }); expect(auditLog).toHaveBeenCalledWith("integration.client_app.shared", { @@ -146,3 +153,71 @@ describe("shareGuardianClientApp", () => { expect(auditLog).not.toHaveBeenCalled(); }); }); + +describe("releasing a borrowed provider app", () => { + it("removes the borrowed app on disconnect and audits it", async () => { + findUnique.mockResolvedValue({ + clientAppSharedBy: { oura: "guardian", strava: "guardian" }, + }); + await expect( + releaseSharedClientApp("oura", "rec", "disconnected"), + ).resolves.toBe(true); + expect(update).toHaveBeenCalledWith({ + where: { id: "rec" }, + data: { + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + clientAppSharedBy: { strava: "guardian" }, + }, + }); + expect(auditLog).toHaveBeenCalledWith( + "integration.client_app.removed", + expect.objectContaining({ + userId: "rec", + details: { + provider: "oura", + reason: "disconnected", + sharedBy: "guardian", + }, + }), + ); + }); + + it("leaves an app the record holds of its own", async () => { + findUnique.mockResolvedValue({ clientAppSharedBy: null }); + await expect( + releaseSharedClientApp("oura", "rec", "disconnected"), + ).resolves.toBe(false); + expect(update).not.toHaveBeenCalled(); + }); + + it("clears the marker when the last borrowed app goes", async () => { + findUnique.mockResolvedValue({ clientAppSharedBy: { polar: "g" } }); + await releaseSharedClientApp("polar", "rec", "guardian_left"); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ clientAppSharedBy: Prisma.DbNull }), + }), + ); + }); + + it("removes only the apps of guardians without a live MANAGE grant", async () => { + findUnique.mockResolvedValue({ + clientAppSharedBy: { oura: "left", strava: "stays" }, + }); + findActiveGrant.mockImplementation( + async ({ granteeId }: { granteeId: string }) => + granteeId === "stays" ? { access: "MANAGE", revokedAt: null } : null, + ); + await expect(releaseUnbackedClientApps("rec")).resolves.toBe(1); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + }), + }), + ); + }); +}); diff --git a/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts new file mode 100644 index 000000000..fc3cf7c5a --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts @@ -0,0 +1,88 @@ +/** + * A disconnect leaves provider-side state alone while another record still + * holds the same provider account: Withings webhook subscriptions and the + * Strava app authorisation belong to the account, not to the record. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, unsubscribeMock, deauthorizeMock } = vi.hoisted(() => ({ + prismaMock: { + withingsConnection: { + findUnique: vi.fn(), + findMany: vi.fn(), + delete: vi.fn(), + }, + user: { findUnique: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + }, + unsubscribeMock: vi.fn(async () => {}), + deauthorizeMock: vi.fn(async () => {}), +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ decrypt: (s: string) => s })); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); +vi.mock("@/lib/withings/client", () => ({ + unsubscribeWebhook: unsubscribeMock, +})); +vi.mock("@/lib/withings/sync", () => ({ + WITHINGS_NOTIFY_APPLIS: [1, 44], + getWithingsWebhookCallbackUrl: () => "https://app.example/hook", +})); +vi.mock("@/lib/strava/client", () => ({ deauthorize: deauthorizeMock })); +vi.mock("@/lib/strava/credentials", () => ({ + STRAVA_DISCONNECTED: { stravaAccessTokenEncrypted: null }, + getStravaConnection: vi.fn(async () => ({ accessToken: "at" })), +})); + +import { disconnectOAuth } from "../disconnect"; + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.withingsConnection.findUnique.mockResolvedValue({ + userId: "profile-1", + withingsUserId: "wu-1", + accessToken: "token", + }); + prismaMock.user.findUnique.mockResolvedValue({ stravaAthleteId: "42" }); +}); + +describe("disconnectOAuth with a shared provider account", () => { + it("keeps the Withings subscriptions while another record holds the account", async () => { + prismaMock.withingsConnection.findMany.mockResolvedValue([ + { userId: "guardian-1" }, + ]); + await expect(disconnectOAuth("withings", "profile-1")).resolves.toBe( + "disconnected", + ); + expect(prismaMock.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { withingsUserId: "wu-1", userId: { not: "profile-1" } }, + }), + ); + expect(unsubscribeMock).not.toHaveBeenCalled(); + expect(prismaMock.withingsConnection.delete).toHaveBeenCalled(); + }); + + it("unsubscribes every category when no other record holds the account", async () => { + prismaMock.withingsConnection.findMany.mockResolvedValue([]); + await disconnectOAuth("withings", "profile-1"); + expect(unsubscribeMock).toHaveBeenCalledTimes(2); + }); + + it("keeps the Strava authorisation while another record holds the athlete", async () => { + prismaMock.user.findMany.mockResolvedValue([{ id: "guardian-1" }]); + await disconnectOAuth("strava", "profile-1"); + expect(deauthorizeMock).not.toHaveBeenCalled(); + expect(prismaMock.user.update).toHaveBeenCalled(); + }); + + it("deauthorises Strava when the athlete is held nowhere else", async () => { + prismaMock.user.findMany.mockResolvedValue([]); + await disconnectOAuth("strava", "profile-1"); + expect(deauthorizeMock).toHaveBeenCalledWith("at"); + }); +}); diff --git a/src/lib/integrations/oauth-kit/account-link.ts b/src/lib/integrations/oauth-kit/account-link.ts new file mode 100644 index 000000000..800d45221 --- /dev/null +++ b/src/lib/integrations/oauth-kit/account-link.ts @@ -0,0 +1,114 @@ +/** + * One provider account, one HealthLog record. + * + * A guardian finishing a connect for a managed profile is signed in to the + * provider in the same browser, so the consent screen can hand back the + * guardian's own provider account. Stored on the profile, that account's data + * would land on both records, its webhooks would route to whichever row a + * lookup found first, and a disconnect on one record would unsubscribe the + * other's notifications. Every callback therefore refuses a provider account + * that another record already holds (`already_linked`), and a disconnect only + * touches provider-side state (webhook subscriptions, app authorisation) when + * no other record holds the same account. + * + * Where the provider's account id is not stored there is nothing to compare, + * and the check passes: an unknown id is not evidence of a second holder. + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; + +export type LinkedAccountProvider = + | "withings" + | "whoop" + | "fitbit" + | "google-health" + | "strava" + | "polar" + | "oura"; + +/** The callback reason a refused second holder redirects with. */ +export const ALREADY_LINKED_REASON = "already_linked"; + +/** + * The records other than `userId` that hold the provider account `accountId`. + */ +export async function otherHoldersOfAccount( + provider: LinkedAccountProvider, + accountId: string, + userId: string, +): Promise { + const notMe = { not: userId }; + switch (provider) { + case "withings": + return ( + await prisma.withingsConnection.findMany({ + where: { withingsUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "whoop": + return ( + await prisma.whoopConnection.findMany({ + where: { whoopUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "fitbit": + return ( + await prisma.fitbitConnection.findMany({ + where: { fitbitUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "google-health": + return ( + await prisma.googleHealthConnection.findMany({ + where: { googleUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "strava": + return ( + await prisma.user.findMany({ + where: { stravaAthleteId: accountId, id: notMe }, + select: { id: true }, + }) + ).map((r) => r.id); + case "oura": + return ( + await prisma.user.findMany({ + where: { ouraUserId: accountId, id: notMe }, + select: { id: true }, + }) + ).map((r) => r.id); + case "polar": { + // The member id is sealed with a random nonce, so it cannot be matched + // in SQL; the connected rows are few, and each is opened here. + const rows = await prisma.user.findMany({ + where: { polarUserIdEncrypted: { not: null }, id: notMe }, + select: { id: true, polarUserIdEncrypted: true }, + }); + const holders: string[] = []; + for (const row of rows) { + try { + if (decrypt(row.polarUserIdEncrypted!) === accountId) { + holders.push(row.id); + } + } catch { + // An unreadable row proves nothing either way. + } + } + return holders; + } + } +} + +/** Does a record other than `userId` hold the provider account `accountId`? */ +export async function accountLinkedElsewhere( + provider: LinkedAccountProvider, + accountId: string | null | undefined, + userId: string, +): Promise { + if (!accountId) return false; + return (await otherHoldersOfAccount(provider, accountId, userId)).length > 0; +} diff --git a/src/lib/integrations/oauth-kit/connect-target.ts b/src/lib/integrations/oauth-kit/connect-target.ts index 98dafb33b..2289d320e 100644 --- a/src/lib/integrations/oauth-kit/connect-target.ts +++ b/src/lib/integrations/oauth-kit/connect-target.ts @@ -17,6 +17,7 @@ * can pass on a consent screen and a grant can be revoked in them. */ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { auditLog } from "@/lib/auth/audit"; import { findActiveGrant, grantAllows } from "@/lib/sharing/grants"; import type { OAuthKitProvider } from "./token"; @@ -107,9 +108,20 @@ export async function shareGuardianClientApp( const clientId = guardian?.[idColumn] ?? null; const clientSecret = guardian?.[secretColumn] ?? null; if (!clientId || !clientSecret) return; + const record = await prisma.user.findUnique({ + where: { id: target.recordUserId }, + select: { clientAppSharedBy: true }, + }); await prisma.user.update({ where: { id: target.recordUserId }, - data: { [idColumn]: clientId, [secretColumn]: clientSecret }, + data: { + [idColumn]: clientId, + [secretColumn]: clientSecret, + clientAppSharedBy: { + ...readClientAppSharedBy(record?.clientAppSharedBy), + [provider]: target.actorUserId, + }, + }, }); await auditLog("integration.client_app.shared", { userId: target.recordUserId, @@ -117,3 +129,102 @@ export async function shareGuardianClientApp( details: { provider }, }); } + +/** Which provider apps on a record were borrowed, and from which guardian. */ +export type ClientAppSharedBy = Partial>; + +/** Read the stored marker, dropping anything that is not a provider → id. */ +export function readClientAppSharedBy(value: unknown): ClientAppSharedBy { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: ClientAppSharedBy = {}; + for (const provider of Object.keys(CLIENT_COLUMNS) as OAuthKitProvider[]) { + const guardianId = (value as Record)[provider]; + if (typeof guardianId === "string" && guardianId.length > 0) { + out[provider] = guardianId; + } + } + return out; +} + +type ReleaseDb = Pick< + Prisma.TransactionClient, + "user" | "accountGrant" | "auditLog" +>; + +/** Why a borrowed app left the record, as the audit row names it. */ +export type ClientAppReleaseReason = "disconnected" | "guardian_left"; + +/** + * Remove a provider app the record borrowed from a guardian, if it did. + * + * The app's client id and secret are the guardian's; they stay on the record + * only while they serve a connection the guardian may still act on. Does + * nothing when the record's app for `provider` is its own (no marker). + */ +export async function releaseSharedClientApp( + provider: OAuthKitProvider, + recordUserId: string, + reason: ClientAppReleaseReason, + db: ReleaseDb = prisma, +): Promise { + const row = await db.user.findUnique({ + where: { id: recordUserId }, + select: { clientAppSharedBy: true }, + }); + const sharedBy = readClientAppSharedBy(row?.clientAppSharedBy); + const guardianUserId = sharedBy[provider]; + if (!guardianUserId) return false; + delete sharedBy[provider]; + const [idColumn, secretColumn] = CLIENT_COLUMNS[provider]; + await db.user.update({ + where: { id: recordUserId }, + data: { + [idColumn]: null, + [secretColumn]: null, + clientAppSharedBy: + Object.keys(sharedBy).length > 0 ? sharedBy : Prisma.DbNull, + }, + }); + await auditLog("integration.client_app.removed", { + userId: recordUserId, + details: { provider, reason, sharedBy: guardianUserId }, + client: db, + }); + return true; +} + +/** + * Remove every app the record borrowed from a guardian who no longer holds a + * live MANAGE grant on it. Run after any change that can end a guardian's + * access (revoke, renounce, removal, handover, the guardian's account + * deletion or data wipe). A connection still using the removed app keeps its + * imported data and asks for a reconnect at its next refresh, which a + * remaining guardian finishes with their own app. + */ +export async function releaseUnbackedClientApps( + recordUserId: string, + db: ReleaseDb = prisma, +): Promise { + const row = await db.user.findUnique({ + where: { id: recordUserId }, + select: { clientAppSharedBy: true }, + }); + const sharedBy = readClientAppSharedBy(row?.clientAppSharedBy); + let released = 0; + for (const [provider, guardianUserId] of Object.entries(sharedBy) as [ + OAuthKitProvider, + string, + ][]) { + const grant = await findActiveGrant( + { grantorId: recordUserId, granteeId: guardianUserId }, + db, + ); + if (grant && grantAllows(grant, "manage")) continue; + if ( + await releaseSharedClientApp(provider, recordUserId, "guardian_left", db) + ) { + released += 1; + } + } + return released; +} diff --git a/src/lib/integrations/oauth-kit/disconnect.ts b/src/lib/integrations/oauth-kit/disconnect.ts index b4393f9eb..03f8b8e90 100644 --- a/src/lib/integrations/oauth-kit/disconnect.ts +++ b/src/lib/integrations/oauth-kit/disconnect.ts @@ -26,6 +26,8 @@ import { getStravaConnection, } from "@/lib/strava/credentials"; import type { OAuthKitProvider } from "./token"; +import { otherHoldersOfAccount } from "./account-link"; +import { releaseSharedClientApp } from "./connect-target"; export type DisconnectOutcome = "disconnected" | "not_connected"; @@ -51,19 +53,33 @@ async function dropGrant( if (!connection) return false; // Withings keeps calling a subscribed URL after the grant is gone, so // the subscriptions go first. Best effort: a refused unsubscribe must - // not keep the person connected. - try { - const accessToken = decrypt(connection.accessToken); - const callbackUrl = getWithingsWebhookCallbackUrl(); - for (const appli of WITHINGS_NOTIFY_APPLIS) { - try { - await unsubscribeWebhook(accessToken, callbackUrl, appli); - } catch { - // Already gone, or the token expired; the delete below stands. + // not keep the person connected. A subscription belongs to the Withings + // account, not to this record: while another record still holds the + // same account (a pair linked before callbacks refused a second + // holder), it is left in place, or that record would stop hearing. + const sharedWith = await otherHoldersOfAccount( + "withings", + connection.withingsUserId, + userId, + ); + if (sharedWith.length > 0) { + getEvent()?.addWarning( + "withings: webhook subscriptions kept, account held by another record", + ); + } else { + try { + const accessToken = decrypt(connection.accessToken); + const callbackUrl = getWithingsWebhookCallbackUrl(); + for (const appli of WITHINGS_NOTIFY_APPLIS) { + try { + await unsubscribeWebhook(accessToken, callbackUrl, appli); + } catch { + // Already gone, or the token expired; the delete below stands. + } } + } catch { + // An undecryptable token cannot unsubscribe anything. } - } catch { - // An undecryptable token cannot unsubscribe anything. } await prisma.withingsConnection.delete({ where: { userId } }); return true; @@ -96,6 +112,7 @@ async function dropGrant( ouraAccessTokenEncrypted: null, ouraRefreshTokenEncrypted: null, ouraScopeDenied: Prisma.DbNull, + ouraUserId: null, }, }); return true; @@ -115,13 +132,24 @@ async function dropGrant( case "strava": { const connection = await getStravaConnection(userId); if (!connection) return false; - // Strava keeps the app authorised until it is told otherwise. - try { - await deauthorize(connection.accessToken); - } catch (err) { - getEvent()?.addWarning( - `strava: deauthorize failed on disconnect: ${err}`, - ); + // Strava keeps the app authorised until it is told otherwise. The + // authorisation belongs to the athlete; while another record still + // holds the same athlete it stays, or that record would lose its grant. + const athlete = await prisma.user.findUnique({ + where: { id: userId }, + select: { stravaAthleteId: true }, + }); + const sharedWith = athlete?.stravaAthleteId + ? await otherHoldersOfAccount("strava", athlete.stravaAthleteId, userId) + : []; + if (sharedWith.length === 0) { + try { + await deauthorize(connection.accessToken); + } catch (err) { + getEvent()?.addWarning( + `strava: deauthorize failed on disconnect: ${err}`, + ); + } } await prisma.user.update({ where: { id: userId }, @@ -139,6 +167,9 @@ export async function disconnectOAuth( ): Promise { if (!(await dropGrant(provider, userId))) return "not_connected"; await auditLog(AUDIT_ACTION[provider], { userId }); + // A provider app the record borrowed from a guardian served this + // connection only; it leaves with it. + await releaseSharedClientApp(provider, userId, "disconnected"); await markDisconnected(userId, provider); return "disconnected"; } diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 1b581906d..899d673f7 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -4,6 +4,7 @@ import { } from "@/lib/jobs/offhost-purge"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; import type { Prisma } from "@/generated/prisma/client"; import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only"; import { @@ -148,7 +149,13 @@ export async function deleteGuardianAccountWithLifecycle( }); } - return deleteAccount(tx); + const result = await deleteAccount(tx); + // The guardian's grants went with their account; a provider app any of + // their profiles borrowed from them goes too. + for (const { grantorId } of grants) { + await releaseUnbackedClientApps(grantorId, tx); + } + return result; }, ACCOUNT_DELETION_TRANSACTION_OPTIONS); } @@ -167,7 +174,7 @@ export async function deleteGuardianAccountWithLifecycle( export async function protectManagedProfilesDuringDataWipe( tx: Transaction, userId: string, -): Promise { +): Promise { const affectedProfiles = await tx.accountGrant.findMany({ where: { OR: [{ grantorId: userId }, { granteeId: userId }], @@ -212,6 +219,9 @@ export async function protectManagedProfilesDuringDataWipe( } }); } + // The profiles whose grants the wipe touches, for the caller to settle + // what those grants carried once they are gone. + return affectedProfiles.map((p) => p.grantorId); } export async function deleteManagedProfile(input: { diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 99e7140c2..2e3ac2569 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -522,6 +522,36 @@ function collectionRefusal( }); } +/** + * The Oura account id of a grant (`personal_info.id`), or null when the grant + * does not cover the `personal` scope or Oura does not answer. Read once at + * connect so one Oura account cannot be attached to two records; a null id + * skips that check rather than failing the connect. + */ +export async function fetchOuraAccountId( + accessToken: string, +): Promise { + try { + const res = await safeFetch( + `${OURA_API_BASE}/v2/usercollection/personal_info`, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }, + ); + if (!res.ok) return null; + const json = (await res.json().catch(() => null)) as { + id?: unknown; + } | null; + return typeof json?.id === "string" && json.id.length > 0 ? json.id : null; + } catch { + return null; + } +} + /** Read one page of a collection; throws the classified refusal. */ async function readCollectionPage( path: string, diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 183e38a55..dcb8daf68 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -55,6 +55,7 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { isP2002 } from "@/lib/prisma-errors"; import { clearActingSessions } from "@/lib/sharing/acting-session"; +import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; import { activeGuardianWhere, LastManagedGuardianError, @@ -617,6 +618,8 @@ export async function revokeManagedProfileGuardian(input: { { grantorId: profile.id, granteeId: target.granteeId }, tx, ); + // A provider app the profile borrowed from this guardian goes with them. + await releaseUnbackedClientApps(profile.id, tx); // v1.42 (#959) — a handover link the removed Guardian minted dies with // their access. The claim refuses it anyway (its creator must still be // an active Guardian); withdrawing it here keeps the other Guardians' @@ -771,6 +774,8 @@ export async function settleHandoverGuardianGrant( } const sessionsCleared = await clearActingSessions(pair, tx); + // A Guardian who keeps no MANAGE takes back the provider app they lent. + await releaseUnbackedClientApps(input.recordId, tx); return { access: input.target, grantId, changed: true, sessionsCleared }; } @@ -939,6 +944,8 @@ async function endAndClear( { grantorId: grant.grantorId, granteeId: grant.granteeId }, tx, ); + // An ended MANAGE grant takes back the provider app it lent the record. + await releaseUnbackedClientApps(grant.grantorId, tx); return { grant, sessionsCleared }; }); } diff --git a/src/lib/withings/webhook-handler.ts b/src/lib/withings/webhook-handler.ts index 156814bf5..0405653bf 100644 --- a/src/lib/withings/webhook-handler.ts +++ b/src/lib/withings/webhook-handler.ts @@ -138,9 +138,22 @@ export async function processWithingsNotification( }, }); - const connection = await prisma.withingsConnection.findFirst({ + // A Withings account belongs to one record; callbacks refuse a second + // holder. A pair linked before that rule routes to the record that linked + // the account first, never to whichever row a lookup happens to find. + const holders = await prisma.withingsConnection.findMany({ where: { withingsUserId }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { userId: true }, + take: 2, }); + const connection = holders[0] ?? null; + if (holders.length > 1) { + annotate({ meta: { withings_account_shared: true } }); + getEvent()?.addWarning( + "Withings account held by more than one record; routed to the first", + ); + } if (!connection) { getEvent()?.addWarning( diff --git a/tests/integration/managed-profile-oauth-connect.test.ts b/tests/integration/managed-profile-oauth-connect.test.ts index beca90d08..be879252e 100644 --- a/tests/integration/managed-profile-oauth-connect.test.ts +++ b/tests/integration/managed-profile-oauth-connect.test.ts @@ -286,3 +286,163 @@ describe("verifyCallbackActor against real grants", () => { }); }); }); + +describe("one provider account, one record", () => { + it("refuses to attach the guardian's own Oura account to the profile", async () => { + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: GUARDIAN }, + data: { ouraUserId: "oura-account-guardian" }, + }); + vi.mocked(globalThis.fetch).mockImplementation(async (input) => { + const url = urlOf(input); + if (url.endsWith("/v2/usercollection/personal_info")) { + return new Response(JSON.stringify({ id: "oura-account-guardian" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url !== OURA_TOKEN_URL) throw new Error(`Unexpected fetch: ${url}`); + return new Response( + JSON.stringify({ + access_token: "guardian-access", + refresh_token: "guardian-refresh", + expires_in: 86400, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + const started = await startConnect(); + const { data } = (await started.json()) as { + data: { authorizationUrl: string }; + }; + + const res = await ouraCallback(stateOf(data.authorizationUrl)); + expect(res.headers.get("location")).toContain("reason=already_linked"); + const profile = await prisma.user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { ouraAccessTokenEncrypted: true, ouraUserId: true }, + }); + expect(profile.ouraAccessTokenEncrypted).toBeNull(); + expect(profile.ouraUserId).toBeNull(); + }); + + it("finds the other holder of a Withings account and routes its webhook to the first", async () => { + const prisma = getPrismaClient(); + const { encrypt } = await import("@/lib/crypto"); + const base = { + withingsUserId: "withings-shared", + accessToken: encrypt("a"), + refreshToken: encrypt("r"), + tokenExpiresAt: new Date(Date.now() + 3_600_000), + }; + await prisma.withingsConnection.create({ + data: { + ...base, + userId: GUARDIAN, + createdAt: new Date(Date.now() - 60_000), + }, + }); + await prisma.withingsConnection.create({ + data: { ...base, userId: PROFILE }, + }); + + const { otherHoldersOfAccount } = + await import("@/lib/integrations/oauth-kit/account-link"); + await expect( + otherHoldersOfAccount("withings", "withings-shared", PROFILE), + ).resolves.toEqual([GUARDIAN]); + await expect( + otherHoldersOfAccount("withings", "withings-shared", ADULT), + ).resolves.toHaveLength(2); + await expect( + otherHoldersOfAccount("withings", "withings-elsewhere", PROFILE), + ).resolves.toEqual([]); + }); +}); + +describe("a borrowed provider app leaves with its reason", () => { + async function connectForProfile() { + await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + const started = await startConnect(); + const { data } = (await started.json()) as { + data: { authorizationUrl: string }; + }; + const res = await ouraCallback(stateOf(data.authorizationUrl)); + expect(res.headers.get("location")).toContain("oura=connected"); + } + + async function profileApp() { + return getPrismaClient().user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { + ouraClientIdEncrypted: true, + ouraClientSecretEncrypted: true, + clientAppSharedBy: true, + }, + }); + } + + it("removes the guardian's app when the profile's Oura is disconnected", async () => { + await connectForProfile(); + expect((await profileApp()).clientAppSharedBy).toEqual({ oura: GUARDIAN }); + + const { disconnectOAuth } = + await import("@/lib/integrations/oauth-kit/disconnect"); + await expect(disconnectOAuth("oura", PROFILE)).resolves.toBe( + "disconnected", + ); + expect(await profileApp()).toEqual({ + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + clientAppSharedBy: null, + }); + const removed = await getPrismaClient().auditLog.findFirst({ + where: { userId: PROFILE, action: "integration.client_app.removed" }, + }); + expect(removed).not.toBeNull(); + }); + + it("removes the guardian's app when their MANAGE grant ends", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: "second-guardian", + username: "second-guardian", + email: "second-guardian@example.test", + role: "USER", + }, + }); + await prisma.accountGrant.create({ + data: { + grantorId: PROFILE, + granteeId: "second-guardian", + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + await connectForProfile(); + const leaving = await prisma.accountGrant.findFirstOrThrow({ + where: { grantorId: PROFILE, granteeId: GUARDIAN, revokedAt: null }, + }); + + const { revokeManagedProfileGuardian } = + await import("@/lib/sharing/grants"); + await revokeManagedProfileGuardian({ + profileId: PROFILE, + guardianId: "second-guardian", + grantId: leaving.id, + }); + + const app = await profileApp(); + expect(app.ouraClientIdEncrypted).toBeNull(); + expect(app.ouraClientSecretEncrypted).toBeNull(); + expect(app.clientAppSharedBy).toBeNull(); + }); +}); From b795b3b1f360ac2855a5b99a16660a828cb2398e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:37:21 +0200 Subject: [PATCH 32/98] fix(jobs): enqueue during boot waits for the boss instead of dropping The server answers before pg-boss is bound, and a producer rebinds in the background after losing its connection. A request in either window read the boss as null and skipped its enqueue: OAuth callbacks lost the first sync and the history backfill, measurement and mood writes lost their rollup recompute, WHOOP webhook deliveries were dropped. whenBossReady() waits for the boss while a start is under way (boot or reconnect), bounded at 60 s and logged when it gives up, and answers null at once when nothing is coming. enqueueWhenBossReady() sends at once through a bound boss and otherwise defers the send to the background, so the request is not held for the boot. --- src/app/api/fitbit/callback/route.ts | 7 +- src/app/api/google-health/callback/route.ts | 7 +- src/app/api/nightscout/connect/route.ts | 7 +- src/app/api/oura/callback/route.ts | 7 +- src/app/api/polar/callback/route.ts | 7 +- src/app/api/strava/callback/route.ts | 7 +- src/app/api/whoop/callback/route.ts | 7 +- src/instrumentation.ts | 5 +- src/lib/jobs/__tests__/boss-ready.test.ts | 89 ++++++++++++++++++++ src/lib/jobs/boss-instance.ts | 4 +- src/lib/jobs/boss-ready.ts | 93 +++++++++++++++++++++ src/lib/rollups/measurement-rollups.ts | 72 ++++++++-------- src/lib/rollups/mood-rollups.ts | 21 ++--- src/lib/whoop/webhook-handler.ts | 16 ++-- 14 files changed, 266 insertions(+), 83 deletions(-) create mode 100644 src/lib/jobs/__tests__/boss-ready.test.ts create mode 100644 src/lib/jobs/boss-ready.ts diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index c553c0aff..a8284ae92 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -16,7 +16,7 @@ import { import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; import { FITBIT_OAUTH_STATE_COOKIE } from "@/lib/fitbit/oauth-state"; import { FITBIT_BACKFILL_QUEUE } from "@/lib/jobs/fitbit-backfill"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; import { @@ -205,8 +205,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // Enqueue the self-converging history backfill. Best-effort: the boot-time // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a // missing boss instance here doesn't strand the connection. - const boss = getGlobalBoss(); - if (boss) { + await enqueueWhenBossReady("fitbit-backfill", async (boss) => { await boss .send(FITBIT_BACKFILL_QUEUE, { userId: user.id, @@ -215,7 +214,7 @@ export const GET = apiHandler(async (request: NextRequest) => { .catch((err) => getEvent()?.addWarning(`fitbit-backfill enqueue failed: ${err}`), ); - } + }); const response = NextResponse.redirect( new URL( diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 821c81530..4c0ac3d98 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -18,7 +18,7 @@ import { import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials"; import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE } from "@/lib/google-health/oauth-state"; import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; import { @@ -235,8 +235,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // Enqueue the self-converging history backfill. Best-effort: the boot-time // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a // missing boss instance here doesn't strand the connection. - const boss = getGlobalBoss(); - if (boss) { + await enqueueWhenBossReady("google-health-backfill", async (boss) => { await boss .send(GOOGLE_HEALTH_BACKFILL_QUEUE, { userId: recordUserId, @@ -247,7 +246,7 @@ export const GET = apiHandler(async (request: NextRequest) => { `google-health-backfill enqueue failed: ${err}`, ), ); - } + }); const response = NextResponse.redirect( new URL( diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index a0d297e38..8dc4ae8a5 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -16,7 +16,7 @@ import { encrypt } from "@/lib/crypto"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markReconnected } from "@/lib/integrations/status"; import { NIGHTSCOUT_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { fetchSgvEntries, NightscoutApiError, @@ -160,8 +160,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // empty card until the next hourly tick. Best-effort by design: the hourly // cron remains the safety net, so a missing boss instance must never fail the // connect response the user just earned. - const boss = getGlobalBoss(); - if (boss) { + await enqueueWhenBossReady("nightscout-sync", async (boss) => { await boss .send(NIGHTSCOUT_SYNC_QUEUE, { userId: user.id }) .catch((err) => @@ -169,7 +168,7 @@ export const POST = apiHandler(async (request: NextRequest) => { `nightscout-sync connect enqueue failed: ${err}`, ), ); - } + }); return apiSuccess({ connected: true }); }); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index ec67933d4..26b2e677e 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -25,7 +25,7 @@ import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim"; import { ouraCollectionsMissingFromGrant } from "@/lib/oura/scope"; import { markReconnected } from "@/lib/integrations/status"; import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; import { OURA_BACKFILL_QUEUE, @@ -184,8 +184,7 @@ async function completeCallback( // missing. Reconnects ride the same path. Best-effort by design: the hourly // cron remains the safety net, so a missing boss instance must never strand // the redirect. - const boss = getGlobalBoss(); - if (boss) { + await enqueueWhenBossReady("oura-connect", async (boss) => { await boss .send(OURA_SYNC_QUEUE, { userId }) .catch((err) => @@ -206,7 +205,7 @@ async function completeCallback( `oura-backfill connect enqueue failed: ${err}`, ), ); - } + }); const response = NextResponse.redirect( new URL( diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index 6bb84b67e..84230b171 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -19,7 +19,7 @@ import { import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim"; import { markReconnected } from "@/lib/integrations/status"; import { POLAR_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { NextRequest, NextResponse } from "next/server"; /** @@ -156,14 +156,13 @@ async function completeCallback( // card until the next hourly tick. Best-effort by design: the hourly cron // remains the safety net, so a missing boss instance must never strand the // redirect. - const boss = getGlobalBoss(); - if (boss) { + await enqueueWhenBossReady("polar-connect", async (boss) => { await boss .send(POLAR_SYNC_QUEUE, { userId }) .catch((err) => getEvent()?.addWarning(`polar-sync connect enqueue failed: ${err}`), ); - } + }); const response = NextResponse.redirect( new URL( diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index 9e0c4c08e..e94544c3c 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -19,7 +19,7 @@ import { import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim"; import { markReconnected } from "@/lib/integrations/status"; import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { NextRequest, NextResponse } from "next/server"; /** @@ -161,8 +161,7 @@ async function completeCallback( // (`stravaBackfillCompletedAt IS NULL`) is the safety net, so a missing // boss instance here never strands the connection. Mirrors the WHOOP / // Fitbit callback enqueue. - const boss = getGlobalBoss(); - if (boss) { + await enqueueWhenBossReady("strava-backfill", async (boss) => { await boss .send(STRAVA_BACKFILL_QUEUE, { userId, @@ -171,7 +170,7 @@ async function completeCallback( .catch((err) => getEvent()?.addWarning(`strava-backfill enqueue failed: ${err}`), ); - } + }); const response = NextResponse.redirect( new URL( diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index fdf5fb1cd..9bf1cef17 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -15,7 +15,7 @@ import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import { WHOOP_OAUTH_STATE_COOKIE } from "@/lib/whoop/oauth-state"; import { buildReturnSchemeRedirect } from "@/lib/whoop/return-scheme"; import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; import { isP2002 } from "@/lib/prisma-errors"; @@ -257,8 +257,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // Enqueue the self-converging history backfill. Best-effort: the boot-time // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a // missing boss instance here doesn't strand the connection. - const boss = getGlobalBoss(); - if (boss) { + await enqueueWhenBossReady("whoop-backfill", async (boss) => { await boss .send(WHOOP_BACKFILL_QUEUE, { userId, @@ -267,7 +266,7 @@ export const GET = apiHandler(async (request: NextRequest) => { .catch((err) => getEvent()?.addWarning(`whoop-backfill enqueue failed: ${err}`), ); - } + }); const response = outcomeRedirect(returnScheme, "connected"); response.cookies.delete(WHOOP_OAUTH_STATE_COOKIE); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index f152e67ca..ea0e46748 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -84,8 +84,9 @@ export async function register() { // needs to start, provision its queues and run the boot discovery; the // probe now answers `starting` meanwhile. A request that enqueues while // the boss is not bound yet takes the same path as during a producer - // reconnect: a garnish skips its enqueue (the owning cron catches up), - // and a route that needs the worker answers 503 until it is up. + // reconnect: the enqueue waits for the boss in the background + // (`enqueueWhenBossReady`, bounded and logged when it gives up), and a + // route that needs the worker answers 503 until it is up. const [ { shouldRunWorker }, { markStartup, startupPhase, startupElapsedMs, STARTING_MAX_MS }, diff --git a/src/lib/jobs/__tests__/boss-ready.test.ts b/src/lib/jobs/__tests__/boss-ready.test.ts new file mode 100644 index 000000000..78d4a6332 --- /dev/null +++ b/src/lib/jobs/__tests__/boss-ready.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PgBoss } from "pg-boss"; + +import { markStartup } from "@/lib/boot/startup-state"; +import { setGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + BOSS_START_KEY, + enqueueWhenBossReady, + whenBossReady, +} from "@/lib/jobs/boss-ready"; + +const STARTUP_KEY = Symbol.for("healthlog.boot.startupState"); + +function fakeBoss(): PgBoss { + return { send: vi.fn(async () => "job-1") } as unknown as PgBoss; +} + +beforeEach(() => { + vi.useFakeTimers(); + setGlobalBoss(null); + delete (globalThis as Record)[STARTUP_KEY]; + delete (globalThis as Record)[BOSS_START_KEY]; +}); + +afterEach(() => { + vi.useRealTimers(); + setGlobalBoss(null); + delete (globalThis as Record)[STARTUP_KEY]; + delete (globalThis as Record)[BOSS_START_KEY]; + vi.restoreAllMocks(); +}); + +describe("enqueueWhenBossReady", () => { + it("sends at once through a bound boss", async () => { + const boss = fakeBoss(); + setGlobalBoss(boss); + const send = vi.fn(async (b: PgBoss) => b.send("q", {})); + await expect(enqueueWhenBossReady("q", send)).resolves.toBe("sent"); + expect(send).toHaveBeenCalledWith(boss); + }); + + it("skips without waiting when no start is under way", async () => { + const send = vi.fn(async () => undefined); + await expect(enqueueWhenBossReady("q", send)).resolves.toBe("skipped"); + expect(send).not.toHaveBeenCalled(); + }); + + it("sends once the boss is bound when it arrives during boot", async () => { + markStartup("starting"); + const send = vi.fn(async () => undefined); + await expect(enqueueWhenBossReady("q", send)).resolves.toBe("deferred"); + expect(send).not.toHaveBeenCalled(); + + const boss = fakeBoss(); + setGlobalBoss(boss); + await vi.advanceTimersByTimeAsync(300); + expect(send).toHaveBeenCalledWith(boss); + }); + + it("waits for a producer reconnect too", async () => { + (globalThis as Record)[BOSS_START_KEY] = Promise.resolve(); + const send = vi.fn(async () => undefined); + await expect(enqueueWhenBossReady("q", send)).resolves.toBe("deferred"); + setGlobalBoss(fakeBoss()); + await vi.advanceTimersByTimeAsync(300); + expect(send).toHaveBeenCalledTimes(1); + }); +}); + +describe("whenBossReady", () => { + it("gives up after the timeout and says so", async () => { + markStartup("starting"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const pending = whenBossReady("rollup-recompute", 1_000); + await vi.advanceTimersByTimeAsync(1_100); + await expect(pending).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("rollup-recompute was not enqueued"), + ); + }); + + it("stops waiting as soon as the start fails", async () => { + markStartup("starting"); + const pending = whenBossReady("q", 60_000); + markStartup("failed"); + await vi.advanceTimersByTimeAsync(300); + await expect(pending).resolves.toBeNull(); + }); +}); diff --git a/src/lib/jobs/boss-instance.ts b/src/lib/jobs/boss-instance.ts index 438e85818..c9f056acd 100644 --- a/src/lib/jobs/boss-instance.ts +++ b/src/lib/jobs/boss-instance.ts @@ -9,9 +9,11 @@ import { PgBoss } from "pg-boss"; import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db"; import { timeZoneSessionOptions } from "@/lib/db-session"; +// The slot a start is parked in; `whenBossReady` reads it to know one is +// under way. +import { BOSS_START_KEY } from "@/lib/jobs/boss-ready"; const BOSS_KEY = "__healthlog_pgboss__" as const; -const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; const PRODUCER_START_MAX_ATTEMPTS = 5; const PRODUCER_RETRY_BASE_DELAY_MS = 250; const PRODUCER_RETRY_MAX_DELAY_MS = 2_000; diff --git a/src/lib/jobs/boss-ready.ts b/src/lib/jobs/boss-ready.ts new file mode 100644 index 000000000..1388fcfff --- /dev/null +++ b/src/lib/jobs/boss-ready.ts @@ -0,0 +1,93 @@ +/** + * Enqueue work that must not be lost while the process is still booting. + * + * The HTTP server answers before pg-boss is bound (`instrumentation.ts` starts + * the background role after the server is up), and a producer that lost its + * connection rebinds in the background. A request in either window used to + * read `getGlobalBoss()` as null and drop its enqueue: an OAuth callback + * skipped the first sync and the history backfill, a measurement write skipped + * its rollup recompute. Those waited for the next cron or boot discovery. + * + * `whenBossReady()` waits for the boss while a start is under way, bounded by + * `BOSS_READY_TIMEOUT_MS`, and logs one line when it gives up. With no start + * under way (a process that never runs a background role, or one whose start + * failed) it answers null at once: nothing is coming, so nothing is waited for. + * + * `enqueueWhenBossReady()` is what a producer calls. With the boss bound it + * sends at once and the caller sees the send's own result and errors, as + * before. While a start is under way it does not hold the request: the send + * waits in the background and runs once the boss is up. + */ +import type { PgBoss } from "pg-boss"; + +import { startupPhase } from "@/lib/boot/startup-state"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; + +/** The global slot a producer (re)start is parked in while it runs. */ +export const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; + +/** Longest an enqueue waits for the boss before it is given up. */ +export const BOSS_READY_TIMEOUT_MS = 60_000; + +const POLL_MS = 250; + +/** Is a boss start (boot, or a producer reconnect) under way right now? */ +export function bossStartPending(): boolean { + if ((globalThis as Record)[BOSS_START_KEY]) return true; + return startupPhase() === "starting"; +} + +/** + * The bound boss, waiting for it while a start is under way. Null when no + * start is under way or the wait ran out (logged with `label`). + */ +export async function whenBossReady( + label: string, + timeoutMs: number = BOSS_READY_TIMEOUT_MS, +): Promise { + const bound = getGlobalBoss(); + if (bound) return bound; + if (!bossStartPending()) return null; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => { + setTimeout(resolve, Math.min(POLL_MS, deadline - Date.now())).unref?.(); + }); + const boss = getGlobalBoss(); + if (boss) return boss; + if (!bossStartPending()) return null; + } + console.warn( + `[pg-boss] not ready after ${Math.round(timeoutMs / 1000)} s; ${label} was not enqueued`, + ); + return null; +} + +/** + * Send through the boss now when it is bound, or once it is up when a start + * is under way. Returns whether the send ran (or was deferred) rather than + * being skipped for want of a boss. + */ +export async function enqueueWhenBossReady( + label: string, + send: (boss: PgBoss) => Promise, +): Promise<"sent" | "deferred" | "skipped"> { + const bound = getGlobalBoss(); + if (bound) { + await send(bound); + return "sent"; + } + if (!bossStartPending()) return "skipped"; + trackBackgroundTask( + whenBossReady(label).then(async (boss) => { + if (!boss) return; + try { + await send(boss); + } catch (err) { + console.warn(`[pg-boss] deferred ${label} enqueue failed:`, err); + } + }), + ); + return "deferred"; +} diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index a5277687f..bf3343d18 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -36,6 +36,7 @@ */ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { annotate } from "@/lib/logging/context"; import { readCanonicalRollupBuckets, @@ -521,11 +522,12 @@ function bucketRuns( } /** - * Best-effort pg-boss enqueue. Silent no-op when no boss is attached - * (route is running in a context without the worker — typically the - * test suite). The reader-side fall-through still produces fresh - * data on a cache miss, so missing the enqueue is at-most a one-read - * cost rather than a correctness bug. + * Best-effort pg-boss enqueue. A write that lands while the process is still + * bringing its boss up is sent once the boss is bound (`enqueueWhenBossReady`) + * instead of being dropped; with no boss coming at all (a context without + * the worker, typically the test suite) it is a silent no-op. The + * reader-side fall-through still produces fresh data on a cache miss, so a + * missed enqueue is at most a one-read cost rather than a correctness bug. */ export async function enqueueRollupRecompute(input: { userId: string; @@ -534,8 +536,6 @@ export async function enqueueRollupRecompute(input: { from: Date; to: Date; }): Promise { - const boss = getGlobalBoss(); - if (!boss) return; const payload: RollupRecomputePayload = { userId: input.userId, type: input.type, @@ -544,34 +544,36 @@ export async function enqueueRollupRecompute(input: { to: input.to.toISOString(), enqueuedAt: new Date().toISOString(), }; - await boss.send(ROLLUP_RECOMPUTE_QUEUE, payload, { - retryLimit: 3, - retryDelay: 30, - retryBackoff: true, - // Coalesce duplicate enqueues for the same bucket key — multiple - // measurements landing on the same day shouldn't each spawn a - // separate worker run. The singleton key uses bucket-start so - // two writes inside the same week share one queued job. - // - // The key only does that because the queue carries the `short` policy - // (see `reminder/register-rollup.ts`); under pg-boss's default `standard` - // policy nothing indexes `singleton_key` and this key coalesced nothing, - // which is how a large sync came to fan out thousands of jobs re-deriving - // byte-identical rows. `short` collapses sends only while an identical job - // is still QUEUED — safe here precisely because the handler re-reads the - // bucket's live rows when it starts, so the queued job still sees writes - // that land after it was enqueued. - // - // A job covering a run of several buckets (`recomputeBucketsForKeys`) - // carries its end in the key too: under the bare start it would collapse - // into a queued single-bucket job and the rest of the run would be lost. - singletonKey: - `${input.userId}|${input.type}|${input.granularity}|${input.from.toISOString()}` + - (bucketSpan(input.from, input.granularity).to.getTime() === - input.to.getTime() - ? "" - : `|${input.to.toISOString()}`), - }); + await enqueueWhenBossReady("rollup-recompute", (boss) => + boss.send(ROLLUP_RECOMPUTE_QUEUE, payload, { + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + // Coalesce duplicate enqueues for the same bucket key — multiple + // measurements landing on the same day shouldn't each spawn a + // separate worker run. The singleton key uses bucket-start so + // two writes inside the same week share one queued job. + // + // The key only does that because the queue carries the `short` policy + // (see `reminder/register-rollup.ts`); under pg-boss's default `standard` + // policy nothing indexes `singleton_key` and this key coalesced nothing, + // which is how a large sync came to fan out thousands of jobs re-deriving + // byte-identical rows. `short` collapses sends only while an identical job + // is still QUEUED — safe here precisely because the handler re-reads the + // bucket's live rows when it starts, so the queued job still sees writes + // that land after it was enqueued. + // + // A job covering a run of several buckets (`recomputeBucketsForKeys`) + // carries its end in the key too: under the bare start it would collapse + // into a queued single-bucket job and the rest of the run would be lost. + singletonKey: + `${input.userId}|${input.type}|${input.granularity}|${input.from.toISOString()}` + + (bucketSpan(input.from, input.granularity).to.getTime() === + input.to.getTime() + ? "" + : `|${input.to.toISOString()}`), + }), + ); } /** diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index 1a8627190..cd86d10c6 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -47,6 +47,7 @@ */ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { annotate } from "@/lib/logging/context"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; @@ -349,8 +350,6 @@ export async function enqueueMoodRollupRecompute(input: { from: Date; to: Date; }): Promise { - const boss = getGlobalBoss(); - if (!boss) return; const payload: MoodRollupRecomputePayload = { userId: input.userId, granularity: input.granularity, @@ -358,14 +357,16 @@ export async function enqueueMoodRollupRecompute(input: { to: input.to.toISOString(), enqueuedAt: new Date().toISOString(), }; - await boss.send(MOOD_ROLLUP_RECOMPUTE_QUEUE, payload, { - retryLimit: 3, - retryDelay: 30, - retryBackoff: true, - // Coalesce duplicate enqueues for the same bucket key — two writes - // inside the same week share one queued job. - singletonKey: `${input.userId}|${input.granularity}|${input.from.toISOString()}`, - }); + await enqueueWhenBossReady("mood-rollup-recompute", (boss) => + boss.send(MOOD_ROLLUP_RECOMPUTE_QUEUE, payload, { + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + // Coalesce duplicate enqueues for the same bucket key — two writes + // inside the same week share one queued job. + singletonKey: `${input.userId}|${input.granularity}|${input.from.toISOString()}`, + }), + ); } /** diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index 5cdaff6ff..a815cabba 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -30,7 +30,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { getClientIp } from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; -import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import type { MeasurementType, Prisma } from "@/generated/prisma/client"; import { WHOOP_RECOVERY_FIELD_TAGS } from "./client"; @@ -259,15 +259,17 @@ export async function processWhoopNotification( // resource sync. The payload carries the resource id so the worker can do a // targeted fetch-by-id refresh — landing the exact record immediately rather // than waiting for the next overlap window to re-walk the collection. - const boss = getGlobalBoss(); - if (!boss) { + // A delivery during boot is sent once the boss is up rather than dropped. + const outcome = await enqueueWhenBossReady("whoop-webhook", (boss) => + boss.send(queue, { + userId: connection.userId, + resourceId: resourceId ?? undefined, + }), + ); + if (outcome === "skipped") { getEvent()?.addWarning("whoop-webhook: pg-boss not initialised"); return NextResponse.json({ status: "ok" }, { status: 200 }); } - await boss.send(queue, { - userId: connection.userId, - resourceId: resourceId ?? undefined, - }); annotate({ action: { name: "whoop.webhook.enqueued" }, From 5eb7caebaeda944819c03e4b92e6acb192384ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:38:32 +0200 Subject: [PATCH 33/98] feat(activity): add an activity calendar with personal baseline and goals A week or month of steps, active minutes, workouts by sport and training load, each day placed against the person's own spread over the 84 days before the period and each period against the median of the twelve weeks or six months before it. Optional weekly and monthly goals live on the account (activity_goals_json), travel with the backup and are cleared by a data wipe. The calendar sits on the workouts page; the steps page links to it. Computed server-side without AI. --- docs/api/openapi.yaml | 576 ++++++++++++++++- e2e/activity-calendar.spec.ts | 68 ++ messages/de.json | 68 ++ messages/en.json | 68 ++ messages/es.json | 68 ++ messages/fr.json | 68 ++ messages/it.json | 68 ++ messages/ko.json | 68 ++ messages/nl.json | 68 ++ messages/pl.json | 68 ++ .../0470_user_activity_goals/migration.sql | 9 + prisma/schema.prisma | 6 + src/__tests__/sharing-surface-guard.test.ts | 6 +- .../success-affordance-guard.test.ts | 3 + .../workout-canonical-identity-guard.test.ts | 3 + src/app/api/activity/__tests__/routes.test.ts | 168 +++++ src/app/api/activity/calendar/route.ts | 98 +++ src/app/api/activity/goals/route.ts | 90 +++ src/app/insights/steps/page.tsx | 23 +- src/app/insights/workouts/page-client.tsx | 6 + .../__tests__/activity-calendar-card.test.tsx | 157 +++++ .../activity/activity-calendar-card.tsx | 509 +++++++++++++++ .../activity/activity-goals-sheet.tsx | 151 +++++ src/hooks/use-activity-calendar.ts | 49 ++ .../activity/__tests__/calendar-math.test.ts | 289 +++++++++ src/lib/activity/calendar-math.ts | 609 ++++++++++++++++++ src/lib/activity/calendar-read.ts | 181 ++++++ src/lib/activity/goal-bounds.ts | 27 + src/lib/data-wipe/wipe-plan.ts | 1 + src/lib/export/account-settings-backup.ts | 33 + src/lib/export/backup-plan.ts | 1 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/activity.ts | 102 +++ src/lib/openapi/routes/index.ts | 2 + src/lib/query-keys/activity.ts | 11 + src/lib/query-keys/index.ts | 2 + src/lib/validations/activity-calendar.ts | 182 ++++++ src/lib/validations/activity-goals.ts | 98 +++ src/lib/validations/backup.ts | 1 + .../activity-calendar-read.test.ts | 172 +++++ 40 files changed, 4172 insertions(+), 6 deletions(-) create mode 100644 e2e/activity-calendar.spec.ts create mode 100644 prisma/migrations/0470_user_activity_goals/migration.sql create mode 100644 src/app/api/activity/__tests__/routes.test.ts create mode 100644 src/app/api/activity/calendar/route.ts create mode 100644 src/app/api/activity/goals/route.ts create mode 100644 src/components/activity/__tests__/activity-calendar-card.test.tsx create mode 100644 src/components/activity/activity-calendar-card.tsx create mode 100644 src/components/activity/activity-goals-sheet.tsx create mode 100644 src/hooks/use-activity-calendar.ts create mode 100644 src/lib/activity/__tests__/calendar-math.test.ts create mode 100644 src/lib/activity/calendar-math.ts create mode 100644 src/lib/activity/calendar-read.ts create mode 100644 src/lib/activity/goal-bounds.ts create mode 100644 src/lib/openapi/routes/activity.ts create mode 100644 src/lib/query-keys/activity.ts create mode 100644 src/lib/validations/activity-calendar.ts create mode 100644 src/lib/validations/activity-goals.ts create mode 100644 tests/integration/activity-calendar-read.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 339c7b7c5..8ae7afe7f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5023,6 +5023,89 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 + /api/activity/calendar: + get: + tags: + - Measurements + summary: Activity calendar against the person's own normal + description: "A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step + total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and + workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. + Each day is placed against the person's own spread over the 84 days before the period; the period total against + the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population + norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts + module." + parameters: + - in: query + name: period + schema: + default: month + description: "Calendar span: one week (Monday to Sunday) or one month." + type: string + enum: + - week + - month + description: "Calendar span: one week (Monday to Sunday) or one month." + - in: query + name: date + schema: + description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after + today, or more than ten years back, is refused. + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after + today, or more than ten years back, is refused. + responses: + "200": + description: The calendar. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityCalendarEnvelope" + "401": *a1 + "403": *a13 + "422": *a4 + "429": *a5 + /api/activity/goals: + get: + tags: + - Measurements + summary: Activity goals + description: "The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = + not set; there is no default. Owner only; a delegate reads the goals inside the calendar." + responses: + "200": + description: The goals. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Measurements + summary: Set activity goals + description: "Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated + session cannot change another person's goals." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsPatch" + responses: + "200": + description: The goals after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" + "401": *a1 + "422": *a4 + "429": *a12 /api/devices: post: tags: @@ -27531,6 +27614,41 @@ components: speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only)." + ActivityGoalsPatch: + type: object + properties: + workoutsPerWeek: + description: Workouts per week (Monday to Sunday). `null` clears the goal. + anyOf: + - type: integer + minimum: 1 + maximum: 28 + - type: "null" + activeMinutesPerWeek: + description: "Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed + over the week. `null` clears the goal." + anyOf: + - type: integer + minimum: 10 + maximum: 5000 + - type: "null" + stepsPerWeek: + description: Steps per week. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 700000 + - type: "null" + stepsPerMonth: + description: Steps per calendar month. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 3000000 + - type: "null" + additionalProperties: false + description: The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is + absent stays as it is. Unknown keys are refused. DeviceRegisterRequest: type: object properties: @@ -36469,10 +36587,11 @@ components: one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a - credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded, - ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, - ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider - — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, + credential of its own carries the generic `http.unauthenticated`. activity — + activity.calendar.future_date, activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, + ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, + ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, + ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, @@ -39082,6 +39201,7 @@ components: documentsLayoutJson: {} moduleListLayoutJson: {} previewFeaturesJson: {} + activityGoalsJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -49122,6 +49242,454 @@ components: - index - status additionalProperties: false + ActivityCalendarEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityCalendar" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityCalendar: + type: object + properties: + period: + type: string + enum: + - week + - month + start: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + end: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + gridStart: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: First day drawn (the Monday on or before `start`). + gridEnd: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: Last day drawn (the Sunday on or after `end`). + today: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + timezone: + type: string + description: The IANA zone the days are cut in. + loadType: + anyOf: + - type: string + enum: + - DAY_STRAIN + - CARDIO_LOAD + - STRAIN_SCORE + - type: "null" + description: Which training-load score `load` carries; null when the account has none. + days: + type: array + items: + type: object + properties: + date: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + inPeriod: + type: boolean + future: + type: boolean + steps: + type: + - number + - "null" + activeMinutes: + description: The larger of the day's exercise minutes and workout minutes (never their sum). + type: + - number + - "null" + workoutCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + workoutsBySport: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + load: + type: + - number + - "null" + level: + type: object + properties: + steps: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + activeMinutes: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + load: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + required: + - steps + - activeMinutes + - load + additionalProperties: false + required: + - date + - inPeriod + - future + - steps + - activeMinutes + - workoutCount + - workoutsBySport + - load + - level + additionalProperties: false + weeks: + type: array + items: + type: object + properties: + start: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + end: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + steps: + type: + - number + - "null" + activeMinutes: + type: + - number + - "null" + workouts: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + goals: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + description: The weekly goals for a week that has begun. + required: + - start + - end + - steps + - activeMinutes + - workouts + - goals + additionalProperties: false + baseline: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityBaselineStats" + activeMinutes: + $ref: "#/components/schemas/ActivityBaselineStats" + load: + $ref: "#/components/schemas/ActivityBaselineStats" + required: + - steps + - activeMinutes + - load + additionalProperties: false + summary: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityPeriodComparison" + activeMinutes: + $ref: "#/components/schemas/ActivityPeriodComparison" + workouts: + $ref: "#/components/schemas/ActivityPeriodComparison" + load: + $ref: "#/components/schemas/ActivityPeriodComparison" + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + sports: + type: array + items: + type: object + properties: + sportType: + type: string + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - sportType + - count + additionalProperties: false + goals: + $ref: "#/components/schemas/ActivityGoals" + goalProgress: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + available: + type: object + properties: + steps: + type: boolean + activeMinutes: + type: boolean + workouts: + type: boolean + load: + type: boolean + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + previous: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: A day in the previous period. + next: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + description: A day in the next period, or null when that lies after today. + required: + - period + - start + - end + - gridStart + - gridEnd + - today + - timezone + - loadType + - days + - weeks + - baseline + - summary + - sports + - goals + - goalProgress + - available + - previous + - next + additionalProperties: false + ActivityGoalProgress: + type: object + properties: + key: + type: string + enum: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + target: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + value: + type: number + met: + type: boolean + required: + - key + - target + - value + - met + additionalProperties: false + ActivityBaselineStats: + anyOf: + - type: object + properties: + median: + type: number + p10: + type: number + p25: + type: number + p75: + type: number + p90: + type: number + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days with a value the spread was computed from. + required: + - median + - p10 + - p25 + - p75 + - p90 + - days + additionalProperties: false + - type: "null" + ActivityPeriodComparison: + type: object + properties: + value: + description: "The period so far: a total (steps, active minutes, workouts) or a mean (load)." + type: + - number + - "null" + daysWithData: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + usual: + description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. + Null with fewer than three earlier periods of data." + type: + - number + - "null" + usualToDate: + description: The usual figure for the days elapsed so far; equals `usual` once the period is over. + type: + - number + - "null" + referencePeriods: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - value + - daysWithData + - usual + - usualToDate + - referencePeriods + additionalProperties: false + ActivityGoals: + type: object + properties: + workoutsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + activeMinutesPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerMonth: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + additionalProperties: false + description: The person's own goals; `null` = not set. There is no default goal. + ActivityGoalsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityGoalsPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DeviceRegisterResponse: type: object properties: diff --git a/e2e/activity-calendar.spec.ts b/e2e/activity-calendar.spec.ts new file mode 100644 index 000000000..3cfae6517 --- /dev/null +++ b/e2e/activity-calendar.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The activity calendar on the workouts page (D1036), at phone width: the + * card renders a whole-week grid, pages back a period, switches to the week + * view, and the goals sheet saves a goal the calendar then reports. + */ +test.describe("activity calendar", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test("draws the month, pages back and switches to a week", async ({ + page, + }) => { + await page.goto("/insights/workouts"); + const card = page.locator('[data-slot="activity-calendar"]'); + await expect(card).toBeVisible(); + await expect( + page.locator('[data-slot="activity-calendar-label"]'), + ).not.toHaveText(""); + + const days = page.locator('[data-slot="activity-calendar-day"]'); + const monthCount = await days.count(); + if (monthCount > 0) expect(monthCount % 7).toBe(0); + + const label = page.locator('[data-slot="activity-calendar-label"]'); + const before = await label.textContent(); + await page.locator('[data-slot="activity-calendar-previous"]').click(); + await expect(label).not.toHaveText(before ?? ""); + + await card.getByRole("radio", { name: "Week" }).click(); + if ((await days.count()) > 0) await expect(days).toHaveCount(7); + + // Nothing in the card pushes the page sideways on a phone. + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test("saves a goal and shows its progress", async ({ page }) => { + await page.goto("/insights/workouts"); + await page.locator('[data-slot="activity-calendar-set-goals"]').click(); + await page.getByLabel("Steps per month").fill("150000"); + await page.locator('[data-slot="activity-goals-save"]').click(); + await expect( + page + .locator('[data-slot="activity-calendar-goals"]') + .getByText("Steps per month"), + ).toBeVisible(); + + // Clear it again so the account is left as it was found. + await page.locator('[data-slot="activity-calendar-set-goals"]').click(); + await page.getByLabel("Steps per month").fill(""); + await page.locator('[data-slot="activity-goals-save"]').click(); + await expect( + page + .locator('[data-slot="activity-calendar-goals"]') + .getByText("Steps per month"), + ).toHaveCount(0); + }); +}); diff --git a/messages/de.json b/messages/de.json index a72dcb006..077caaefb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Klinische Obergrenze", "clinicalLower": "Klinische Untergrenze" } + }, + "activityCalendar": { + "title": "Aktivitätskalender", + "periodLabel": "Zeitraum", + "period": { + "week": "Woche", + "month": "Monat" + }, + "weekRange": "{start} bis {end}", + "previous": "Vorheriger Zeitraum", + "next": "Nächster Zeitraum", + "metricLabel": "Tage einfärben nach", + "metric": { + "steps": "Schritte", + "activeMinutes": "Aktive Minuten", + "load": "Trainingslast" + }, + "empty": "In diesem Zeitraum gibt es noch keine Schritte, aktiven Minuten oder Trainings.", + "loadError": "Der Aktivitätskalender konnte nicht geladen werden.", + "weekGoalsColumn": "Wochenziele", + "weekGoalsMet": "Alle Wochenziele erreicht", + "weekGoalsNotMet": "Nicht alle Wochenziele erreicht", + "cell": { + "steps": "{value} Schritte", + "activeMinutes": "{value} aktive Minuten", + "workouts": "{value} Trainings: {sports}" + }, + "level": { + "none": "Keine Daten", + "unrated": "Noch zu wenig Verlauf", + "low": "Weit unter deinem Üblichen", + "below": "Unter deinem Üblichen", + "usual": "Etwa wie üblich", + "above": "Über deinem Üblichen", + "high": "Weit über deinem Üblichen" + }, + "legend": "Verglichen mit deinen 12 Wochen davor:", + "noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.", + "summary": { + "steps": "{value} Schritte", + "activeMinutes": "{value} aktive Minuten", + "workouts": "{value} Trainings", + "load": "Durchschnittliche Trainingslast {value}", + "usual": "Bis hierher üblich bei dir: {value}", + "noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Trainings pro Woche", + "activeMinutesPerWeek": "Aktive Minuten pro Woche", + "stepsPerWeek": "Schritte pro Woche", + "stepsPerMonth": "Schritte pro Monat" + }, + "goalOf": "{value} von {target}", + "setGoals": "Ziele festlegen", + "editGoals": "Ziele ändern", + "goalsSheet": { + "title": "Aktivitätsziele", + "description": "Lass ein Feld leer, wenn du dort kein Ziel möchtest.", + "none": "Kein Ziel", + "invalid": "Eine ganze Zahl von {min} bis {max}.", + "saveError": "Die Ziele konnten nicht gespeichert werden." + }, + "crossLink": { + "title": "Aktivitätskalender", + "body": "Deine Schritte als Woche oder Monat, verglichen mit deinem Üblichen.", + "cta": "Kalender öffnen" + } } } diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..41c212bfc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Clinical upper limit", "clinicalLower": "Clinical lower limit" } + }, + "activityCalendar": { + "title": "Activity calendar", + "periodLabel": "Calendar span", + "period": { + "week": "Week", + "month": "Month" + }, + "weekRange": "{start} to {end}", + "previous": "Previous period", + "next": "Next period", + "metricLabel": "Colour the days by", + "metric": { + "steps": "Steps", + "activeMinutes": "Active minutes", + "load": "Training load" + }, + "empty": "No steps, active minutes or workouts in this period yet.", + "loadError": "The activity calendar could not be loaded.", + "weekGoalsColumn": "Weekly goals", + "weekGoalsMet": "All weekly goals met", + "weekGoalsNotMet": "Not all weekly goals met", + "cell": { + "steps": "{value} steps", + "activeMinutes": "{value} active minutes", + "workouts": "{value} workouts: {sports}" + }, + "level": { + "none": "No data", + "unrated": "Not enough history yet", + "low": "Far below your usual", + "below": "Below your usual", + "usual": "About your usual", + "above": "Above your usual", + "high": "Far above your usual" + }, + "legend": "Compared with your 12 weeks before:", + "noBaseline": "Your usual range shows after 14 days with data.", + "summary": { + "steps": "{value} steps", + "activeMinutes": "{value} active minutes", + "workouts": "{value} workouts", + "load": "Average training load {value}", + "usual": "Your usual by this point: {value}", + "noUsual": "Not enough earlier weeks or months to compare with yet." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Workouts per week", + "activeMinutesPerWeek": "Active minutes per week", + "stepsPerWeek": "Steps per week", + "stepsPerMonth": "Steps per month" + }, + "goalOf": "{value} of {target}", + "setGoals": "Set goals", + "editGoals": "Change goals", + "goalsSheet": { + "title": "Activity goals", + "description": "Leave a field empty for no goal.", + "none": "No goal", + "invalid": "A whole number from {min} to {max}.", + "saveError": "The goals could not be saved." + }, + "crossLink": { + "title": "Activity calendar", + "body": "Your steps as a week or a month, against your own usual.", + "cta": "Open calendar" + } } } diff --git a/messages/es.json b/messages/es.json index 3239be811..7a297a960 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Límite clínico superior", "clinicalLower": "Límite clínico inferior" } + }, + "activityCalendar": { + "title": "Calendario de actividad", + "periodLabel": "Periodo del calendario", + "period": { + "week": "Semana", + "month": "Mes" + }, + "weekRange": "{start} a {end}", + "previous": "Periodo anterior", + "next": "Periodo siguiente", + "metricLabel": "Colorear los días por", + "metric": { + "steps": "Pasos", + "activeMinutes": "Minutos activos", + "load": "Carga de entrenamiento" + }, + "empty": "Todavía no hay pasos, minutos activos ni entrenamientos en este periodo.", + "loadError": "No se pudo cargar el calendario de actividad.", + "weekGoalsColumn": "Objetivos semanales", + "weekGoalsMet": "Todos los objetivos semanales cumplidos", + "weekGoalsNotMet": "No se cumplieron todos los objetivos semanales", + "cell": { + "steps": "{value} pasos", + "activeMinutes": "{value} minutos activos", + "workouts": "{value} entrenamientos: {sports}" + }, + "level": { + "none": "Sin datos", + "unrated": "Aún no hay suficiente historial", + "low": "Muy por debajo de lo habitual", + "below": "Por debajo de lo habitual", + "usual": "Como de costumbre", + "above": "Por encima de lo habitual", + "high": "Muy por encima de lo habitual" + }, + "legend": "Comparado con tus 12 semanas anteriores:", + "noBaseline": "Tu rango habitual aparece tras 14 días con datos.", + "summary": { + "steps": "{value} pasos", + "activeMinutes": "{value} minutos activos", + "workouts": "{value} entrenamientos", + "load": "Carga de entrenamiento media {value}", + "usual": "Lo habitual hasta este punto: {value}", + "noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Entrenamientos por semana", + "activeMinutesPerWeek": "Minutos activos por semana", + "stepsPerWeek": "Pasos por semana", + "stepsPerMonth": "Pasos por mes" + }, + "goalOf": "{value} de {target}", + "setGoals": "Fijar objetivos", + "editGoals": "Cambiar objetivos", + "goalsSheet": { + "title": "Objetivos de actividad", + "description": "Deja un campo vacío si no quieres un objetivo.", + "none": "Sin objetivo", + "invalid": "Un número entero de {min} a {max}.", + "saveError": "No se pudieron guardar los objetivos." + }, + "crossLink": { + "title": "Calendario de actividad", + "body": "Tus pasos por semana o por mes, frente a lo que es habitual para ti.", + "cta": "Abrir calendario" + } } } diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..e9c8f45c1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Limite clinique haute", "clinicalLower": "Limite clinique basse" } + }, + "activityCalendar": { + "title": "Calendrier d'activité", + "periodLabel": "Période du calendrier", + "period": { + "week": "Semaine", + "month": "Mois" + }, + "weekRange": "{start} au {end}", + "previous": "Période précédente", + "next": "Période suivante", + "metricLabel": "Colorer les jours selon", + "metric": { + "steps": "Pas", + "activeMinutes": "Minutes actives", + "load": "Charge d'entraînement" + }, + "empty": "Aucun pas, aucune minute active ni aucun entraînement sur cette période pour l'instant.", + "loadError": "Le calendrier d'activité n'a pas pu être chargé.", + "weekGoalsColumn": "Objectifs hebdomadaires", + "weekGoalsMet": "Tous les objectifs hebdomadaires atteints", + "weekGoalsNotMet": "Objectifs hebdomadaires pas tous atteints", + "cell": { + "steps": "{value} pas", + "activeMinutes": "{value} minutes actives", + "workouts": "{value} entraînements : {sports}" + }, + "level": { + "none": "Aucune donnée", + "unrated": "Pas encore assez d'historique", + "low": "Bien en dessous de ton habitude", + "below": "En dessous de ton habitude", + "usual": "Comme d'habitude", + "above": "Au-dessus de ton habitude", + "high": "Bien au-dessus de ton habitude" + }, + "legend": "Comparé à tes 12 semaines précédentes :", + "noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.", + "summary": { + "steps": "{value} pas", + "activeMinutes": "{value} minutes actives", + "workouts": "{value} entraînements", + "load": "Charge d'entraînement moyenne {value}", + "usual": "Ton habitude à ce stade : {value}", + "noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer." + }, + "sportCount": "{sport} : {count}", + "goal": { + "workoutsPerWeek": "Entraînements par semaine", + "activeMinutesPerWeek": "Minutes actives par semaine", + "stepsPerWeek": "Pas par semaine", + "stepsPerMonth": "Pas par mois" + }, + "goalOf": "{value} sur {target}", + "setGoals": "Définir des objectifs", + "editGoals": "Modifier les objectifs", + "goalsSheet": { + "title": "Objectifs d'activité", + "description": "Laisse un champ vide pour ne pas fixer d'objectif.", + "none": "Aucun objectif", + "invalid": "Un nombre entier de {min} à {max}.", + "saveError": "Les objectifs n'ont pas pu être enregistrés." + }, + "crossLink": { + "title": "Calendrier d'activité", + "body": "Tes pas par semaine ou par mois, comparés à ton habitude.", + "cta": "Ouvrir le calendrier" + } } } diff --git a/messages/it.json b/messages/it.json index 85e890708..c522cb6d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Limite clinico superiore", "clinicalLower": "Limite clinico inferiore" } + }, + "activityCalendar": { + "title": "Calendario attività", + "periodLabel": "Periodo del calendario", + "period": { + "week": "Settimana", + "month": "Mese" + }, + "weekRange": "dal {start} al {end}", + "previous": "Periodo precedente", + "next": "Periodo successivo", + "metricLabel": "Colora i giorni per", + "metric": { + "steps": "Passi", + "activeMinutes": "Minuti attivi", + "load": "Carico di allenamento" + }, + "empty": "Ancora nessun passo, minuto attivo o allenamento in questo periodo.", + "loadError": "Impossibile caricare il calendario attività.", + "weekGoalsColumn": "Obiettivi settimanali", + "weekGoalsMet": "Tutti gli obiettivi settimanali raggiunti", + "weekGoalsNotMet": "Non tutti gli obiettivi settimanali raggiunti", + "cell": { + "steps": "{value} passi", + "activeMinutes": "{value} minuti attivi", + "workouts": "{value} allenamenti: {sports}" + }, + "level": { + "none": "Nessun dato", + "unrated": "Storico ancora insufficiente", + "low": "Molto sotto il tuo solito", + "below": "Sotto il tuo solito", + "usual": "Come al solito", + "above": "Sopra il tuo solito", + "high": "Molto sopra il tuo solito" + }, + "legend": "Rispetto alle tue 12 settimane precedenti:", + "noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.", + "summary": { + "steps": "{value} passi", + "activeMinutes": "{value} minuti attivi", + "workouts": "{value} allenamenti", + "load": "Carico di allenamento medio {value}", + "usual": "Il tuo solito fino a questo punto: {value}", + "noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Allenamenti a settimana", + "activeMinutesPerWeek": "Minuti attivi a settimana", + "stepsPerWeek": "Passi a settimana", + "stepsPerMonth": "Passi al mese" + }, + "goalOf": "{value} di {target}", + "setGoals": "Imposta obiettivi", + "editGoals": "Modifica obiettivi", + "goalsSheet": { + "title": "Obiettivi di attività", + "description": "Lascia vuoto un campo per non avere un obiettivo.", + "none": "Nessun obiettivo", + "invalid": "Un numero intero da {min} a {max}.", + "saveError": "Impossibile salvare gli obiettivi." + }, + "crossLink": { + "title": "Calendario attività", + "body": "I tuoi passi per settimana o per mese, rispetto al tuo solito.", + "cta": "Apri il calendario" + } } } diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..91597e3e2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "임상 기준 상한", "clinicalLower": "임상 기준 하한" } + }, + "activityCalendar": { + "title": "활동 캘린더", + "periodLabel": "캘린더 기간", + "period": { + "week": "주", + "month": "월" + }, + "weekRange": "{start} ~ {end}", + "previous": "이전 기간", + "next": "다음 기간", + "metricLabel": "날짜 색상 기준", + "metric": { + "steps": "걸음 수", + "activeMinutes": "활동 시간(분)", + "load": "운동 부하" + }, + "empty": "이 기간에는 아직 걸음 수, 활동 시간, 운동 기록이 없습니다.", + "loadError": "활동 캘린더를 불러오지 못했습니다.", + "weekGoalsColumn": "주간 목표", + "weekGoalsMet": "주간 목표를 모두 달성함", + "weekGoalsNotMet": "주간 목표를 모두 달성하지는 못함", + "cell": { + "steps": "{value}걸음", + "activeMinutes": "활동 {value}분", + "workouts": "운동 {value}회: {sports}" + }, + "level": { + "none": "데이터 없음", + "unrated": "아직 기록이 충분하지 않음", + "low": "평소보다 훨씬 적음", + "below": "평소보다 적음", + "usual": "평소와 비슷함", + "above": "평소보다 많음", + "high": "평소보다 훨씬 많음" + }, + "legend": "이전 12주와 비교:", + "noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.", + "summary": { + "steps": "{value}걸음", + "activeMinutes": "활동 {value}분", + "workouts": "운동 {value}회", + "load": "평균 운동 부하 {value}", + "usual": "이 시점까지의 평소 값: {value}", + "noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "주당 운동 횟수", + "activeMinutesPerWeek": "주당 활동 시간(분)", + "stepsPerWeek": "주당 걸음 수", + "stepsPerMonth": "월간 걸음 수" + }, + "goalOf": "{target} 중 {value}", + "setGoals": "목표 설정", + "editGoals": "목표 변경", + "goalsSheet": { + "title": "활동 목표", + "description": "목표를 두지 않으려면 칸을 비워 두세요.", + "none": "목표 없음", + "invalid": "{min}부터 {max}까지의 정수를 입력하세요.", + "saveError": "목표를 저장하지 못했습니다." + }, + "crossLink": { + "title": "활동 캘린더", + "body": "주 또는 월 단위의 걸음 수를 내 평소 값과 비교합니다.", + "cta": "캘린더 열기" + } } } diff --git a/messages/nl.json b/messages/nl.json index c44560507..85559a6eb 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Klinische bovengrens", "clinicalLower": "Klinische ondergrens" } + }, + "activityCalendar": { + "title": "Activiteitenkalender", + "periodLabel": "Kalenderperiode", + "period": { + "week": "Week", + "month": "Maand" + }, + "weekRange": "{start} t/m {end}", + "previous": "Vorige periode", + "next": "Volgende periode", + "metricLabel": "Dagen kleuren op", + "metric": { + "steps": "Stappen", + "activeMinutes": "Actieve minuten", + "load": "Trainingsbelasting" + }, + "empty": "Nog geen stappen, actieve minuten of trainingen in deze periode.", + "loadError": "De activiteitenkalender kon niet worden geladen.", + "weekGoalsColumn": "Weekdoelen", + "weekGoalsMet": "Alle weekdoelen gehaald", + "weekGoalsNotMet": "Niet alle weekdoelen gehaald", + "cell": { + "steps": "{value} stappen", + "activeMinutes": "{value} actieve minuten", + "workouts": "{value} trainingen: {sports}" + }, + "level": { + "none": "Geen gegevens", + "unrated": "Nog te weinig geschiedenis", + "low": "Ver onder je gewone niveau", + "below": "Onder je gewone niveau", + "usual": "Ongeveer zoals gewoonlijk", + "above": "Boven je gewone niveau", + "high": "Ver boven je gewone niveau" + }, + "legend": "Vergeleken met je 12 weken daarvoor:", + "noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.", + "summary": { + "steps": "{value} stappen", + "activeMinutes": "{value} actieve minuten", + "workouts": "{value} trainingen", + "load": "Gemiddelde trainingsbelasting {value}", + "usual": "Gebruikelijk tot dit punt: {value}", + "noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Trainingen per week", + "activeMinutesPerWeek": "Actieve minuten per week", + "stepsPerWeek": "Stappen per week", + "stepsPerMonth": "Stappen per maand" + }, + "goalOf": "{value} van {target}", + "setGoals": "Doelen instellen", + "editGoals": "Doelen wijzigen", + "goalsSheet": { + "title": "Activiteitsdoelen", + "description": "Laat een veld leeg als je geen doel wilt.", + "none": "Geen doel", + "invalid": "Een heel getal van {min} tot {max}.", + "saveError": "De doelen konden niet worden opgeslagen." + }, + "crossLink": { + "title": "Activiteitenkalender", + "body": "Je stappen per week of maand, vergeleken met wat gewoon is voor jou.", + "cta": "Kalender openen" + } } } diff --git a/messages/pl.json b/messages/pl.json index c1812715f..0e77760d4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Kliniczna górna granica", "clinicalLower": "Kliniczna dolna granica" } + }, + "activityCalendar": { + "title": "Kalendarz aktywności", + "periodLabel": "Zakres kalendarza", + "period": { + "week": "Tydzień", + "month": "Miesiąc" + }, + "weekRange": "od {start} do {end}", + "previous": "Poprzedni okres", + "next": "Następny okres", + "metricLabel": "Koloruj dni według", + "metric": { + "steps": "Kroki", + "activeMinutes": "Aktywne minuty", + "load": "Obciążenie treningowe" + }, + "empty": "W tym okresie nie ma jeszcze kroków, aktywnych minut ani treningów.", + "loadError": "Nie udało się wczytać kalendarza aktywności.", + "weekGoalsColumn": "Cele tygodniowe", + "weekGoalsMet": "Wszystkie cele tygodniowe osiągnięte", + "weekGoalsNotMet": "Nie wszystkie cele tygodniowe osiągnięte", + "cell": { + "steps": "Kroki: {value}", + "activeMinutes": "Aktywne minuty: {value}", + "workouts": "Treningi: {value} ({sports})" + }, + "level": { + "none": "Brak danych", + "unrated": "Za mało historii", + "low": "Znacznie poniżej twojej normy", + "below": "Poniżej twojej normy", + "usual": "Mniej więcej jak zwykle", + "above": "Powyżej twojej normy", + "high": "Znacznie powyżej twojej normy" + }, + "legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:", + "noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.", + "summary": { + "steps": "Kroki: {value}", + "activeMinutes": "Aktywne minuty: {value}", + "workouts": "Treningi: {value}", + "load": "Średnie obciążenie treningowe {value}", + "usual": "Twoja norma do tego momentu: {value}", + "noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Treningi w tygodniu", + "activeMinutesPerWeek": "Aktywne minuty w tygodniu", + "stepsPerWeek": "Kroki w tygodniu", + "stepsPerMonth": "Kroki w miesiącu" + }, + "goalOf": "{value} z {target}", + "setGoals": "Ustaw cele", + "editGoals": "Zmień cele", + "goalsSheet": { + "title": "Cele aktywności", + "description": "Zostaw pole puste, jeśli nie chcesz celu.", + "none": "Brak celu", + "invalid": "Liczba całkowita od {min} do {max}.", + "saveError": "Nie udało się zapisać celów." + }, + "crossLink": { + "title": "Kalendarz aktywności", + "body": "Twoje kroki w tygodniu lub miesiącu na tle twojej normy.", + "cta": "Otwórz kalendarz" + } } } diff --git a/prisma/migrations/0470_user_activity_goals/migration.sql b/prisma/migrations/0470_user_activity_goals/migration.sql new file mode 100644 index 000000000..49d25a9d1 --- /dev/null +++ b/prisma/migrations/0470_user_activity_goals/migration.sql @@ -0,0 +1,9 @@ +-- Optional per-user activity goals for the activity calendar: workouts, +-- active minutes and steps per week, steps per month. +-- +-- Nullable, no default, no backfill: null reads as "no goal set", which is +-- every account's state today. +-- +-- Reversibility: dropping the column removes the goals; nothing else reads it. + +ALTER TABLE "users" ADD COLUMN "activity_goals_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 254c052d1..7c2a5b0e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -380,6 +380,12 @@ model User { /// Settings > System through `/api/settings/preview-features`. /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will. previewFeaturesJson Json? @map("preview_features_json") + /// Optional activity goals the person set for themselves: workouts, active + /// minutes and steps per week, steps per month. Null or an absent key = no + /// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through + /// `/api/activity/goals`, read by the activity calendar + /// (`/api/activity/calendar`), which reports progress against each goal set. + activityGoalsJson Json? @map("activity_goals_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d6ec5452e..cdd3e4159 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1077,6 +1077,10 @@ const DELEGABLE_ROUTES: Record = { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", }, + "app/api/activity/calendar/route.ts": { + domain: "measurements", + why: "The activity calendar: day totals of steps, active minutes and load, workouts by sport, and the record owner's goals, computed without AI. Read-only, scoped to the resolved user, module-gated on the record's own `workouts` switch like the workout list beside it.", + }, "app/api/cycle/cycles/route.ts": { domain: "cycle", why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.", @@ -2172,7 +2176,7 @@ const ACTOR_ROUTES: Record = { * v1.44 -- a guardian connects and disconnects a managed profile's OAuth * providers: two guardian-only entries. 266 -> 268. */ -const FROZEN_ENTRY_COUNT = 268; +const FROZEN_ENTRY_COUNT = 269; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 858a950ab..361c8afa2 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record< // the two status grids used to spell the tint at every call site and now // name a tone instead, so the marker lives here once. "src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 }, + // v1.44: the activity calendar's per-week tick for "every weekly goal met", + // a state of a row the person reads, not the outcome of a write. + "src/components/activity/activity-calendar-card.tsx": { "text-success": 1 }, // v1.42: the readiness inventory's "carries" status icon, one arm of the // carries / thin / empty tone table, beside its spoken status label. "src/components/timeline/readiness-inventory.tsx": { "text-success": 1 }, diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 416faa7e5..4525be0f6 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record = { "lib/jobs/workout-insight-generate.ts", "lib/workouts/list-read.ts": "lib/workouts/list-read.ts", "lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts", + // The activity calendar counts workouts per day; a session two devices + // recorded must count once there, as it does in the list. + "lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts", // The only indirection in the set. The Coach block is handed rows it does not // read itself; the snapshot builder fetches them in parallel with every other // block's data. Recording the owner here rather than assuming the call site diff --git a/src/app/api/activity/__tests__/routes.test.ts b/src/app/api/activity/__tests__/routes.test.ts new file mode 100644 index 000000000..e189a3cea --- /dev/null +++ b/src/app/api/activity/__tests__/routes.test.ts @@ -0,0 +1,168 @@ +/** + * `/api/activity/calendar` and `/api/activity/goals` (D1036). + * + * The goals: a PATCH replaces only what it names, `null` clears, an unknown + * key or a goal out of bounds is a 422 and writes nothing, and clearing the + * last goal returns the column to NULL rather than storing an empty object. + * The calendar: the module gate answers before any read, a future or malformed + * day is refused, and the reader is asked for exactly the window the period + * needs and never past today. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + findUnique: vi.fn(), + update: vi.fn(), + readActivityDays: vi.fn(), + requireModuleEnabled: vi.fn(), + auditLog: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })), + requireRecordAuth: vi.fn(async () => ({ + user: { id: "user-1" }, + actor: { id: "user-1" }, + grantId: null, + })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } }, + toJson: (v: unknown) => v, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: mocks.requireModuleEnabled, +})); +vi.mock("@/lib/activity/calendar-read", () => ({ + readActivityDays: mocks.readActivityDays, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); + +import { Prisma } from "@/generated/prisma/client"; +import { GET as calendarGet } from "../calendar/route"; +import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route"; + +type Handler = (req: NextRequest) => Promise; + +function patch(body: unknown) { + return (goalsPatch as unknown as Handler)( + new NextRequest("http://localhost/api/activity/goals", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function calendar(query: string) { + return (calendarGet as unknown as Handler)( + new NextRequest(`http://localhost/api/activity/calendar${query}`), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.findUnique.mockResolvedValue({ + activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 }, + }); + mocks.update.mockResolvedValue({}); + mocks.requireModuleEnabled.mockResolvedValue({ enabled: true }); + mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null }); +}); + +describe("activity goals", () => { + it("reads every goal, unset ones as null", async () => { + const res = await (goalsGet as unknown as () => Promise)(); + const body = await res.json(); + expect(body.data).toEqual({ + workoutsPerWeek: 3, + activeMinutesPerWeek: null, + stepsPerWeek: 50000, + stepsPerMonth: null, + }); + }); + + it("changes what the body names and keeps the rest", async () => { + const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 }); + expect(res.status).toBe(200); + expect(mocks.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + activityGoalsJson: { + workoutsPerWeek: 3, + activeMinutesPerWeek: null, + stepsPerWeek: null, + stepsPerMonth: 250000, + }, + }, + }); + expect(mocks.auditLog).toHaveBeenCalledWith( + "activity.goals.update", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("stores NULL once the last goal is cleared", async () => { + await patch({ workoutsPerWeek: null, stepsPerWeek: null }); + expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe( + Prisma.DbNull, + ); + }); + + it.each([ + [{ caloriesPerWeek: 3000 }], + [{ workoutsPerWeek: 0 }], + [{ stepsPerWeek: 2.5 }], + [{ stepsPerMonth: "200000" }], + ])("refuses %j without writing", async (body) => { + const res = await patch(body); + expect(res.status).toBe(422); + expect(mocks.update).not.toHaveBeenCalled(); + }); +}); + +describe("activity calendar", () => { + it("answers the module gate before reading anything", async () => { + mocks.requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + }); + const res = await calendar("?period=month"); + expect(res.status).toBe(403); + expect(mocks.readActivityDays).not.toHaveBeenCalled(); + }); + + it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])( + "refuses %s", + async (q) => { + const res = await calendar(q); + expect(res.status).toBe(422); + }, + ); + + it("refuses a day after today", async () => { + const res = await calendar("?date=2999-01-01"); + expect(res.status).toBe(422); + expect(mocks.readActivityDays).not.toHaveBeenCalled(); + }); + + it("reads the baseline window before the period and nothing past the grid", async () => { + const res = await calendar("?period=week&date=2025-03-12"); + expect(res.status).toBe(200); + const args = mocks.readActivityDays.mock.calls[0][0]; + // Week of Monday 2025-03-10; 84 days before it. + expect(args.fromKey).toBe("2024-12-16"); + expect(args.toKey).toBe("2025-03-16"); + expect(args.tz).toBe("Europe/Berlin"); + const body = await res.json(); + expect(body.data.start).toBe("2025-03-10"); + expect(body.data.goals.workoutsPerWeek).toBe(3); + expect(body.data.days).toHaveLength(7); + }); +}); diff --git a/src/app/api/activity/calendar/route.ts b/src/app/api/activity/calendar/route.ts new file mode 100644 index 000000000..9ee482f07 --- /dev/null +++ b/src/app/api/activity/calendar/route.ts @@ -0,0 +1,98 @@ +/** + * `GET /api/activity/calendar` — a week or a month of activity against the + * person's own normal, with their weekly and monthly goals (D1036). + * + * Computed server-side, without AI, from the stored readings: steps (the + * canonical-source day totals the steps chart draws), exercise minutes, + * workouts after the cross-source collapse, and a training-load score when a + * device or HealthLog provides one. What "usual" means is in + * `src/lib/activity/calendar-math.ts`; it is always the person's own history, + * never a population norm. + * + * Owned by the Workouts module, like the page that shows it: a switched-off + * module answers 403 here as `/api/workouts` does. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { + buildActivityCalendar, + periodBounds, +} from "@/lib/activity/calendar-math"; +import { readActivityDays } from "@/lib/activity/calendar-read"; +import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar"; +import { parseActivityGoals } from "@/lib/validations/activity-goals"; + +export const dynamic = "force-dynamic"; + +/** How far back the calendar pages: ten years, the series route's ceiling. */ +const MAX_DAYS_BACK = 3650 - 200; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "measurements"); + annotate({ action: { name: "activity.calendar.read" } }); + + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + + const params = new URL(request.url).searchParams; + const parsed = activityCalendarQuerySchema.safeParse({ + period: params.get("period") ?? undefined, + date: params.get("date") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const tz = await resolveUserTimezone(user.id); + const now = new Date(); + const today = userDayKey(now, tz); + const anchor = parsed.data.date ?? today; + if (anchor > today) { + return apiError("The calendar cannot show a day after today", 422, { + errorCode: "activity.calendar.future_date", + }); + } + if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) { + return apiError("The calendar reaches back ten years", 422, { + errorCode: "activity.calendar.too_old", + }); + } + + const bounds = periodBounds(parsed.data.period, anchor); + const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today; + const [{ days, loadType }, goalRow] = await Promise.all([ + readActivityDays({ + userId: user.id, + tz, + fromKey: bounds.readStart, + toKey: lastRead, + now, + includeWorkouts: true, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { activityGoalsJson: true }, + }), + ]); + + const calendar = buildActivityCalendar({ + bounds, + today, + input: days, + goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null), + }); + + annotate({ + meta: { + activity_calendar_period: bounds.period, + activity_calendar_days_read: days.length, + activity_calendar_load_type: loadType ?? "none", + }, + }); + return apiSuccess({ ...calendar, timezone: tz, loadType }); +}); diff --git a/src/app/api/activity/goals/route.ts b/src/app/api/activity/goals/route.ts new file mode 100644 index 000000000..2a2ef4c5d --- /dev/null +++ b/src/app/api/activity/goals/route.ts @@ -0,0 +1,90 @@ +/** + * GET / PATCH the caller's activity goals (D1036). + * + * Every goal is optional and set by the person; there is no default goal. + * PATCH replaces the goals the body names (`null` clears one) and keeps the + * rest; an unknown key or an out-of-bounds goal is refused with the + * multi-issue 422. The goals live on their own `User` column + * (`activity_goals_json`) and the activity calendar reads them. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { Prisma } from "@/generated/prisma/client"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + ACTIVITY_GOAL_KEYS, + activityGoalsSchema, + hasNoActivityGoals, + mergeActivityGoals, + parseActivityGoals, + type ActivityGoals, +} from "@/lib/validations/activity-goals"; + +export const dynamic = "force-dynamic"; + +async function readGoals(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { activityGoalsJson: true }, + }); + return parseActivityGoals(row?.activityGoalsJson ?? null); +} + +function setKeys(goals: ActivityGoals): string[] { + return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null); +} + +export const GET = apiHandler(async () => { + // Owner-only, like the write: a delegate sees the goals where they matter, + // inside the calendar, which carries them. + const { user } = await requireAuth(); + const goals = await readGoals(user.id); + annotate({ + action: { name: "activity.goals.read" }, + meta: { set: setKeys(goals) }, + }); + return apiSuccess(goals); +}); + +export const PATCH = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = activityGoalsSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "activity.goals.validation-failed" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = mergeActivityGoals(await readGoals(user.id), parsed.data); + await prisma.user.update({ + where: { id: user.id }, + data: { + activityGoalsJson: hasNoActivityGoals(next) + ? Prisma.DbNull + : toJson(next), + }, + }); + + await auditLog("activity.goals.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { set: setKeys(next) }, + }); + annotate({ + action: { name: "activity.goals.update" }, + meta: { set: setKeys(next) }, + }); + return apiSuccess(next); +}); diff --git a/src/app/insights/steps/page.tsx b/src/app/insights/steps/page.tsx index 57783168e..a562c4baf 100644 --- a/src/app/insights/steps/page.tsx +++ b/src/app/insights/steps/page.tsx @@ -1,8 +1,11 @@ "use client"; -import { Footprints } from "lucide-react"; +import { CalendarDays, Footprints } from "lucide-react"; +import { CrossLinkCard } from "@/components/insights/cross-link-card"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useSurfaceVisible } from "@/hooks/use-surface-visible"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.12 — `/insights/steps`. @@ -15,8 +18,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * user having step data. Reuses the generic HealthKitMetricPage scaffold; * empty-state carries no manual-entry CTA — the series arrives from Apple * Health or Withings sync. + * + * v1.44 — links across to the activity calendar on the workouts page, which + * shows the same day totals as a week or month against the person's usual. + * The link follows the Workouts module: switched off, it is not offered. */ export default function InsightsStepsPage() { + const { t } = useTranslations(); + const calendarVisible = useSurfaceVisible("insights-page:workouts"); return ( + ) : null + } /> ); } diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index c307d65af..40e58529d 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -21,6 +21,7 @@ import { canLogWorkout } from "@/lib/workouts/manual-entry"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { translateOrNull } from "@/lib/i18n/translate-code"; import { EmptyState } from "@/components/ui/empty-state"; +import { ActivityCalendarCard } from "@/components/activity/activity-calendar-card"; /** * v1.4.32 — `/insights/workouts`. @@ -107,6 +108,11 @@ export default function InsightsWorkoutsPageClient() { {showLog ? ( ) : null} + {/* The activity calendar (D1036): steps, active minutes and workouts + against the person's own usual. It stands above the list and draws + from steps alone too, so an account without workouts still sees + its routine. */} + {/* No `` here: workouts are session records, not a MeasurementType series, so the period-over-period range read has nothing to aggregate. */} diff --git a/src/components/activity/__tests__/activity-calendar-card.test.tsx b/src/components/activity/__tests__/activity-calendar-card.test.tsx new file mode 100644 index 000000000..a005c664f --- /dev/null +++ b/src/components/activity/__tests__/activity-calendar-card.test.tsx @@ -0,0 +1,157 @@ +/** + * `` draws what the server computed (SSR markup, like + * the other component suites). The arithmetic is pinned in + * `src/lib/activity/__tests__/calendar-math.test.ts`; this suite pins that + * the card shows each day's level, the workouts, the summary against the + * usual and the goals, and that a shared record gets no goal editor. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + buildActivityCalendar, + periodBounds, + type ActivityDayInput, +} from "@/lib/activity/calendar-math"; +import { shiftDateKey } from "@/lib/tz/format"; +import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals"; + +const state: { data: unknown; shared: boolean } = { + data: undefined, + shared: false, +}; + +vi.mock("@/hooks/use-activity-calendar", () => ({ + useActivityCalendar: () => ({ + data: state.data, + isError: false, + error: null, + refetch: vi.fn(), + }), + useSaveActivityGoals: () => ({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isError: false, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: state.shared }), +})); + +import { ActivityCalendarCard } from "../activity-calendar-card"; +import { parseGoalField } from "../activity-goals-sheet"; + +function calendar() { + const today = "2026-10-21"; + const bounds = periodBounds("month", today); + const input: ActivityDayInput[] = []; + for (let i = 1; i <= 200; i++) { + input.push({ + date: shiftDateKey(bounds.start, -i), + steps: 6000 + (i % 4) * 500, + exerciseMinutes: 30, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + }); + } + input.push({ + date: "2026-10-05", + steps: 20000, + exerciseMinutes: 90, + workoutMinutes: 75, + workoutCount: 1, + workoutsBySport: { running: 1 }, + load: null, + }); + return { + ...buildActivityCalendar({ + bounds, + today, + input, + goals: { + ...NO_ACTIVITY_GOALS, + workoutsPerWeek: 1, + stepsPerMonth: 100000, + }, + }), + timezone: "Europe/Berlin", + loadType: null, + }; +} + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("ActivityCalendarCard", () => { + it("shows a loading frame before the data arrives", () => { + state.data = undefined; + expect(render()).toContain('data-slot="activity-calendar-loading"'); + }); + + it("draws every day of the grid with its level", () => { + state.data = calendar(); + const html = render(); + const days = html.match(/data-slot="activity-calendar-day"/g) ?? []; + expect(days.length).toBe(35); + expect(html).toMatch(/data-date="2026-10-05" data-level="high"/); + expect(html).toMatch(/data-date="2026-10-30" data-level="none"/); + // The accessible name carries the figures and the level in words. + expect(html).toContain("20,000 steps"); + expect(html).toContain("Far above your usual"); + }); + + it("summarises the period against the usual and lists the sports", () => { + state.data = calendar(); + const html = render(); + expect(html).toContain('data-metric="steps"'); + expect(html).toContain("Your usual by this point:"); + expect(html).toContain("Running: 1"); + }); + + it("shows goal progress and a week tick column", () => { + state.data = calendar(); + const html = render(); + expect(html).toContain("Steps per month"); + expect(html).toContain( + 'data-slot="activity-calendar-week-goal" data-met="true"', + ); + expect(html).toContain('data-slot="activity-calendar-set-goals"'); + }); + + it("offers no goal editor inside somebody else's record", () => { + state.data = calendar(); + state.shared = true; + const html = render(); + state.shared = false; + expect(html).not.toContain('data-slot="activity-calendar-set-goals"'); + }); +}); + +describe("goal field parsing", () => { + it("blank clears, digits within bounds pass, anything else is refused", () => { + expect(parseGoalField("workoutsPerWeek", " ")).toEqual({ + ok: true, + value: null, + }); + expect(parseGoalField("workoutsPerWeek", "4")).toEqual({ + ok: true, + value: 4, + }); + expect(parseGoalField("workoutsPerWeek", "0")).toEqual({ ok: false }); + expect(parseGoalField("stepsPerWeek", "10.000")).toEqual({ ok: false }); + expect(parseGoalField("stepsPerWeek", "70000")).toEqual({ + ok: true, + value: 70000, + }); + expect(parseGoalField("stepsPerMonth", "99999999")).toEqual({ ok: false }); + }); +}); diff --git a/src/components/activity/activity-calendar-card.tsx b/src/components/activity/activity-calendar-card.tsx new file mode 100644 index 000000000..b5ec47a75 --- /dev/null +++ b/src/components/activity/activity-calendar-card.tsx @@ -0,0 +1,509 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + CalendarDays, + Check, + ChevronLeft, + ChevronRight, + Target, +} from "lucide-react"; + +import { TileHeader } from "@/components/insights/tile-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { InlineAction } from "@/components/ui/inline-action"; +import { Progress } from "@/components/ui/progress"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ScorePill } from "@/components/ui/score-pill"; +import { Segmented } from "@/components/ui/segmented"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TagChip } from "@/components/ui/tag-chip"; +import { ActivityGoalsSheet } from "@/components/activity/activity-goals-sheet"; +import { useActivityCalendar } from "@/hooks/use-activity-calendar"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { translateOrNull } from "@/lib/i18n/translate-code"; +import { cn } from "@/lib/utils"; +import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar"; + +type Period = "week" | "month"; +type RatedMetric = "steps" | "activeMinutes" | "load"; +type CalendarDay = ActivityCalendarResponse["days"][number]; +type DayLevel = CalendarDay["level"]["steps"]; + +/** + * One hue, rising intensity: the calendar shows more and less than usual, + * not good and bad, so no status colour takes part. The bar is a graphic + * (3:1 floor), the day number beside it stays foreground text. + */ +const LEVEL_BAR: Record = { + none: "bg-transparent", + unrated: "bg-muted-foreground/60", + low: "bg-chart-2/20", + below: "bg-chart-2/40", + usual: "bg-chart-2/60", + above: "bg-chart-2/80", + high: "bg-chart-2", +}; + +const LEGEND_LEVELS: DayLevel[] = ["low", "below", "usual", "above", "high"]; + +/** 2024-01-01 is a Monday: seven UTC days from it name the week's columns. */ +function weekdayLabels(locale: string): string[] { + const fmt = new Intl.DateTimeFormat(locale, { + weekday: "narrow", + timeZone: "UTC", + }); + return Array.from({ length: 7 }, (_, i) => + fmt.format(new Date(Date.UTC(2024, 0, 1 + i))), + ); +} + +function dateOfKey(key: string): Date { + return new Date(`${key}T12:00:00.000Z`); +} + +/** + * The activity calendar (D1036): a week or a month of steps, active minutes, + * workouts and training load against the person's own usual, with the goals + * they set. Every figure comes from `/api/activity/calendar`; this component + * draws, it does not compute. + */ +export function ActivityCalendarCard() { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const capabilities = useRecordCapabilities(); + const [period, setPeriod] = useState("month"); + const [anchor, setAnchor] = useState(null); + const [metricChoice, setMetricChoice] = useState(null); + const [goalsOpen, setGoalsOpen] = useState(false); + // Each opening mounts a fresh sheet, so its draft starts from the stored + // goals; keyed on the count rather than on `open`, so closing keeps the + // sheet mounted for its exit animation. + const [goalsOpenCount, setGoalsOpenCount] = useState(0); + const query = useActivityCalendar(period, anchor); + const data = query.data; + const weekdays = useMemo(() => weekdayLabels(locale), [locale]); + + const ratedOptions = useMemo(() => { + if (!data) return [] as RatedMetric[]; + return (["steps", "activeMinutes", "load"] as const).filter( + (m) => data.available[m], + ); + }, [data]); + const metric: RatedMetric | null = + metricChoice && ratedOptions.includes(metricChoice) + ? metricChoice + : (ratedOptions[0] ?? null); + + const periodLabel = data + ? period === "month" + ? new Intl.DateTimeFormat(locale, { + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(dateOfKey(data.start)) + : t("activityCalendar.weekRange", { + start: fmt.dateShort(dateOfKey(data.start)), + end: fmt.dateShort(dateOfKey(data.end)), + }) + : ""; + + const formatMetric = (m: RatedMetric | "workouts", value: number) => + m === "load" ? fmt.number(value, 1) : fmt.integer(Math.round(value)); + + const sportLabel = (sport: string) => + translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport; + + const weeklyGoalsSet = + data !== undefined && + (data.goals.workoutsPerWeek !== null || + data.goals.activeMinutesPerWeek !== null || + data.goals.stepsPerWeek !== null); + + const levelText = (level: DayLevel) => t(`activityCalendar.level.${level}`); + + const dayLabel = (day: CalendarDay) => { + const parts = [fmt.dateWithWeekday(dateOfKey(day.date))]; + if (day.future) return parts.join(", "); + if (day.steps !== null) + parts.push( + t("activityCalendar.cell.steps", { value: fmt.integer(day.steps) }), + ); + if (day.activeMinutes !== null) + parts.push( + t("activityCalendar.cell.activeMinutes", { + value: fmt.integer(day.activeMinutes), + }), + ); + if (day.workoutCount > 0) + parts.push( + t("activityCalendar.cell.workouts", { + value: fmt.integer(day.workoutCount), + sports: Object.keys(day.workoutsBySport).map(sportLabel).join(", "), + }), + ); + if (metric) parts.push(levelText(day.level[metric])); + return parts.join(", "); + }; + + const rows: CalendarDay[][] = []; + if (data) { + for (let i = 0; i < data.days.length; i += 7) { + rows.push(data.days.slice(i, i + 7)); + } + } + + return ( + + + { + setPeriod(next); + setAnchor(data ? data.start : null); + }} + options={[ + { value: "week", label: t("activityCalendar.period.week") }, + { value: "month", label: t("activityCalendar.period.month") }, + ]} + /> + } + /> + + + {query.isError ? ( + query.refetch()} + /> + ) : !data ? ( +
+ + +
+ ) : ( + <> +
+ +

+ {periodLabel} +

+ +
+ + {ratedOptions.length > 1 && metric ? ( + ({ + value: m, + label: t(`activityCalendar.metric.${m}`), + }))} + /> + ) : null} + + {!data.available.steps && + !data.available.activeMinutes && + !data.available.workouts && + !data.available.load ? ( + + ) : ( +
+
+ {weekdays.map((label, i) => ( + + {label} + + ))} + {weeklyGoalsSet && period === "month" ? ( + + + {t("activityCalendar.weekGoalsColumn")} + + + ) : null} +
+ {rows.map((week, rowIndex) => { + const weekRow = data.weeks[rowIndex]; + const allMet = + weekRow && weekRow.goals.length > 0 + ? weekRow.goals.every((g) => g.met) + : null; + return ( +
+ {week.map((day) => { + const level = metric ? day.level[metric] : "none"; + const value = + metric && !day.future ? day[metric] : null; + return ( +
+ +
+ ); + })} + {weeklyGoalsSet && period === "month" ? ( + + {allMet ? ( + + ) : allMet === false ? ( + + {t("activityCalendar.weekGoalsNotMet")} + + ) : null} + + ) : null} +
+ ); + })} +
+ )} + + {metric ? ( +
+ {t("activityCalendar.legend")} + {LEGEND_LEVELS.map((level) => ( + + + ))} + {data.baseline[metric] === null ? ( + + {t("activityCalendar.noBaseline")} + + ) : null} +
+ ) : null} + +
    + {(["steps", "activeMinutes", "workouts", "load"] as const) + .filter((m) => data.available[m]) + .map((m) => { + const s = data.summary[m]; + const delta = + s.value !== null && s.usualToDate + ? (s.value - s.usualToDate) / s.usualToDate + : null; + return ( +
  • +
    +

    + {t(`activityCalendar.summary.${m}`, { + value: + s.value === null ? "–" : formatMetric(m, s.value), + })} +

    +

    + {s.usualToDate === null + ? t("activityCalendar.summary.noUsual") + : t("activityCalendar.summary.usual", { + value: formatMetric(m, s.usualToDate), + })} +

    +
    + {delta !== null ? ( + + {`${delta > 0 ? "+" : ""}${fmt.percent(delta)}`} + + ) : null} +
  • + ); + })} +
+ + {data.sports.length > 0 ? ( +
+ {data.sports.map((s) => ( + + {t("activityCalendar.sportCount", { + sport: sportLabel(s.sportType), + count: fmt.integer(s.count), + })} + + ))} +
+ ) : null} + +
+ {data.goalProgress.map((g) => ( +
+
+ + + + {t("activityCalendar.goalOf", { + value: fmt.integer(Math.round(g.value)), + target: fmt.integer(g.target), + })} + +
+ +
+ ))} + {!capabilities.inSharedRecord ? ( + { + setGoalsOpenCount((n) => n + 1); + setGoalsOpen(true); + }} + > + {data.goalProgress.length > 0 || weeklyGoalsSet + ? t("activityCalendar.editGoals") + : t("activityCalendar.setGoals")} + + ) : null} +
+ + )} +
+ {data && !capabilities.inSharedRecord ? ( + + ) : null} +
+ ); +} diff --git a/src/components/activity/activity-goals-sheet.tsx b/src/components/activity/activity-goals-sheet.tsx new file mode 100644 index 000000000..d5f1082b2 --- /dev/null +++ b/src/components/activity/activity-goals-sheet.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useSaveActivityGoals } from "@/hooks/use-activity-calendar"; +import { + ACTIVITY_GOAL_BOUNDS, + ACTIVITY_GOAL_KEYS, + type ActivityGoalKey, +} from "@/lib/activity/goal-bounds"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +type Goals = Record; +type Draft = Record; + +function toDraft(goals: Goals): Draft { + return Object.fromEntries( + ACTIVITY_GOAL_KEYS.map((k) => [ + k, + goals[k] === null ? "" : String(goals[k]), + ]), + ) as Draft; +} + +/** + * Parse one field. Blank clears the goal; anything else must be a whole + * number inside the bounds the server enforces. Digits only, so a grouping + * separator typed by habit ("10.000") is refused rather than misread. + */ +export function parseGoalField( + key: ActivityGoalKey, + raw: string, +): { ok: true; value: number | null } | { ok: false } { + const trimmed = raw.trim(); + if (trimmed === "") return { ok: true, value: null }; + if (!/^\d+$/.test(trimmed)) return { ok: false }; + const value = Number(trimmed); + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + if (value < min || value > max) return { ok: false }; + return { ok: true, value }; +} + +/** + * Set or clear the activity goals. Every goal is optional; an empty field is + * no goal. There is no suggested number: the goal is the person's own. + * + * The draft starts from `goals` when the sheet mounts; the caller remounts it + * on every open (a `key`), so a reopened sheet never shows a stale draft. + */ +export function ActivityGoalsSheet({ + open, + onOpenChange, + goals, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + goals: Goals; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const save = useSaveActivityGoals(); + const [draft, setDraft] = useState(() => toDraft(goals)); + const [invalid, setInvalid] = useState([]); + + const submit = () => { + const bad: ActivityGoalKey[] = []; + const patch: Partial = {}; + for (const key of ACTIVITY_GOAL_KEYS) { + const parsed = parseGoalField(key, draft[key]); + if (!parsed.ok) bad.push(key); + else patch[key] = parsed.value; + } + setInvalid(bad); + if (bad.length > 0) return; + save.mutate(patch, { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + {t("common.save")} + + } + > +
{ + e.preventDefault(); + submit(); + }} + > + {ACTIVITY_GOAL_KEYS.map((key) => { + const id = `activity-goal-${key}`; + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + const isInvalid = invalid.includes(key); + return ( +
+ + + setDraft((d) => ({ ...d, [key]: e.target.value })) + } + /> + {isInvalid ? ( + + ) : null} +
+ ); + })} + {save.isError ? ( +

+ {t("activityCalendar.goalsSheet.saveError")} +

+ ) : null} +
+
+ ); +} diff --git a/src/hooks/use-activity-calendar.ts b/src/hooks/use-activity-calendar.ts new file mode 100644 index 000000000..c69f843ea --- /dev/null +++ b/src/hooks/use-activity-calendar.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar"; +import type { + ActivityGoals, + ActivityGoalsInput, +} from "@/lib/validations/activity-goals"; + +/** + * `GET /api/activity/calendar` for one period. `date` null = the period that + * contains today. The server computes everything, the client only draws it. + */ +export function useActivityCalendar( + period: "week" | "month", + date: string | null, + opts?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: queryKeys.activityCalendar(period, date), + queryFn: () => { + const params = new URLSearchParams({ period }); + if (date) params.set("date", date); + return apiGet( + `/api/activity/calendar?${params.toString()}`, + ); + }, + enabled: opts?.enabled, + staleTime: 60_000, + }); +} + +/** `PATCH /api/activity/goals`; refreshes every calendar cell on success. */ +export function useSaveActivityGoals() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (patch: ActivityGoalsInput) => + apiPatch("/api/activity/goals", patch), + onSuccess: (goals) => { + queryClient.setQueryData(queryKeys.activityGoals(), goals); + void queryClient.invalidateQueries({ + queryKey: queryKeys.activityCalendarAll(), + }); + }, + }); +} diff --git a/src/lib/activity/__tests__/calendar-math.test.ts b/src/lib/activity/__tests__/calendar-math.test.ts new file mode 100644 index 000000000..816cec6ab --- /dev/null +++ b/src/lib/activity/__tests__/calendar-math.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "vitest"; + +import { shiftDateKey } from "@/lib/tz/format"; +import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals"; + +import { + BASELINE_DAYS, + MIN_BASELINE_DAYS, + activeMinutesOf, + baselineStats, + buildActivityCalendar, + comparePeriod, + dayLevel, + daysInMonth, + goalProgress, + periodBounds, + quantileSorted, + shiftMonth, + weekStartOf, + type ActivityDayInput, +} from "../calendar-math"; + +function day( + date: string, + over: Partial = {}, +): ActivityDayInput { + return { + date, + steps: null, + exerciseMinutes: null, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + ...over, + }; +} + +describe("quantiles", () => { + it("interpolates linearly between ranks (PERCENTILE.INC)", () => { + const s = [1, 2, 3, 4, 5]; + expect(quantileSorted(s, 0)).toBe(1); + expect(quantileSorted(s, 0.5)).toBe(3); + expect(quantileSorted(s, 0.25)).toBe(2); + expect(quantileSorted([10, 20], 0.5)).toBe(15); + expect(quantileSorted([10, 20, 30, 40], 0.9)).toBeCloseTo(37); + }); + + it("refuses a baseline below the day floor", () => { + expect(baselineStats(Array(MIN_BASELINE_DAYS - 1).fill(5000))).toBeNull(); + const stats = baselineStats( + Array.from({ length: 20 }, (_, i) => (i + 1) * 1000), + ); + expect(stats?.days).toBe(20); + expect(stats?.median).toBe(10500); + }); +}); + +describe("day level against the person's own spread", () => { + const stats = baselineStats(Array.from({ length: 100 }, (_, i) => i))!; + it.each([ + [null, "none"], + [5, "low"], + [20, "below"], + [50, "usual"], + [80, "above"], + [95, "high"], + ] as const)("%s → %s", (value, level) => { + expect(dayLevel(value, stats)).toBe(level); + }); + + it("is unrated without a baseline, not guessed", () => { + expect(dayLevel(9000, null)).toBe("unrated"); + }); +}); + +describe("active minutes never double-count a workout", () => { + it("takes the larger of exercise and workout minutes", () => { + expect( + activeMinutesOf( + day("2026-05-01", { exerciseMinutes: 40, workoutMinutes: 30 }), + true, + ), + ).toBe(40); + expect( + activeMinutesOf( + day("2026-05-01", { exerciseMinutes: 10, workoutMinutes: 45 }), + true, + ), + ).toBe(45); + }); + it("is 0 on a quiet day once the account has activity data, otherwise unknown", () => { + expect(activeMinutesOf(day("2026-05-01"), true)).toBe(0); + expect(activeMinutesOf(day("2026-05-01"), false)).toBeNull(); + }); +}); + +describe("period bounds", () => { + it("weeks run Monday to Sunday", () => { + // 2026-10-21 is a Wednesday. + expect(weekStartOf("2026-10-21")).toBe("2026-10-19"); + expect(weekStartOf("2026-10-19")).toBe("2026-10-19"); + expect(weekStartOf("2026-10-25")).toBe("2026-10-19"); + const b = periodBounds("week", "2026-10-21"); + expect([b.start, b.end]).toEqual(["2026-10-19", "2026-10-25"]); + expect(b.readStart).toBe(shiftDateKey("2026-10-19", -BASELINE_DAYS)); + }); + + it("a month grid covers whole weeks and reads six months back", () => { + const b = periodBounds("month", "2026-02-14"); + expect([b.start, b.end]).toEqual(["2026-02-01", "2026-02-28"]); + expect(b.gridStart).toBe("2026-01-26"); + expect(b.gridEnd).toBe("2026-03-01"); + expect(b.readStart).toBe("2025-08-01"); + }); + + it("month arithmetic handles length and year turns", () => { + expect(daysInMonth("2024-02-01")).toBe(29); + expect(daysInMonth("2026-02-01")).toBe(28); + expect(shiftMonth("2026-01-01", -1)).toBe("2025-12-01"); + expect(shiftMonth("2026-12-01", 1)).toBe("2027-01-01"); + }); +}); + +describe("period against the usual period", () => { + it("compares a finished week with the median of the twelve before", () => { + const values = new Map(); + // Twelve earlier weeks at 7 000 a day, this week at 10 000 a day. + const bounds = periodBounds("week", "2026-10-05"); + for (let i = 1; i <= 84; i++) + values.set(shiftDateKey(bounds.start, -i), 7000); + for (let i = 0; i < 7; i++) + values.set(shiftDateKey(bounds.start, i), 10000); + const result = comparePeriod({ + bounds, + today: "2026-10-21", + valueOf: (d) => values.get(d) ?? null, + mode: "sum", + }); + expect(result.value).toBe(70000); + expect(result.usual).toBe(49000); + expect(result.usualToDate).toBe(49000); + expect(result.referencePeriods).toBe(12); + }); + + it("pro-rates the usual for a period still running", () => { + const bounds = periodBounds("week", "2026-10-21"); + const values = new Map(); + for (let i = 1; i <= 84; i++) + values.set(shiftDateKey(bounds.start, -i), 1000); + const result = comparePeriod({ + bounds, + today: "2026-10-21", // Wednesday: three days elapsed + valueOf: (d) => + values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null), + mode: "sum", + }); + expect(result.value).toBe(6000); + expect(result.usual).toBe(7000); + expect(result.usualToDate).toBe(3000); + }); + + it("compares months per day, so February meets March fairly", () => { + const bounds = periodBounds("month", "2026-02-10"); + const result = comparePeriod({ + bounds, + today: "2026-10-21", + valueOf: (d) => (d < "2026-03-01" ? 100 : null), + mode: "sum", + }); + expect(result.value).toBe(2800); + // Six earlier months at 100 a day → 28 days × 100. + expect(result.usual).toBe(2800); + }); + + it("gives no usual with fewer than three earlier periods of data", () => { + const bounds = periodBounds("week", "2026-10-05"); + const result = comparePeriod({ + bounds, + today: "2026-10-21", + valueOf: (d) => (d >= shiftDateKey(bounds.start, -14) ? 500 : null), + mode: "sum", + }); + expect(result.referencePeriods).toBe(2); + expect(result.usual).toBeNull(); + }); +}); + +describe("goals", () => { + it("reports only goals that are set, for the period they belong to", () => { + const goals = { + ...NO_ACTIVITY_GOALS, + workoutsPerWeek: 3, + stepsPerMonth: 200000, + }; + expect(goalProgress("week", goals, { workouts: 4 })).toEqual([ + { key: "workoutsPerWeek", target: 3, value: 4, met: true }, + ]); + expect(goalProgress("month", goals, { steps: 150000 })).toEqual([ + { key: "stepsPerMonth", target: 200000, value: 150000, met: false }, + ]); + expect(goalProgress("week", NO_ACTIVITY_GOALS, { workouts: 9 })).toEqual( + [], + ); + }); +}); + +describe("the assembled calendar", () => { + const today = "2026-10-21"; + const bounds = periodBounds("month", today); + // 84 baseline days of 5 000–9 000 steps, a run every Monday. + const input: ActivityDayInput[] = []; + for (let i = 1; i <= 200; i++) { + const date = shiftDateKey(bounds.start, -i); + input.push( + day(date, { + steps: 5000 + (i % 5) * 1000, + workoutCount: i % 7 === 0 ? 1 : 0, + workoutMinutes: i % 7 === 0 ? 30 : 0, + workoutsBySport: i % 7 === 0 ? { running: 1 } : {}, + }), + ); + } + input.push( + day("2026-10-02", { + steps: 15000, + workoutCount: 2, + workoutMinutes: 90, + workoutsBySport: { running: 1, cycling: 1 }, + }), + ); + input.push(day("2026-10-03", { steps: 2000 })); + const calendar = buildActivityCalendar({ + bounds, + today, + input, + goals: { ...NO_ACTIVITY_GOALS, workoutsPerWeek: 2 }, + }); + + it("draws whole weeks and marks the days after today as future", () => { + expect(calendar.days[0].date).toBe(calendar.gridStart); + expect(calendar.days.length % 7).toBe(0); + const future = calendar.days.find((d) => d.date === "2026-10-22"); + expect(future?.future).toBe(true); + expect(future?.level.steps).toBe("none"); + }); + + it("rates a big day high and a short one low against the person's own spread", () => { + expect(calendar.baseline.steps?.days).toBe(BASELINE_DAYS); + expect( + calendar.days.find((d) => d.date === "2026-10-02")?.level.steps, + ).toBe("high"); + expect( + calendar.days.find((d) => d.date === "2026-10-03")?.level.steps, + ).toBe("low"); + expect( + calendar.days.find((d) => d.date === "2026-10-04")?.level.steps, + ).toBe("none"); + }); + + it("counts workouts by sport in the period", () => { + expect(calendar.sports).toEqual([ + { sportType: "cycling", count: 1 }, + { sportType: "running", count: 1 }, + ]); + expect(calendar.summary.workouts.value).toBe(2); + }); + + it("marks weekly goals per calendar row, and none for a week not yet begun", () => { + const week = calendar.weeks.find((w) => w.start === "2026-09-28"); + expect(week?.goals).toEqual([ + { key: "workoutsPerWeek", target: 2, value: 2, met: true }, + ]); + const last = calendar.weeks[calendar.weeks.length - 1]; + expect(last.start > today).toBe(true); + expect(last.goals).toEqual([]); + expect(calendar.next).toBeNull(); + expect(calendar.previous).toBe("2026-09-01"); + }); + + it("offers only the signals the account has", () => { + expect(calendar.available).toEqual({ + steps: true, + activeMinutes: true, + workouts: true, + load: false, + }); + }); +}); diff --git a/src/lib/activity/calendar-math.ts b/src/lib/activity/calendar-math.ts new file mode 100644 index 000000000..6f4a5e225 --- /dev/null +++ b/src/lib/activity/calendar-math.ts @@ -0,0 +1,609 @@ +/** + * Activity calendar arithmetic (D1036). Pure: no database, no clock, no zone. + * + * The calendar compares a week or a month of activity with the person's own + * normal, never with a population norm. Two comparisons, each with its own + * honest reference: + * + * - A DAY is placed against the spread of the person's own days in the 84 + * days before the period starts (`BASELINE_DAYS`): below the 10th + * percentile is `low`, below the 25th `below`, above the 75th `above`, + * above the 90th `high`, everything between `usual`. With fewer than + * `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`: + * there is not enough history to say what usual is, and the calendar says + * so instead of guessing. + * - A PERIOD total is placed against the median total of the same kind of + * period before it: the twelve weeks before a week, the six months before + * a month. A month is compared per day (months differ in length), so the + * usual month is the median daily rate of the earlier months times this + * month's length. A period still running is compared with the usual rate + * times the days elapsed so far, not with a full period it cannot have + * reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data + * are needed; fewer gives no usual. + * + * What a day's value is: + * - steps: the day's step total (the same canonical-source sum the steps + * chart draws); a day without a step reading has no value, it is not 0, + * because a watch left on the charger is not a day without steps. + * - active minutes: the larger of the day's exercise minutes and the day's + * workout minutes. Exercise minutes already contain the workouts a watch + * recorded, so adding the two would count a workout twice; taking the + * larger never does, and still counts a workout logged by hand on a day + * the watch recorded nothing. Once the account has any activity data in + * the window, a day without either is 0. + * - workouts: the number of workouts that started that day, after the + * cross-source duplicate collapse the workout list applies. Shown as + * counts, never rated: most days have none, so percentiles of a mostly-zero + * series say nothing. + * - load: the day's training-load score when a device or HealthLog computes + * one; no value otherwise. + */ +import { + daysBetweenDateKeys, + shiftDateKey, + weekdayOfDateKey, +} from "@/lib/tz/format"; + +import type { + ActivityGoalKey, + ActivityGoals, +} from "@/lib/validations/activity-goals"; + +/** Days before the period whose spread defines a usual day. */ +export const BASELINE_DAYS = 84; +/** Days with a value needed before a day is rated against the baseline. */ +export const MIN_BASELINE_DAYS = 14; +/** Earlier periods compared against: twelve weeks, six months. */ +export const REFERENCE_WEEKS = 12; +export const REFERENCE_MONTHS = 6; +/** Earlier periods with data needed before a usual period total is given. */ +export const MIN_REFERENCE_PERIODS = 3; + +export type ActivityPeriod = "week" | "month"; +export type RatedMetric = "steps" | "activeMinutes" | "load"; +export type DayLevel = + "none" | "unrated" | "low" | "below" | "usual" | "above" | "high"; + +/** One local day of raw signals, as the reader assembles it. */ +export interface ActivityDayInput { + /** Local calendar day, `YYYY-MM-DD`. */ + date: string; + steps: number | null; + exerciseMinutes: number | null; + workoutMinutes: number; + workoutCount: number; + workoutsBySport: Record; + load: number | null; +} + +export interface BaselineStats { + median: number; + p10: number; + p25: number; + p75: number; + p90: number; + /** Days with a value the stats were computed from. */ + days: number; +} + +/** + * Linear-interpolation quantile (the R-7 / spreadsheet `PERCENTILE.INC` + * definition) over an ascending-sorted array. `q` in [0, 1]. + */ +export function quantileSorted(sorted: readonly number[], q: number): number { + if (sorted.length === 0) return Number.NaN; + if (sorted.length === 1) return sorted[0]; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo); +} + +export function median(values: readonly number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + return quantileSorted(sorted, 0.5); +} + +export function baselineStats(values: readonly number[]): BaselineStats | null { + if (values.length < MIN_BASELINE_DAYS) return null; + const sorted = [...values].sort((a, b) => a - b); + return { + median: quantileSorted(sorted, 0.5), + p10: quantileSorted(sorted, 0.1), + p25: quantileSorted(sorted, 0.25), + p75: quantileSorted(sorted, 0.75), + p90: quantileSorted(sorted, 0.9), + days: sorted.length, + }; +} + +/** Where one day's value sits in the person's own spread. */ +export function dayLevel( + value: number | null, + baseline: BaselineStats | null, +): DayLevel { + if (value === null) return "none"; + if (!baseline) return "unrated"; + if (value < baseline.p10) return "low"; + if (value < baseline.p25) return "below"; + if (value > baseline.p90) return "high"; + if (value > baseline.p75) return "above"; + return "usual"; +} + +/** The active-minutes rule from the header: the larger, never the sum. */ +export function activeMinutesOf( + day: ActivityDayInput, + hasActivityData: boolean, +): number | null { + if (day.exerciseMinutes !== null) { + return Math.max(day.exerciseMinutes, day.workoutMinutes); + } + if (day.workoutMinutes > 0) return day.workoutMinutes; + return hasActivityData ? 0 : null; +} + +export function metricValue( + day: ActivityDayInput, + metric: RatedMetric, + hasActivityData: boolean, +): number | null { + switch (metric) { + case "steps": + return day.steps; + case "activeMinutes": + return activeMinutesOf(day, hasActivityData); + case "load": + return day.load; + } +} + +/** Monday on or before `date` (the calendar's weeks run Monday to Sunday). */ +export function weekStartOf(date: string): string { + const weekday = weekdayOfDateKey(date); // 0 = Sunday + return shiftDateKey(date, -((weekday + 6) % 7)); +} + +export function monthStartOf(date: string): string { + return `${date.slice(0, 7)}-01`; +} + +export function daysInMonth(monthStart: string): number { + const [y, m] = monthStart.split("-").map(Number); + return new Date(Date.UTC(y, m, 0)).getUTCDate(); +} + +/** First day of the month `delta` months from `monthStart`. */ +export function shiftMonth(monthStart: string, delta: number): string { + const [y, m] = monthStart.split("-").map(Number); + const index = y * 12 + (m - 1) + delta; + const year = Math.floor(index / 12); + const month = (index % 12) + 1; + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`; +} + +export interface PeriodBounds { + period: ActivityPeriod; + /** First and last day of the period, inclusive. */ + start: string; + end: string; + /** The grid the calendar draws: whole weeks around the period. */ + gridStart: string; + gridEnd: string; + /** The earliest day any comparison reads. */ + readStart: string; +} + +export function periodBounds( + period: ActivityPeriod, + anchor: string, +): PeriodBounds { + if (period === "week") { + const start = weekStartOf(anchor); + const end = shiftDateKey(start, 6); + return { + period, + start, + end, + gridStart: start, + gridEnd: end, + readStart: shiftDateKey( + start, + -Math.max(BASELINE_DAYS, REFERENCE_WEEKS * 7), + ), + }; + } + const start = monthStartOf(anchor); + const end = shiftDateKey(start, daysInMonth(start) - 1); + const gridStart = weekStartOf(start); + const gridEnd = shiftDateKey(weekStartOf(end), 6); + // Date keys order lexically, so the earliest is the smallest string. + const readStart = [ + shiftMonth(start, -REFERENCE_MONTHS), + shiftDateKey(start, -BASELINE_DAYS), + gridStart, + ].sort()[0]; + return { period, start, end, gridStart, gridEnd, readStart }; +} + +/** Every day key from `from` to `to`, inclusive. */ +export function dayRange(from: string, to: string): string[] { + const n = daysBetweenDateKeys(from, to); + const out: string[] = []; + for (let i = 0; i <= n; i++) out.push(shiftDateKey(from, i)); + return out; +} + +export interface PeriodComparison { + /** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */ + value: number | null; + /** Days of the period that carry a value. */ + daysWithData: number; + /** The usual full period, or null when there is not enough history. */ + usual: number | null; + /** The usual figure for the days elapsed so far (equals `usual` for a finished period). */ + usualToDate: number | null; + /** Earlier periods the usual was computed from. */ + referencePeriods: number; +} + +export type SummaryMetric = RatedMetric | "workouts"; + +function aggregate( + values: ReadonlyArray, + mode: "sum" | "mean", +): { value: number | null; n: number } { + const present = values.filter((v): v is number => v !== null); + if (present.length === 0) return { value: null, n: 0 }; + const total = present.reduce((a, b) => a + b, 0); + return { + value: mode === "sum" ? total : total / present.length, + n: present.length, + }; +} + +/** The earlier periods a period is compared with, as inclusive day ranges. */ +export function referencePeriods( + bounds: Pick, +): Array<{ start: string; end: string; length: number }> { + const out: Array<{ start: string; end: string; length: number }> = []; + if (bounds.period === "week") { + for (let i = 1; i <= REFERENCE_WEEKS; i++) { + const start = shiftDateKey(bounds.start, -7 * i); + out.push({ start, end: shiftDateKey(start, 6), length: 7 }); + } + return out; + } + for (let i = 1; i <= REFERENCE_MONTHS; i++) { + const start = shiftMonth(bounds.start, -i); + const length = daysInMonth(start); + out.push({ start, end: shiftDateKey(start, length - 1), length }); + } + return out; +} + +export function comparePeriod(input: { + bounds: Pick; + /** The last day that has happened (today in the person's zone). */ + today: string; + valueOf: (date: string) => number | null; + mode: "sum" | "mean"; +}): PeriodComparison { + const { bounds, today, valueOf, mode } = input; + const lastCounted = bounds.end < today ? bounds.end : today; + const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1; + const elapsed = + lastCounted < bounds.start + ? 0 + : daysBetweenDateKeys(bounds.start, lastCounted) + 1; + const current = + elapsed === 0 + ? { value: null, n: 0 } + : aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode); + + // Each earlier period becomes a per-day rate (sum) or a mean (mean), so a + // 28-day February and a 31-day March compare on the same footing. + const rates: number[] = []; + for (const ref of referencePeriods(bounds)) { + const agg = aggregate(dayRange(ref.start, ref.end).map(valueOf), mode); + if (agg.value === null) continue; + rates.push(mode === "sum" ? agg.value / ref.length : agg.value); + } + const usualRate = + rates.length >= MIN_REFERENCE_PERIODS ? median(rates) : null; + const usual = + usualRate === null ? null : mode === "sum" ? usualRate * length : usualRate; + const usualToDate = + usualRate === null + ? null + : mode === "sum" + ? usualRate * elapsed + : usualRate; + return { + value: current.value, + daysWithData: current.n, + usual, + usualToDate: elapsed === 0 ? null : usualToDate, + referencePeriods: rates.length, + }; +} + +export interface GoalProgress { + key: ActivityGoalKey; + target: number; + value: number; + met: boolean; +} + +/** Which goals apply to which period, and which figure each one reads. */ +const GOALS_BY_PERIOD: Record< + ActivityPeriod, + Array<{ key: ActivityGoalKey; metric: SummaryMetric }> +> = { + week: [ + { key: "workoutsPerWeek", metric: "workouts" }, + { key: "activeMinutesPerWeek", metric: "activeMinutes" }, + { key: "stepsPerWeek", metric: "steps" }, + ], + month: [{ key: "stepsPerMonth", metric: "steps" }], +}; + +export function goalProgress( + period: ActivityPeriod, + goals: ActivityGoals, + totals: Partial>, +): GoalProgress[] { + const out: GoalProgress[] = []; + for (const { key, metric } of GOALS_BY_PERIOD[period]) { + const target = goals[key]; + if (target === null) continue; + const value = totals[metric] ?? 0; + out.push({ key, target, value, met: value >= target }); + } + return out; +} + +export interface CalendarDay { + date: string; + inPeriod: boolean; + future: boolean; + steps: number | null; + activeMinutes: number | null; + workoutCount: number; + workoutsBySport: Record; + load: number | null; + level: Record; +} + +export interface CalendarWeekRow { + start: string; + end: string; + steps: number | null; + activeMinutes: number | null; + workouts: number; + /** The weekly goals, met or not, for a week that has started. */ + goals: GoalProgress[]; +} + +export interface ActivityCalendar { + period: ActivityPeriod; + start: string; + end: string; + gridStart: string; + gridEnd: string; + today: string; + days: CalendarDay[]; + weeks: CalendarWeekRow[]; + baseline: Record; + summary: Record; + sports: Array<{ sportType: string; count: number }>; + goals: ActivityGoals; + goalProgress: GoalProgress[]; + available: Record; + previous: string; + next: string | null; +} + +/** + * Assemble the calendar from per-day inputs covering at least + * `bounds.readStart`..`bounds.gridEnd`. Days missing from `input` count as + * days without any reading. + */ +export function buildActivityCalendar(args: { + bounds: PeriodBounds; + today: string; + input: readonly ActivityDayInput[]; + goals: ActivityGoals; +}): ActivityCalendar { + const { bounds, today, input, goals } = args; + const byDate = new Map(input.map((d) => [d.date, d])); + const empty = (date: string): ActivityDayInput => ({ + date, + steps: null, + exerciseMinutes: null, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + }); + const dayOf = (date: string) => byDate.get(date) ?? empty(date); + + // The first day with any activity signal: from there on, a day without + // workouts or exercise minutes is a day of 0 active minutes, not a gap. + const firstActivityDay = + input + .filter( + (d) => + d.exerciseMinutes !== null || + d.workoutCount > 0 || + d.workoutMinutes > 0, + ) + .map((d) => d.date) + .sort()[0] ?? null; + const hasActivityOn = (date: string) => + firstActivityDay !== null && date >= firstActivityDay && date <= today; + + const valueOf = (metric: SummaryMetric) => (date: string) => { + if (date > today) return null; + const day = dayOf(date); + if (metric === "workouts") + return hasActivityOn(date) ? day.workoutCount : null; + return metricValue(day, metric, hasActivityOn(date)); + }; + + const baselineFrom = shiftDateKey(bounds.start, -BASELINE_DAYS); + const baselineTo = shiftDateKey(bounds.start, -1); + const baselineDays = dayRange(baselineFrom, baselineTo); + const ratedMetrics: RatedMetric[] = ["steps", "activeMinutes", "load"]; + const baseline = Object.fromEntries( + ratedMetrics.map((metric) => [ + metric, + baselineStats( + baselineDays + .map(valueOf(metric)) + .filter((v): v is number => v !== null), + ), + ]), + ) as Record; + + const days: CalendarDay[] = dayRange(bounds.gridStart, bounds.gridEnd).map( + (date) => { + const day = dayOf(date); + const future = date > today; + const steps = future ? null : day.steps; + const activeMinutes = future + ? null + : activeMinutesOf(day, hasActivityOn(date)); + const load = future ? null : day.load; + return { + date, + inPeriod: date >= bounds.start && date <= bounds.end, + future, + steps, + activeMinutes, + workoutCount: future ? 0 : day.workoutCount, + workoutsBySport: future ? {} : day.workoutsBySport, + load, + level: { + steps: dayLevel(steps, baseline.steps), + activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes), + load: dayLevel(load, baseline.load), + }, + }; + }, + ); + + const summary: Record = { + steps: comparePeriod({ + bounds, + today, + valueOf: valueOf("steps"), + mode: "sum", + }), + activeMinutes: comparePeriod({ + bounds, + today, + valueOf: valueOf("activeMinutes"), + mode: "sum", + }), + workouts: comparePeriod({ + bounds, + today, + valueOf: valueOf("workouts"), + mode: "sum", + }), + load: comparePeriod({ + bounds, + today, + valueOf: valueOf("load"), + mode: "mean", + }), + }; + + const sportCounts = new Map(); + for (const date of dayRange(bounds.start, bounds.end)) { + if (date > today) break; + for (const [sport, count] of Object.entries(dayOf(date).workoutsBySport)) { + sportCounts.set(sport, (sportCounts.get(sport) ?? 0) + count); + } + } + const sports = [...sportCounts] + .map(([sportType, count]) => ({ sportType, count })) + .sort( + (a, b) => b.count - a.count || a.sportType.localeCompare(b.sportType), + ); + + const weeks: CalendarWeekRow[] = []; + for ( + let start = bounds.gridStart; + start <= bounds.gridEnd; + start = shiftDateKey(start, 7) + ) { + const end = shiftDateKey(start, 6); + const range = dayRange(start, end); + const sum = (metric: SummaryMetric) => + aggregate(range.map(valueOf(metric)), "sum").value; + const row = { + start, + end, + steps: sum("steps"), + activeMinutes: sum("activeMinutes"), + workouts: sum("workouts") ?? 0, + }; + weeks.push({ + ...row, + goals: + start > today + ? [] + : goalProgress("week", goals, { + steps: row.steps, + activeMinutes: row.activeMinutes, + workouts: row.workouts, + }), + }); + } + + const progress = goalProgress(bounds.period, goals, { + steps: summary.steps.value, + activeMinutes: summary.activeMinutes.value, + workouts: summary.workouts.value, + }); + + const anyIn = (pick: (d: ActivityDayInput) => boolean) => input.some(pick); + const available: Record = { + steps: anyIn((d) => d.steps !== null), + activeMinutes: firstActivityDay !== null, + workouts: anyIn((d) => d.workoutCount > 0), + load: anyIn((d) => d.load !== null), + }; + + const previous = + bounds.period === "week" + ? shiftDateKey(bounds.start, -7) + : shiftMonth(bounds.start, -1); + const nextStart = + bounds.period === "week" + ? shiftDateKey(bounds.start, 7) + : shiftMonth(bounds.start, 1); + + return { + period: bounds.period, + start: bounds.start, + end: bounds.end, + gridStart: bounds.gridStart, + gridEnd: bounds.gridEnd, + today, + days, + weeks, + baseline, + summary, + sports, + goals, + goalProgress: progress, + available, + previous, + next: nextStart > today ? null : nextStart, + }; +} diff --git a/src/lib/activity/calendar-read.ts b/src/lib/activity/calendar-read.ts new file mode 100644 index 000000000..470fc0aa9 --- /dev/null +++ b/src/lib/activity/calendar-read.ts @@ -0,0 +1,181 @@ +/** + * Database side of the activity calendar (D1036): per local day, the step + * total, the exercise minutes, the training load and the workouts, over the + * window `periodBounds` asks for. The arithmetic lives in `calendar-math.ts`. + * + * Steps follow the same rule as the steps chart: each UTC day resolves to the + * source the person's priority ladder picks (`seriesRowsFrom`, the helper the + * series route reads through), and the picked rows are then cut at the + * person's own midnight. A calendar day and the chart's day therefore show + * the same number. Workouts go through the cross-source collapse the workout + * list applies, so a session recorded by two devices counts once. + * + * Bounded: four aggregate queries returning at most one row per day of the + * window (about 220 days for a month), plus the workouts of that window. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +import type { ActivityDayInput } from "./calendar-math"; + +/** + * Training-load types in the order the calendar prefers them: a device's own + * daily figure first (WHOOP day strain, Polar cardio load), HealthLog's + * computed strain score last. The first type with any reading in the window + * is the one shown; two scales are never mixed on one calendar. + */ +export const LOAD_TYPES: readonly MeasurementType[] = [ + "DAY_STRAIN", + "CARDIO_LOAD", + "STRAIN_SCORE", +]; + +const DAY_MS = 86_400_000; + +interface DayAggregateRow { + day: string; + total: number; + /** Readings that day; the load is their plain mean (one score a day, as a rule). */ + n: number; +} + +async function dailyAggregate( + userId: string, + tz: string, + priorityJson: unknown, + type: MeasurementType, + from: Date, + to: Date, + now: Date, +): Promise> { + // `seriesRowsFrom` scopes its canonical pick to a trailing window counted + // from now; it is widened by a day so the edge day's source is picked from + // all of its readings, and the outer filter trims to the exact window. + const days = Math.min( + 3649, + Math.max(1, Math.ceil((now.getTime() - from.getTime()) / DAY_MS) + 1), + ); + const rows = await prisma.$queryRawUnsafe( + ` + SELECT + to_char( + date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $2), + 'YYYY-MM-DD' + ) AS day, + SUM(m."value")::double precision AS total, + COUNT(*)::int AS n + FROM ${seriesRowsFrom(priorityJson, type, days)} + WHERE m."measured_at" >= $3 + AND m."measured_at" < $4 + GROUP BY 1 + `, + userId, + tz, + from, + to, + ); + return new Map(rows.map((r) => [r.day, r])); +} + +function meanOf(row: DayAggregateRow | undefined): number | null { + return row && row.n > 0 ? row.total / row.n : null; +} + +export interface ActivityReadResult { + days: ActivityDayInput[]; + loadType: MeasurementType | null; +} + +/** + * Read `fromKey`..`toKey` (inclusive local days) for one account. + * `today` caps the read: nothing after the person's today is fetched. + */ +export async function readActivityDays(args: { + userId: string; + tz: string; + fromKey: string; + toKey: string; + now?: Date; + includeWorkouts: boolean; +}): Promise { + const { userId, tz, fromKey, toKey, includeWorkouts } = args; + const now = args.now ?? new Date(); + const from = startOfLocalDayKey(fromKey, tz); + const to = startOfLocalDayKey(shiftDateKey(toKey, 1), tz); + const priorityJson = await loadUserSourcePriority(userId); + + const [steps, exercise, loads, workoutRows] = await Promise.all([ + dailyAggregate(userId, tz, priorityJson, "ACTIVITY_STEPS", from, to, now), + dailyAggregate(userId, tz, priorityJson, "EXERCISE_MINUTES", from, to, now), + Promise.all( + LOAD_TYPES.map((type) => + dailyAggregate(userId, tz, priorityJson, type, from, to, now), + ), + ), + includeWorkouts + ? prisma.workout.findMany({ + where: { userId, startedAt: { gte: from, lt: to } }, + select: { + startedAt: true, + durationSec: true, + sportType: true, + source: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + orderBy: { startedAt: "asc" }, + }) + : Promise.resolve([]), + ]); + + const loadIndex = loads.findIndex((m) => m.size > 0); + const load = loadIndex >= 0 ? loads[loadIndex] : null; + + const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson); + const workoutsByDay = new Map< + string, + { minutes: number; count: number; bySport: Record } + >(); + for (const w of workouts) { + const key = userDayKey(w.startedAt, tz); + const slot = workoutsByDay.get(key) ?? { + minutes: 0, + count: 0, + bySport: {}, + }; + slot.minutes += Math.max(0, w.durationSec) / 60; + slot.count += 1; + slot.bySport[w.sportType] = (slot.bySport[w.sportType] ?? 0) + 1; + workoutsByDay.set(key, slot); + } + + const keys = new Set([ + ...steps.keys(), + ...exercise.keys(), + ...(load ? load.keys() : []), + ...workoutsByDay.keys(), + ]); + const days: ActivityDayInput[] = [...keys] + .filter((k) => k >= fromKey && k <= toKey) + .sort() + .map((date) => { + const w = workoutsByDay.get(date); + return { + date, + steps: steps.get(date)?.total ?? null, + exerciseMinutes: exercise.get(date)?.total ?? null, + workoutMinutes: w ? Math.round(w.minutes) : 0, + workoutCount: w?.count ?? 0, + workoutsBySport: w?.bySport ?? {}, + load: meanOf(load?.get(date)), + }; + }); + + return { days, loadType: loadIndex >= 0 ? LOAD_TYPES[loadIndex] : null }; +} diff --git a/src/lib/activity/goal-bounds.ts b/src/lib/activity/goal-bounds.ts new file mode 100644 index 000000000..40b59721a --- /dev/null +++ b/src/lib/activity/goal-bounds.ts @@ -0,0 +1,27 @@ +/** + * The activity-goal keys and their bounds, without a schema library, so the + * goals sheet can read them in the browser. The schema that enforces them + * on the server is `src/lib/validations/activity-goals.ts`. + */ +export const ACTIVITY_GOAL_KEYS = [ + "workoutsPerWeek", + "activeMinutesPerWeek", + "stepsPerWeek", + "stepsPerMonth", +] as const; + +export type ActivityGoalKey = (typeof ACTIVITY_GOAL_KEYS)[number]; + +/** + * Wide on purpose: they refuse typing mistakes (a goal of 5 000 000 steps a + * week), they do not judge what a sensible goal is. + */ +export const ACTIVITY_GOAL_BOUNDS: Record< + ActivityGoalKey, + { min: number; max: number } +> = { + workoutsPerWeek: { min: 1, max: 28 }, + activeMinutesPerWeek: { min: 10, max: 5000 }, + stepsPerWeek: { min: 1000, max: 700_000 }, + stepsPerMonth: { min: 1000, max: 3_000_000 }, +}; diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 54d59b8d9..92cf76056 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -487,6 +487,7 @@ export const USER_RESET = { moodTagLayoutJson: Prisma.DbNull, documentsLayoutJson: Prisma.DbNull, moduleListLayoutJson: Prisma.DbNull, + activityGoalsJson: Prisma.DbNull, reportSelectionJson: Prisma.DbNull, modulePreferencesJson: Prisma.DbNull, healthScoreConfigJson: Prisma.DbNull, diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index d2f2bced6..6a0ad5f02 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -61,6 +61,7 @@ import { import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { locales } from "@/lib/i18n/config"; import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; +import { activityGoalsSchema } from "@/lib/validations/activity-goals"; import { isPublicUrl } from "@/lib/validations/notifications"; import { glucoseUnitPatchSchema, @@ -267,6 +268,37 @@ const thresholds: SettingCodec = { }, }; +/** + * The activity goals, goal by goal. A goal outside its bounds, or a key this + * release does not know, is dropped and named; the valid goals beside it + * still come back. + */ +const activityGoals: SettingCodec = { + write: (row) => ({ activityGoalsJson: row.activityGoalsJson ?? null }), + read: (entry) => { + if (!has(entry, "activityGoalsJson")) return undefined; + const value = entry.activityGoalsJson; + if (value === null) return Prisma.DbNull; + if (typeof value !== "object" || Array.isArray(value)) { + return new Refused("activityGoalsJson"); + } + const kept: Record = {}; + const refused: string[] = []; + for (const [key, goal] of Object.entries(value)) { + const parsed = activityGoalsSchema.safeParse({ [key]: goal }); + if (!parsed.success) { + refused.push(`activityGoalsJson.${key}`); + } else if (typeof goal === "number") { + kept[key] = goal; + } + } + return new PartlyRefused( + Object.keys(kept).length === 0 ? Prisma.DbNull : kept, + refused, + ); + }, +}; + const injectionSites = Object.values(InjectionSite) as string[]; /** The injection sites to avoid everywhere; an unknown site is dropped. */ @@ -505,6 +537,7 @@ const ACCOUNT_SETTING_CODECS: Readonly< documentsLayoutJson: json("documentsLayoutJson"), moduleListLayoutJson: json("moduleListLayoutJson"), previewFeaturesJson: json("previewFeaturesJson"), + activityGoalsJson: activityGoals, reportSelectionJson: json("reportSelectionJson"), globalExcludedInjectionSites: excludedSites, healthKitConfigJson: json("healthKitConfigJson"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 527b43b74..4cfe196cf 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -1015,6 +1015,7 @@ export const USER_COLUMN_BACKUP_CLASS = { documentsLayoutJson: "SETTING", moduleListLayoutJson: "SETTING", previewFeaturesJson: "SETTING", + activityGoalsJson: "SETTING", reportSelectionJson: "SETTING", globalExcludedInjectionSites: "SETTING", healthKitConfigJson: "SETTING", diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index d43ec2437..0aac71fa4 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes"; */ export const ERROR_CODE_CATALOGUE: Readonly> = { + activity: ["activity.calendar.future_date", "activity.calendar.too_old"], ai: [ "ai.budget.exceeded", "ai.provider.none", diff --git a/src/lib/openapi/routes/activity.ts b/src/lib/openapi/routes/activity.ts new file mode 100644 index 000000000..52fa5072e --- /dev/null +++ b/src/lib/openapi/routes/activity.ts @@ -0,0 +1,102 @@ +/** + * OpenAPI route table — the activity calendar and activity goals (D1036). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas + * are the runtime ones from `@/lib/validations/activity-*`. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + activityCalendarQuerySchema, + activityCalendarResponseSchema, + activityGoalsResponseSchema, +} from "@/lib/validations/activity-calendar"; +import { activityGoalsSchema } from "@/lib/validations/activity-goals"; + +import { + dataEnvelope, + MODULE_DISABLED_DESCRIPTION, + recordRefusal, + recordWriteRateLimitResponse, + stdResponses, +} from "./shared"; + +const activityGoalsPatch = activityGoalsSchema.meta({ + id: "ActivityGoalsPatch", + description: + "The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is absent stays as it is. Unknown keys are refused.", +}); + +export const activityPaths: NonNullable = { + "/api/activity/calendar": { + get: { + tags: ["Measurements"], + summary: "Activity calendar against the person's own normal", + description: + "A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. Each day is placed against the person's own spread over the 84 days before the period; the period total against the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts module.", + requestParams: { query: activityCalendarQuerySchema }, + responses: { + "200": { + description: "The calendar.", + content: { + "application/json": { + schema: dataEnvelope( + activityCalendarResponseSchema, + "ActivityCalendarEnvelope", + ), + }, + }, + }, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...stdResponses, + }, + }, + }, + "/api/activity/goals": { + get: { + tags: ["Measurements"], + summary: "Activity goals", + description: + "The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = not set; there is no default. Owner only; a delegate reads the goals inside the calendar.", + responses: { + "200": { + description: "The goals.", + content: { + "application/json": { + schema: dataEnvelope( + activityGoalsResponseSchema, + "ActivityGoalsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Measurements"], + summary: "Set activity goals", + description: + "Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated session cannot change another person's goals.", + requestBody: { + required: true, + content: { "application/json": { schema: activityGoalsPatch } }, + }, + responses: { + "200": { + description: "The goals after the change.", + content: { + "application/json": { + schema: dataEnvelope( + activityGoalsResponseSchema, + "ActivityGoalsPatchEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...recordWriteRateLimitResponse, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 96c0d4375..6cfa8666c 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -27,6 +27,7 @@ import { recordEpochParameter, recordScopeParameter, } from "./account-sharing"; +import { activityPaths } from "./activity"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { aiUsagePaths } from "./ai-usage"; import { dawarichPaths } from "./dawarich"; @@ -105,6 +106,7 @@ export const openApiPaths: NonNullable = { ...syncPaths, ...measurementPaths, ...workoutPaths, + ...activityPaths, ...devicePaths, ...medicationPaths, ...profilePaths, diff --git a/src/lib/query-keys/activity.ts b/src/lib/query-keys/activity.ts new file mode 100644 index 000000000..812841224 --- /dev/null +++ b/src/lib/query-keys/activity.ts @@ -0,0 +1,11 @@ +/** + * Query keys — the activity calendar and activity goals (D1036). + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const activityKeys = { + /** Root of every activity-calendar cell; a goal save invalidates it. */ + activityCalendarAll: () => ["activity-calendar"] as const, + activityCalendar: (period: "week" | "month", date: string | null) => + ["activity-calendar", period, date ?? "today"] as const, + activityGoals: () => ["activity-goals"] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 7fc422cc6..59425c8ab 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -47,6 +47,7 @@ import { searchKeys } from "./search"; import { sharingKeys } from "./sharing"; import { vaccinationKeys } from "./vaccinations"; import { workoutKeys } from "./workouts"; +import { activityKeys } from "./activity"; export const queryKeys = { ...authKeys, @@ -60,6 +61,7 @@ export const queryKeys = { ...adminKeys, ...integrationKeys, ...workoutKeys, + ...activityKeys, ...settingsKeys, ...cycleKeys, ...measurementReminderKeys, diff --git a/src/lib/validations/activity-calendar.ts b/src/lib/validations/activity-calendar.ts new file mode 100644 index 000000000..75ba90c65 --- /dev/null +++ b/src/lib/validations/activity-calendar.ts @@ -0,0 +1,182 @@ +/** + * Query of `GET /api/activity/calendar` (D1036). + */ +import { z } from "zod/v4"; + +export const activityCalendarQuerySchema = z.object({ + period: z + .enum(["week", "month"]) + .default("month") + .describe("Calendar span: one week (Monday to Sunday) or one month."), + date: z.iso + .date() + .optional() + .describe( + "Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after today, or more than ten years back, is refused.", + ), +}); + +export type ActivityCalendarQuery = z.infer; + +const dateKey = z.iso.date(); + +const dayLevelEnum = z + .enum(["none", "unrated", "low", "below", "usual", "above", "high"]) + .describe( + "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.", + ); + +const baselineStatsSchema = z + .object({ + median: z.number(), + p10: z.number(), + p25: z.number(), + p75: z.number(), + p90: z.number(), + days: z + .number() + .int() + .describe("Days with a value the spread was computed from."), + }) + .nullable() + .meta({ id: "ActivityBaselineStats" }); + +const periodComparisonSchema = z + .object({ + value: z + .number() + .nullable() + .describe( + "The period so far: a total (steps, active minutes, workouts) or a mean (load).", + ), + daysWithData: z.number().int(), + usual: z + .number() + .nullable() + .describe( + "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data.", + ), + usualToDate: z + .number() + .nullable() + .describe( + "The usual figure for the days elapsed so far; equals `usual` once the period is over.", + ), + referencePeriods: z.number().int(), + }) + .meta({ id: "ActivityPeriodComparison" }); + +const goalProgressSchema = z + .object({ + key: z.enum([ + "workoutsPerWeek", + "activeMinutesPerWeek", + "stepsPerWeek", + "stepsPerMonth", + ]), + target: z.number().int(), + value: z.number(), + met: z.boolean(), + }) + .meta({ id: "ActivityGoalProgress" }); + +const goalsSchema = z + .object({ + workoutsPerWeek: z.number().int().nullable(), + activeMinutesPerWeek: z.number().int().nullable(), + stepsPerWeek: z.number().int().nullable(), + stepsPerMonth: z.number().int().nullable(), + }) + .meta({ + id: "ActivityGoals", + description: + "The person's own goals; `null` = not set. There is no default goal.", + }); + +export const activityGoalsResponseSchema = goalsSchema; + +export const activityCalendarResponseSchema = z + .object({ + period: z.enum(["week", "month"]), + start: dateKey, + end: dateKey, + gridStart: dateKey.describe( + "First day drawn (the Monday on or before `start`).", + ), + gridEnd: dateKey.describe("Last day drawn (the Sunday on or after `end`)."), + today: dateKey, + timezone: z.string().describe("The IANA zone the days are cut in."), + loadType: z + .enum(["DAY_STRAIN", "CARDIO_LOAD", "STRAIN_SCORE"]) + .nullable() + .describe( + "Which training-load score `load` carries; null when the account has none.", + ), + days: z.array( + z.object({ + date: dateKey, + inPeriod: z.boolean(), + future: z.boolean(), + steps: z.number().nullable(), + activeMinutes: z + .number() + .nullable() + .describe( + "The larger of the day's exercise minutes and workout minutes (never their sum).", + ), + workoutCount: z.number().int(), + workoutsBySport: z.record(z.string(), z.number().int()), + load: z.number().nullable(), + level: z.object({ + steps: dayLevelEnum, + activeMinutes: dayLevelEnum, + load: dayLevelEnum, + }), + }), + ), + weeks: z.array( + z.object({ + start: dateKey, + end: dateKey, + steps: z.number().nullable(), + activeMinutes: z.number().nullable(), + workouts: z.number().int(), + goals: z + .array(goalProgressSchema) + .describe("The weekly goals for a week that has begun."), + }), + ), + baseline: z.object({ + steps: baselineStatsSchema, + activeMinutes: baselineStatsSchema, + load: baselineStatsSchema, + }), + summary: z.object({ + steps: periodComparisonSchema, + activeMinutes: periodComparisonSchema, + workouts: periodComparisonSchema, + load: periodComparisonSchema, + }), + sports: z.array( + z.object({ sportType: z.string(), count: z.number().int() }), + ), + goals: goalsSchema, + goalProgress: z.array(goalProgressSchema), + available: z.object({ + steps: z.boolean(), + activeMinutes: z.boolean(), + workouts: z.boolean(), + load: z.boolean(), + }), + previous: dateKey.describe("A day in the previous period."), + next: dateKey + .nullable() + .describe( + "A day in the next period, or null when that lies after today.", + ), + }) + .meta({ id: "ActivityCalendar" }); + +export type ActivityCalendarResponse = z.infer< + typeof activityCalendarResponseSchema +>; diff --git a/src/lib/validations/activity-goals.ts b/src/lib/validations/activity-goals.ts new file mode 100644 index 000000000..3b34ff80a --- /dev/null +++ b/src/lib/validations/activity-goals.ts @@ -0,0 +1,98 @@ +/** + * Activity goals a person sets for themselves (D1036). + * + * Stored in `User.activityGoalsJson`. Every goal is optional and a `null` + * clears it; an account that never set one stores nothing and reads as "no + * goals". The calendar reports progress against the goals that are set and + * says nothing about the ones that are not: there is no default goal and no + * population norm here, only the person's own number. + * + * The keys and bounds live in `src/lib/activity/goal-bounds.ts` so the + * browser can read them without the schema. + */ +import { z } from "zod/v4"; + +import { + ACTIVITY_GOAL_BOUNDS, + ACTIVITY_GOAL_KEYS, + type ActivityGoalKey, +} from "@/lib/activity/goal-bounds"; + +export { ACTIVITY_GOAL_BOUNDS, ACTIVITY_GOAL_KEYS, type ActivityGoalKey }; + +function goalField(key: ActivityGoalKey) { + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + return z.number().int().min(min).max(max).nullable().optional(); +} + +/** The stored shape and the PATCH body: every key optional, `null` clears. */ +export const activityGoalsSchema = z + .object({ + workoutsPerWeek: goalField("workoutsPerWeek").describe( + "Workouts per week (Monday to Sunday). `null` clears the goal.", + ), + activeMinutesPerWeek: goalField("activeMinutesPerWeek").describe( + "Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed over the week. `null` clears the goal.", + ), + stepsPerWeek: goalField("stepsPerWeek").describe( + "Steps per week. `null` clears the goal.", + ), + stepsPerMonth: goalField("stepsPerMonth").describe( + "Steps per calendar month. `null` clears the goal.", + ), + }) + .strict(); + +export type ActivityGoalsInput = z.infer; + +/** The resolved goals: every key present, `null` = not set. */ +export type ActivityGoals = Record; + +export const NO_ACTIVITY_GOALS: ActivityGoals = { + workoutsPerWeek: null, + activeMinutesPerWeek: null, + stepsPerWeek: null, + stepsPerMonth: null, +}; + +/** + * Read a stored value. Anything that does not parse (a hand-edited row, a + * shape from a future release) reads key by key: a valid goal survives, an + * invalid one reads as not set. Never throws. + */ +export function parseActivityGoals(raw: unknown): ActivityGoals { + const out: ActivityGoals = { ...NO_ACTIVITY_GOALS }; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return out; + } + const record = raw as Record; + for (const key of ACTIVITY_GOAL_KEYS) { + const parsed = goalField(key).safeParse(record[key]); + if (parsed.success && typeof parsed.data === "number") { + out[key] = parsed.data; + } + } + return out; +} + +/** + * Apply a validated PATCH to the stored goals: a key the body names replaces + * the stored value (`null` clears it), a key it leaves out stays as it was. + * Built key by key, never by spreading the body. + */ +export function mergeActivityGoals( + current: ActivityGoals, + patch: ActivityGoalsInput, +): ActivityGoals { + const next: ActivityGoals = { ...current }; + for (const key of ACTIVITY_GOAL_KEYS) { + const value = patch[key]; + if (value !== undefined) next[key] = value; + } + return next; +} + +/** True when no goal is set, so the column can go back to null. */ +export function hasNoActivityGoals(goals: ActivityGoals): boolean { + return ACTIVITY_GOAL_KEYS.every((key) => goals[key] === null); +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 257f7c6b7..b08727ca5 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2156,6 +2156,7 @@ const accountSettingsBackupSchema = z documentsLayoutJson: z.unknown().optional(), moduleListLayoutJson: z.unknown().optional(), previewFeaturesJson: z.unknown().optional(), + activityGoalsJson: z.unknown().optional(), reportSelectionJson: z.unknown().optional(), globalExcludedInjectionSites: z.array(z.string()).optional(), healthKitConfigJson: z.unknown().optional(), diff --git a/tests/integration/activity-calendar-read.test.ts b/tests/integration/activity-calendar-read.test.ts new file mode 100644 index 000000000..230281993 --- /dev/null +++ b/tests/integration/activity-calendar-read.test.ts @@ -0,0 +1,172 @@ +/** + * The activity calendar's reader against a real Postgres (D1036). + * + * - steps resolve to the source the person's ladder picks per day, so a day + * two devices counted shows one device's total, never the sum; + * - the day is the person's local day: a reading at 23:30 Berlin time lands + * on that day, not on the UTC day after; + * - a workout two devices recorded counts once, its minutes once; + * - the window is exact: nothing before `fromKey` or after `toKey`. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { readActivityDays } from "@/lib/activity/calendar-read"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const USER = "activity-reader"; +const DAY = 86_400_000; +const NOW = new Date(); + +function daysAgo(n: number, hourUtc = 10): Date { + const d = new Date(NOW.getTime() - n * DAY); + d.setUTCHours(hourUtc, 0, 0, 0); + return d; +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + timezone: TZ, + sourcePriorityJson: { + metricPriority: { steps: ["FITBIT", "APPLE_HEALTH"] }, + }, + }, + }); + invalidateUserTimezone(USER); + + await prisma.measurement.createMany({ + data: [ + // Day 3: both devices; the ladder picks Fitbit's 8 000. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 9500, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3), + }, + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 8000, + unit: "steps", + source: "FITBIT", + measuredAt: daysAgo(3), + }, + // Day 2: Apple only, two slices that add up. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 3000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(2, 8), + }, + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 4000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(2, 15), + }, + // 21:30 UTC is 23:30 in Berlin (summer) or 22:30 (winter): still day 5 locally. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(5, 21), + }, + { + userId: USER, + type: "EXERCISE_MINUTES", + value: 25, + unit: "min", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3, 9), + }, + { + userId: USER, + type: "EXERCISE_MINUTES", + value: 15, + unit: "min", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3, 17), + }, + // Outside the window asked for below. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 777, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(40), + }, + ], + }); + + const start = daysAgo(3, 7); + await prisma.workout.createMany({ + data: [ + { + userId: USER, + sportType: "running", + startedAt: start, + endedAt: new Date(start.getTime() + 40 * 60_000), + durationSec: 2400, + source: "APPLE_HEALTH", + externalId: "hk-1", + }, + { + userId: USER, + sportType: "running", + startedAt: new Date(start.getTime() + 30_000), + endedAt: new Date(start.getTime() + 40 * 60_000), + durationSec: 2370, + source: "FITBIT", + externalId: "fb-1", + }, + ], + }); +}); + +describe("readActivityDays", () => { + it("follows the ladder, the local day and the workout collapse", async () => { + const fromKey = userDayKey(daysAgo(10), TZ); + const toKey = userDayKey(NOW, TZ); + const { days } = await readActivityDays({ + userId: USER, + tz: TZ, + fromKey, + toKey, + now: NOW, + includeWorkouts: true, + }); + const by = new Map(days.map((d) => [d.date, d])); + + const d3 = by.get(userDayKey(daysAgo(3), TZ)); + expect(d3?.steps).toBe(8000); + expect(d3?.exerciseMinutes).toBe(40); + expect(d3?.workoutCount).toBe(1); + expect(d3?.workoutsBySport).toEqual({ running: 1 }); + expect(d3?.workoutMinutes).toBe(40); + + expect(by.get(userDayKey(daysAgo(2), TZ))?.steps).toBe(7000); + expect(by.get(userDayKey(daysAgo(5, 21), TZ))?.steps).toBe(1200); + expect(days.every((d) => d.date >= fromKey && d.date <= toKey)).toBe(true); + expect(days.some((d) => d.steps === 777)).toBe(false); + }); +}); From faa3ef867b563fae728ae7e89f08590c1b42c0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:39:15 +0200 Subject: [PATCH 34/98] feat(insights): correlate intake, alcohol days, training, rings, State of Mind and room climate The discovery matrix gains the families people log but could not test: water, caffeine and up to four varying micronutrients from the nutrient log, alcohol days from the mood tag, workout minutes (one per session) and the day's training impulse, exercise, stand and mindful minutes, State of Mind as an outcome, and the overnight room climate against that night's sleep only. Every channel runs through the same seasonal adjustment, effective-n p-value, Benjamini-Hochberg control and effect-size floor, is read only while its module is on, and drops out below the paired-day floor. State of Mind, mindful minutes and alcohol days are statistics for the person: the Coach, the period narrative and the assessment cards drop every pair that touches them, after the shared scan so the remaining pairs keep the statistics the correlations page shows. A guard walks the import closure of the analysis modules and refuses any path to a provider. --- messages/de.json | 28 +- messages/en.json | 28 +- messages/es.json | 28 +- messages/fr.json | 28 +- messages/it.json | 28 +- messages/ko.json | 28 +- messages/nl.json | 28 +- messages/pl.json | 28 +- .../analysis-depth-no-ai-guard.test.ts | 101 +++++++ src/__tests__/discovery-matrix-guard.test.ts | 4 + .../pulse-day-mean-reader-guard.test.ts | 4 + .../workout-canonical-identity-guard.test.ts | 4 + .../correlations/__tests__/route.test.ts | 4 + src/app/api/insights/correlations/route.ts | 5 + .../mood/mood-discovered-relations.tsx | 23 +- .../coach/__tests__/correlations-read.test.ts | 5 + src/lib/ai/coach/tools/correlations-read.ts | 40 ++- .../correlation-extended-channels.test.ts | 284 ++++++++++++++++++ .../discovery-matrix-modules.test.ts | 98 ++++++ .../metric-correlation-context.test.ts | 5 + .../insights/correlation-channel-series.ts | 184 +++++++++++- src/lib/insights/correlation-discovery.ts | 135 ++++++++- .../insights/correlation-series-builders.ts | 242 +++++++++++++++ .../__tests__/coach-read-locale.test.ts | 5 + src/lib/insights/discovery-matrix.ts | 73 +++++ .../insights/metric-correlation-context.ts | 4 + .../__tests__/period-narrative-db.test.ts | 5 + .../insights/narrative/period-narrative.ts | 6 +- .../__tests__/surface-module-registry.test.ts | 20 ++ src/lib/modules/surface.ts | 21 ++ .../correlation-extended-channels.test.ts | 244 +++++++++++++++ 31 files changed, 1706 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/analysis-depth-no-ai-guard.test.ts create mode 100644 src/lib/insights/__tests__/correlation-extended-channels.test.ts create mode 100644 tests/integration/correlation-extended-channels.test.ts diff --git a/messages/de.json b/messages/de.json index a72dcb006..dd2a6c8e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag", "ENV_PM25": "der Feinstaub (PM2,5)", "ENV_OZONE_8H": "das Ozon", - "ENV_POLLEN_MAX": "der Pollenflug" + "ENV_POLLEN_MAX": "der Pollenflug", + "HRV_RMSSD": "die Herzratenvariabilität (RMSSD)", + "HYDRATION": "die Trinkmenge", + "CAFFEINE": "die Koffeinmenge", + "ALCOHOL": "der Alkoholkonsum", + "WORKOUT_MINUTES": "die Workout-Dauer", + "TRAINING_LOAD": "die Trainingslast", + "EXERCISE_MINUTES": "die Trainingsminuten der Aktivitätsringe", + "STAND_MINUTES": "die Stehminuten", + "MINDFUL_MINUTES": "die Achtsamkeitsminuten", + "STATE_OF_MIND_VALENCE": "die erfasste Gemütslage", + "INDOOR_CO2": "der CO2-Wert im Raum", + "INDOOR_TEMPERATURE": "die Raumtemperatur", + "INDOOR_HUMIDITY": "die Luftfeuchtigkeit im Raum" }, "daily": { "high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.", @@ -2716,6 +2729,12 @@ "high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", "moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", "faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome}. Das ist zu klein, um sich darauf zu stützen, und nie eine Ursache." + }, + "channelNutrient": "die Zufuhr von {nutrient}", + "dailyOvernight": { + "high": "Wenn {behaviour} über Nacht höher liegt, fällt {outcome} in derselben Nacht in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "moderate": "Wenn {behaviour} über Nacht höher liegt, scheint {outcome} in derselben Nacht nach bisheriger Datenlage eher {direction} auszufallen. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "faint": "Wenn {behaviour} über Nacht höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} in derselben Nacht {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache." } }, "emptyTitle": "Noch nicht genug Daten", @@ -4255,7 +4274,12 @@ "outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.", "stat": "{n} gepaarte Tage, r = {r}, q = {q}", "statLabel": "Wie das berechnet wurde", - "explainerLabel": "Wie das berechnet wurde" + "explainerLabel": "Wie das berechnet wurde", + "channel": { + "ALCOHOL": "Alkoholkonsum", + "WORKOUT_MINUTES": "Workout-Dauer", + "TRAINING_LOAD": "Trainingslast" + } }, "inTargetTitle": "Im Zielbereich", "dimensions": { diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..d9a637e04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "intraday pressure swing", "ENV_PM25": "fine particles (PM2.5)", "ENV_OZONE_8H": "ozone", - "ENV_POLLEN_MAX": "pollen" + "ENV_POLLEN_MAX": "pollen", + "HRV_RMSSD": "heart-rate variability (RMSSD)", + "HYDRATION": "water intake", + "CAFFEINE": "caffeine intake", + "ALCOHOL": "alcohol use", + "WORKOUT_MINUTES": "workout minutes", + "TRAINING_LOAD": "training load", + "EXERCISE_MINUTES": "exercise minutes", + "STAND_MINUTES": "standing minutes", + "MINDFUL_MINUTES": "mindful minutes", + "STATE_OF_MIND_VALENCE": "logged state of mind", + "INDOOR_CO2": "indoor CO2", + "INDOOR_TEMPERATURE": "room temperature", + "INDOOR_HUMIDITY": "room humidity" }, "daily": { "high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data. It is a pattern to watch, not a cause.", @@ -2716,6 +2729,12 @@ "high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data. It is a pattern to watch, not a cause.", "moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.", "faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data. It is too small to lean on and never a cause." + }, + "channelNutrient": "{nutrient} intake", + "dailyOvernight": { + "high": "Higher {behaviour} overnight tends to go with {direction} {outcome} that night in your data. It is a pattern to watch, not a cause.", + "moderate": "Higher {behaviour} overnight looks like it goes with {direction} {outcome} that night in your data, on the evidence so far. It is a pattern to watch, not a cause.", + "faint": "Higher {behaviour} overnight shows at most a faint hint of {direction} {outcome} that night in your data. It is too small to lean on and never a cause." } }, "noAnalysisYet": "No assessment yet.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.", "stat": "{n} paired days, r = {r}, q = {q}", "statLabel": "How this was computed", - "explainerLabel": "How this was computed" + "explainerLabel": "How this was computed", + "channel": { + "ALCOHOL": "alcohol use", + "WORKOUT_MINUTES": "workout minutes", + "TRAINING_LOAD": "training load" + } }, "inTargetTitle": "In your range", "dimensions": { diff --git a/messages/es.json b/messages/es.json index 3239be811..cf161392a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "la variación de presión del día", "ENV_PM25": "las partículas finas (PM2,5)", "ENV_OZONE_8H": "el ozono", - "ENV_POLLEN_MAX": "el polen" + "ENV_POLLEN_MAX": "el polen", + "HRV_RMSSD": "la variabilidad de la frecuencia cardiaca (RMSSD)", + "HYDRATION": "la ingesta de agua", + "CAFFEINE": "la ingesta de cafeína", + "ALCOHOL": "el consumo de alcohol", + "WORKOUT_MINUTES": "los minutos de entrenamiento", + "TRAINING_LOAD": "la carga de entrenamiento", + "EXERCISE_MINUTES": "los minutos de ejercicio", + "STAND_MINUTES": "los minutos de pie", + "MINDFUL_MINUTES": "los minutos de atención plena", + "STATE_OF_MIND_VALENCE": "el estado de ánimo registrado", + "INDOOR_CO2": "el CO2 interior", + "INDOOR_TEMPERATURE": "la temperatura interior", + "INDOOR_HUMIDITY": "la humedad interior" }, "daily": { "high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos. Es un patrón que conviene observar, no una causa.", @@ -2716,6 +2729,12 @@ "high": "Más {behaviour} el mismo día y el día anterior tiende a ir con {direction} {outcome} en tus datos: un patrón que conviene observar, no una causa.", "moderate": "Más {behaviour} el mismo día y el día anterior parece ir con {direction} {outcome} en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.", "faint": "Más {behaviour} el mismo día y el día anterior muestra, como mucho, un leve indicio de {direction} {outcome} en tus datos: demasiado pequeño para fiarse, nunca una causa." + }, + "channelNutrient": "la ingesta de {nutrient}", + "dailyOvernight": { + "high": "Más {behaviour} durante la noche tiende a ir con {direction} {outcome} esa misma noche en tus datos: un patrón que conviene observar, no una causa.", + "moderate": "Más {behaviour} durante la noche parece ir con {direction} {outcome} esa misma noche en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.", + "faint": "Más {behaviour} durante la noche muestra, como mucho, un leve indicio de {direction} {outcome} esa misma noche en tus datos: demasiado pequeño para fiarse, nunca una causa." } }, "noAnalysisYet": "Aún no hay valoración.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.", "stat": "{n} días emparejados, r = {r}, q = {q}", "statLabel": "Cómo se calculó esto", - "explainerLabel": "Cómo se calculó esto" + "explainerLabel": "Cómo se calculó esto", + "channel": { + "ALCOHOL": "consumo de alcohol", + "WORKOUT_MINUTES": "minutos de entrenamiento", + "TRAINING_LOAD": "carga de entrenamiento" + } }, "inTargetTitle": "En tu rango", "dimensions": { diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..1edb3089b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "la variation de pression sur la journée", "ENV_PM25": "les particules fines (PM2,5)", "ENV_OZONE_8H": "l’ozone", - "ENV_POLLEN_MAX": "le pollen" + "ENV_POLLEN_MAX": "le pollen", + "HRV_RMSSD": "la variabilité de la fréquence cardiaque (RMSSD)", + "HYDRATION": "l'apport en eau", + "CAFFEINE": "l'apport en caféine", + "ALCOHOL": "la consommation d'alcool", + "WORKOUT_MINUTES": "les minutes d'entraînement", + "TRAINING_LOAD": "la charge d'entraînement", + "EXERCISE_MINUTES": "les minutes d'exercice", + "STAND_MINUTES": "les minutes debout", + "MINDFUL_MINUTES": "les minutes de pleine conscience", + "STATE_OF_MIND_VALENCE": "l'état d'esprit noté", + "INDOOR_CO2": "le CO2 intérieur", + "INDOOR_TEMPERATURE": "la température intérieure", + "INDOOR_HUMIDITY": "l'humidité intérieure" }, "daily": { "high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données. C'est une tendance à surveiller, pas une cause.", @@ -2716,6 +2729,12 @@ "high": "Plus de {behaviour} le jour même et la veille va souvent de pair avec {direction} {outcome} dans tes données : un schéma à surveiller, pas une cause.", "moderate": "Plus de {behaviour} le jour même et la veille semble aller de pair avec {direction} {outcome} dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.", "faint": "Plus de {behaviour} le jour même et la veille ne montre, au mieux, qu’un faible indice de {direction} {outcome} dans tes données : trop peu pour s’y fier, jamais une cause." + }, + "channelNutrient": "l'apport en {nutrient}", + "dailyOvernight": { + "high": "Plus de {behaviour} pendant la nuit va souvent de pair avec {direction} {outcome} cette nuit-là dans tes données : un schéma à surveiller, pas une cause.", + "moderate": "Plus de {behaviour} pendant la nuit semble aller de pair avec {direction} {outcome} cette nuit-là dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.", + "faint": "Plus de {behaviour} pendant la nuit ne montre, au mieux, qu’un faible indice de {direction} {outcome} cette nuit-là dans tes données : trop peu pour s’y fier, jamais une cause." } }, "noAnalysisYet": "Pas encore d’évaluation.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.", "stat": "{n} jours appariés, r = {r}, q = {q}", "statLabel": "Comment ceci est calculé", - "explainerLabel": "Comment ceci est calculé" + "explainerLabel": "Comment ceci est calculé", + "channel": { + "ALCOHOL": "consommation d'alcool", + "WORKOUT_MINUTES": "minutes d'entraînement", + "TRAINING_LOAD": "charge d'entraînement" + } }, "inTargetTitle": "Dans votre plage", "dimensions": { diff --git a/messages/it.json b/messages/it.json index 85e890708..52ef2d137 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata", "ENV_PM25": "il particolato fine (PM2,5)", "ENV_OZONE_8H": "l’ozono", - "ENV_POLLEN_MAX": "i pollini" + "ENV_POLLEN_MAX": "i pollini", + "HRV_RMSSD": "la variabilità della frequenza cardiaca (RMSSD)", + "HYDRATION": "l'assunzione di acqua", + "CAFFEINE": "l'assunzione di caffeina", + "ALCOHOL": "il consumo di alcol", + "WORKOUT_MINUTES": "i minuti di allenamento", + "TRAINING_LOAD": "il carico di allenamento", + "EXERCISE_MINUTES": "i minuti di esercizio", + "STAND_MINUTES": "i minuti in piedi", + "MINDFUL_MINUTES": "i minuti di consapevolezza", + "STATE_OF_MIND_VALENCE": "lo stato d'animo registrato", + "INDOOR_CO2": "la CO2 interna", + "INDOOR_TEMPERATURE": "la temperatura interna", + "INDOOR_HUMIDITY": "l'umidità interna" }, "daily": { "high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati. È un andamento da osservare, non una causa.", @@ -2716,6 +2729,12 @@ "high": "Più {behaviour} nello stesso giorno e in quello prima tende ad accompagnarsi a {direction} {outcome} nei tuoi dati: uno schema da osservare, non una causa.", "moderate": "Più {behaviour} nello stesso giorno e in quello prima sembra accompagnarsi a {direction} {outcome} nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.", "faint": "Più {behaviour} nello stesso giorno e in quello prima mostra al massimo un debole indizio di {direction} {outcome} nei tuoi dati: troppo poco per contarci, mai una causa." + }, + "channelNutrient": "l'assunzione di {nutrient}", + "dailyOvernight": { + "high": "Più {behaviour} durante la notte tende ad accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati: uno schema da osservare, non una causa.", + "moderate": "Più {behaviour} durante la notte sembra accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.", + "faint": "Più {behaviour} durante la notte mostra al massimo un debole indizio di {direction} {outcome} quella stessa notte nei tuoi dati: troppo poco per contarci, mai una causa." } }, "noAnalysisYet": "Ancora nessuna valutazione.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.", "stat": "{n} giorni appaiati, r = {r}, q = {q}", "statLabel": "Come è stato calcolato", - "explainerLabel": "Come è stato calcolato" + "explainerLabel": "Come è stato calcolato", + "channel": { + "ALCOHOL": "consumo di alcol", + "WORKOUT_MINUTES": "minuti di allenamento", + "TRAINING_LOAD": "carico di allenamento" + } }, "inTargetTitle": "Nel tuo intervallo", "dimensions": { diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..26c14f484 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "하루 중 기압 변화", "ENV_PM25": "초미세먼지(PM2.5)", "ENV_OZONE_8H": "오존", - "ENV_POLLEN_MAX": "꽃가루" + "ENV_POLLEN_MAX": "꽃가루", + "HRV_RMSSD": "심박 변이도(RMSSD)", + "HYDRATION": "수분 섭취량", + "CAFFEINE": "카페인 섭취량", + "ALCOHOL": "음주", + "WORKOUT_MINUTES": "운동 시간", + "TRAINING_LOAD": "훈련 부하", + "EXERCISE_MINUTES": "운동하기 시간", + "STAND_MINUTES": "서 있은 시간", + "MINDFUL_MINUTES": "마음챙김 시간", + "STATE_OF_MIND_VALENCE": "기록한 마음 상태", + "INDOOR_CO2": "실내 CO2", + "INDOOR_TEMPERATURE": "실내 온도", + "INDOOR_HUMIDITY": "실내 습도" }, "daily": { "high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요. 원인이 아니라 지켜볼 만한 패턴이에요.", @@ -2716,6 +2729,12 @@ "high": "당일과 전날의 {behaviour}이(가) 높을수록 내 데이터에서는 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", "moderate": "지금까지의 데이터로 보면 당일과 전날의 {behaviour}이(가) 높을수록 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", "faint": "당일과 전날의 {behaviour}이(가) 높을 때 내 데이터에서 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다." + }, + "channelNutrient": "{nutrient} 섭취량", + "dailyOvernight": { + "high": "밤사이 {behaviour}이(가) 높을수록 내 데이터에서는 그날 밤 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "moderate": "지금까지의 데이터로 보면 밤사이 {behaviour}이(가) 높을수록 그날 밤 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "faint": "밤사이 {behaviour}이(가) 높을 때 내 데이터에서 그날 밤 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다." } }, "noAnalysisYet": "아직 평가가 없어요.", @@ -4255,7 +4274,12 @@ "outcomeDown": "기분이 좋으면 다음 날 {factor} 값은 낮은 편이에요.", "stat": "짝지은 {n}일, r = {r}, q = {q}", "statLabel": "계산 방법", - "explainerLabel": "계산 방법" + "explainerLabel": "계산 방법", + "channel": { + "ALCOHOL": "음주", + "WORKOUT_MINUTES": "운동 시간", + "TRAINING_LOAD": "훈련 부하" + } }, "inTargetTitle": "내 범위 안", "dimensions": { diff --git a/messages/nl.json b/messages/nl.json index c44560507..8fd2353e6 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "luchtdrukschommeling gedurende de dag", "ENV_PM25": "fijnstof (PM2.5)", "ENV_OZONE_8H": "ozon", - "ENV_POLLEN_MAX": "pollen" + "ENV_POLLEN_MAX": "pollen", + "HRV_RMSSD": "hartslagvariabiliteit (RMSSD)", + "HYDRATION": "waterinname", + "CAFFEINE": "cafeïne-inname", + "ALCOHOL": "alcoholgebruik", + "WORKOUT_MINUTES": "trainingsminuten", + "TRAINING_LOAD": "trainingsbelasting", + "EXERCISE_MINUTES": "beweegminuten", + "STAND_MINUTES": "staminuten", + "MINDFUL_MINUTES": "mindfulnessminuten", + "STATE_OF_MIND_VALENCE": "vastgelegde gemoedstoestand", + "INDOOR_CO2": "CO2 binnen", + "INDOOR_TEMPERATURE": "binnentemperatuur", + "INDOOR_HUMIDITY": "luchtvochtigheid binnen" }, "daily": { "high": "Bij hogere waarden voor {behaviour} ligt {outcome} de volgende dag in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", @@ -2716,6 +2729,12 @@ "high": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor ligt {outcome} in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", "moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor. Het is een patroon om in de gaten te houden, geen oorzaak.", "faint": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak." + }, + "channelNutrient": "inname van {nutrient}", + "dailyOvernight": { + "high": "Bij hogere waarden voor {behaviour} 's nachts ligt {outcome} die nacht in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", + "moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} die nacht in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} 's nachts. Het is een patroon om in de gaten te houden, geen oorzaak.", + "faint": "Bij hogere waarden voor {behaviour} 's nachts is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} die nacht {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak." } }, "noAnalysisYet": "Nog geen beoordeling.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Een hogere stemming gaat in je gegevens vaak samen met lagere waarden voor {factor} de volgende dag.", "stat": "{n} gekoppelde dagen, r = {r}, q = {q}", "statLabel": "Hoe dit is berekend", - "explainerLabel": "Hoe dit is berekend" + "explainerLabel": "Hoe dit is berekend", + "channel": { + "ALCOHOL": "alcoholgebruik", + "WORKOUT_MINUTES": "trainingsminuten", + "TRAINING_LOAD": "trainingsbelasting" + } }, "inTargetTitle": "Binnen je bereik", "dimensions": { diff --git a/messages/pl.json b/messages/pl.json index c1812715f..304d35ac3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia", "ENV_PM25": "pył drobny (PM2,5)", "ENV_OZONE_8H": "ozon", - "ENV_POLLEN_MAX": "pyłki" + "ENV_POLLEN_MAX": "pyłki", + "HRV_RMSSD": "zmienność rytmu serca (RMSSD)", + "HYDRATION": "spożycie wody", + "CAFFEINE": "spożycie kofeiny", + "ALCOHOL": "spożycie alkoholu", + "WORKOUT_MINUTES": "minuty treningu", + "TRAINING_LOAD": "obciążenie treningowe", + "EXERCISE_MINUTES": "minuty ćwiczeń", + "STAND_MINUTES": "minuty na stojąco", + "MINDFUL_MINUTES": "minuty uważności", + "STATE_OF_MIND_VALENCE": "zapisany stan ducha", + "INDOOR_CO2": "CO2 w pomieszczeniu", + "INDOOR_TEMPERATURE": "temperatura w pomieszczeniu", + "INDOOR_HUMIDITY": "wilgotność w pomieszczeniu" }, "daily": { "high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych. To wzorzec wart obserwacji, nie przyczyna.", @@ -2716,6 +2729,12 @@ "high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", "moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", "faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome}. To za mało, by na tym polegać, i nigdy nie jest to przyczyna." + }, + "channelNutrient": "spożycie: {nutrient}", + "dailyOvernight": { + "high": "Więcej {behaviour} w nocy idzie w twoich danych zwykle w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.", + "moderate": "Więcej {behaviour} w nocy wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.", + "faint": "Więcej {behaviour} w nocy pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome} tej samej nocy. To za mało, by na tym polegać, i nigdy nie jest to przyczyna." } }, "noAnalysisYet": "Brak oceny.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.", "stat": "{n} sparowanych dni, r = {r}, q = {q}", "statLabel": "Jak to obliczono", - "explainerLabel": "Jak to obliczono" + "explainerLabel": "Jak to obliczono", + "channel": { + "ALCOHOL": "spożycie alkoholu", + "WORKOUT_MINUTES": "minuty treningu", + "TRAINING_LOAD": "obciążenie treningowe" + } }, "inTargetTitle": "W Twoim zakresie", "dimensions": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts new file mode 100644 index 000000000..0eb5df59f --- /dev/null +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -0,0 +1,101 @@ +/** + * The statistics behind the correlation, trend and changepoint surfaces are + * computed without a model. + * + * These modules answer "what does the record show": correlation channels, + * lab slopes and projected band exits, screener change against the + * clinically meaningful threshold, training load, level shifts. None of that + * may depend on a provider being configured, and none of it may become a way + * to reach one. A capability gate cannot protect a computation that quietly + * imports the provider chain, so this guard walks each module's import + * closure (value imports, `@/` and relative, through re-exports) and fails on + * anything under `lib/ai/` except the byte cipher the symptom decrypt uses, + * and on any provider call in the module's own text. + * + * Limits, stated: a dynamic `import()` or a `require` is not followed, and a + * type-only import is skipped by design (it carries no code). The list below + * is the set this guard covers; a new analysis module joins it by being added. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const SRC = join(process.cwd(), "src"); + +/** The analysis modules held to "no model anywhere below". */ +const ANALYSIS_MODULES = [ + "lib/insights/correlation-discovery.ts", + "lib/insights/correlation-series-builders.ts", + "lib/insights/correlation-channel-series.ts", + "lib/insights/discovery-matrix.ts", + "lib/insights/derived/changepoint.ts", +] as const; + +/** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ +const AI_TREE_ALLOWED = new Set(["lib/ai/coach/bytes-codec.ts"]); + +/** A provider call, as `ai-egress-capability-guard` defines it. */ +const EGRESS_CALL = + /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; + +const VALUE_IMPORT = + /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/g; + +function resolveImport(fromFile: string, spec: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(SRC, spec.slice(2)); + else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + ]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function closure(entry: string): string[] { + const seen = new Set(); + const stack = [join(SRC, entry)]; + while (stack.length > 0) { + const file = stack.pop() as string; + if (seen.has(file)) continue; + seen.add(file); + // The generated Prisma client is never read (several MB, no model in it). + if (file.includes(`${SRC}/generated/`)) continue; + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(VALUE_IMPORT)) { + const next = resolveImport(file, match[1]); + if (next) stack.push(next); + } + } + return [...seen].map((f) => relative(SRC, f)); +} + +describe("the analysis modules compute without a model", () => { + it.each(ANALYSIS_MODULES)("%s reaches no provider", (entry) => { + const reached = closure(entry); + // A walk that found nothing would pass any assertion below. + expect(reached.length).toBeGreaterThan(1); + const ai = reached.filter( + (f) => f.startsWith("lib/ai/") && !AI_TREE_ALLOWED.has(f), + ); + expect(ai).toEqual([]); + }); + + it.each(ANALYSIS_MODULES)("%s makes no provider call", (entry) => { + expect(readFileSync(join(SRC, entry), "utf8")).not.toMatch(EGRESS_CALL); + }); + + it("would notice a provider import", () => { + // The walk follows the provider module itself into the chain. + expect(closure("lib/ai/provider.ts").length).toBeGreaterThan(1); + expect( + closure("lib/ai/provider.ts").some( + (f) => f === "lib/ai/provider-chain.ts", + ), + ).toBe(true); + }); +}); diff --git a/src/__tests__/discovery-matrix-guard.test.ts b/src/__tests__/discovery-matrix-guard.test.ts index 128b5e8ac..36e7d48d7 100644 --- a/src/__tests__/discovery-matrix-guard.test.ts +++ b/src/__tests__/discovery-matrix-guard.test.ts @@ -96,6 +96,10 @@ describe("T1 — only the assembler fetches discovery channels", () => { "fetchMoodFactorWindowSeries", "fetchMeasurementDailySeriesTiered", "fetchMeasurementWindowSeries", + "fetchNutrientSeries", + "fetchAlcoholSeries", + "fetchTrainingSeries", + "fetchIndoorClimateSeries", ] as const; /** diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index a94277515..bdd7656ac 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -104,6 +104,10 @@ const SQL_READERS: Record = { "lib/measurements/daily-series-read.ts": HELPER, "lib/rollups/measurement-rollups.ts": HELPER, "lib/rollups/tiered-context.ts": HELPER, + "lib/insights/correlation-channel-series.ts": { + exempt: + "the one SQL mean is the room-climate hour mean, over INDOOR_CO2 / INDOOR_TEMPERATURE / INDOOR_HUMIDITY only; pulse reaches the correlation channels through `dayValueRows` and the rollup compose", + }, }; /** Files that name pulse and hold an in-memory mean or a mean-making fold. */ diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index dfbc5cfbe..56c8c4c6e 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -58,6 +58,10 @@ const IDENTITY_OWNER: Record = { "lib/insights/strain-score.ts": "lib/insights/strain-score.ts", "lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts", "lib/day/records.ts": "lib/day/records.ts", + // The workout-minutes correlation channel: one session per day per sport, + // as the list shows it. + "lib/insights/correlation-channel-series.ts": + "lib/insights/correlation-channel-series.ts", }; /** diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 0dad495bb..37759be1d 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -3,6 +3,10 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, auditLog: { create: vi.fn() }, appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, user: { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 7d46a41bb..245c33421 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -245,6 +245,11 @@ async function buildCorrelationsResponse( custom_metric_channels: diagnostics.customMetricChannels, symptom_event_channels: diagnostics.symptomEventChannels, custom_metric_days: diagnostics.customMetricDays, + // v1.44 — reach of the extended families. + nutrient_days: diagnostics.nutrientDays, + alcohol_days: diagnostics.alcoholDays, + training_days: diagnostics.trainingDays, + indoor_nights: diagnostics.indoorNights, }, }); diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 6f72d7148..1902f881d 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record = { // v1.43 — the Dawarich movement channels. MOVEMENT_AWAY: "environment.movement.awayFromHome", MOVEMENT_DISTANCE: "environment.movement.distance", + // v1.44 — the extended channel families. + HRV_RMSSD: "measurements.typeHrvRmssd", + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + HYDRATION: "nutrients.names.water", + CAFFEINE: "nutrients.names.caffeine", + ALCOHOL: "insights.mood.discovery.channel.ALCOHOL", + WORKOUT_MINUTES: "insights.mood.discovery.channel.WORKOUT_MINUTES", + TRAINING_LOAD: "insights.mood.discovery.channel.TRAINING_LOAD", }; +/** A micronutrient channel (`NUTRIENT:`) reads under its catalog name. */ +const NUTRIENT_PREFIX = "NUTRIENT:"; + +function channelLabelKey(channelKey: string): string { + if (channelKey.startsWith(NUTRIENT_PREFIX)) { + return `nutrients.names.${channelKey.slice(NUTRIENT_PREFIX.length)}`; + } + return CHANNEL_LABEL_KEY[channelKey] ?? channelKey; +} + /** Keep only the discovered pairs that involve the mood channel. */ export function moodPairsOf( discovered: DiscoveredCorrelation[], @@ -131,8 +151,7 @@ export function MoodDiscoveredRelations({ const dynamicLabel = moodIsOutcome ? pair.behaviourLabel : pair.outcomeLabel; - const factorLabel = - dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); + const factorLabel = dynamicLabel ?? t(channelLabelKey(channelKey)); if ( patternId && dismissal.isDismissed(patternId, pair.dismissed === true) diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 23e8bd7cd..25ece9e00 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/modules/gate", () => ({ })); vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, user: { findUnique: (a: unknown) => userFindUnique(a) }, diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index b9579386b..2d9fc8f04 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -50,6 +50,10 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, discoverEmergingCorrelations, + withoutModelFencedPairs, + CAFFEINE_CHANNEL_KEY, + HYDRATION_CHANNEL_KEY, + NUTRIENT_CHANNEL_PREFIX, discoverLabOutcomeCorrelations, EARLY_WINDOW_DAYS, MEDICATION_COMPLIANCE_CHANNEL_KEY, @@ -158,6 +162,8 @@ function tzDayKey(at: Date, tz: string): string { const CHANNEL_LABELS: Record = { [MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence", [SYMPTOM_SEVERITY_CHANNEL_KEY]: "symptom severity", + [HYDRATION_CHANNEL_KEY]: "water intake", + [CAFFEINE_CHANNEL_KEY]: "caffeine intake", ...Object.fromEntries( ENVIRONMENT_FIELDS.map((field) => [field.key, field.narrationLabel]), ), @@ -172,6 +178,9 @@ const CHANNEL_LABELS: Record = { */ function humanise(key: string, label?: string): string { if (label && label.trim().length > 0) return label.trim().toLowerCase(); + if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) { + return `${key.slice(NUTRIENT_CHANNEL_PREFIX.length).replace(/_/g, " ")} intake`; + } return CHANNEL_LABELS[key] ?? key.replace(/_/g, " ").toLowerCase(); } @@ -281,7 +290,12 @@ export async function readCoachCorrelations( }); const discovery = discoverCorrelations(series, { locale }); - const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({ + // The Coach is a model: pairs on a fenced channel never reach it. The + // scan above is the shared one, so the remaining pairs' statistics are the + // ones the correlations page shows. + const drivers: CoachCorrelationDriver[] = withoutModelFencedPairs( + discovery.discovered, + ).map((d) => ({ behaviour: humanise(d.behaviour, d.behaviourLabel), outcome: humanise(d.outcome, d.outcomeLabel), direction: d.r >= 0 ? "higher" : "lower", @@ -303,18 +317,18 @@ export async function readCoachCorrelations( recentFromDayKey, locale, }); - const emerging: CoachEmergingDriver[] = emergingResult.emerging.map( - (d) => ({ - behaviour: humanise(d.behaviour, d.behaviourLabel), - outcome: humanise(d.outcome, d.outcomeLabel), - direction: d.r >= 0 ? "higher" : "lower", - lagDays: d.lagDays, - n: d.n, - r: Math.round(d.r * 100) / 100, - note: d.interpretation, - provisional: true, - }), - ); + const emerging: CoachEmergingDriver[] = withoutModelFencedPairs( + emergingResult.emerging, + ).map((d) => ({ + behaviour: humanise(d.behaviour, d.behaviourLabel), + outcome: humanise(d.outcome, d.outcomeLabel), + direction: d.r >= 0 ? "higher" : "lower", + lagDays: d.lagDays, + n: d.n, + r: Math.round(d.r * 100) / 100, + note: d.interpretation, + provisional: true, + })); // v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). const labResult = discoverLabOutcomeCorrelations(labDraws, series, { diff --git a/src/lib/insights/__tests__/correlation-extended-channels.test.ts b/src/lib/insights/__tests__/correlation-extended-channels.test.ts new file mode 100644 index 000000000..0b0accfa7 --- /dev/null +++ b/src/lib/insights/__tests__/correlation-extended-channels.test.ts @@ -0,0 +1,284 @@ +/** + * v1.44 — the extended correlation channel families: the pure series shapers + * (intake, alcohol days, workout minutes, training load, overnight room + * climate) and the engine's per-channel `targets` / `phrasing`, plus the fence + * that keeps the sensitive channels away from every model-bound surface. + */ +import { describe, expect, it } from "vitest"; + +import { + ALCOHOL_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + discoverCorrelations, + HYDRATION_CHANNEL_KEY, + INDOOR_CLIMATE_TARGETS, + metricFamily, + MODEL_FENCED_CHANNELS, + withoutModelFencedPairs, + type DailySeriesPoint, + type NamedSeries, +} from "../correlation-discovery"; +import { + buildAlcoholDailySeries, + buildNutrientDailySeries, + buildOvernightClimateSeries, + buildTrainingLoadSeries, + buildWorkoutMinutesSeries, + MAX_MICRONUTRIENT_CHANNELS, + MIN_ALCOHOL_DAYS, + MIN_OVERNIGHT_HOURS, +} from "../correlation-series-builders"; +import { shiftDateKey } from "@/lib/tz/format"; + +const DAY0 = "2026-01-01"; +const dayN = (n: number) => shiftDateKey(DAY0, n); + +function daily(values: number[], from = 0): DailySeriesPoint[] { + return values.map((value, i) => ({ day: dayN(from + i), value })); +} + +describe("buildNutrientDailySeries", () => { + it("sums sources per day and maps water and caffeine to their channels", () => { + const out = buildNutrientDailySeries( + [ + { day: dayN(0), nutrient: "water", amount: 1200 }, + { day: dayN(0), nutrient: "water", amount: 300 }, + { day: dayN(1), nutrient: "caffeine", amount: 95 }, + ], + { minDays: 20 }, + ); + expect(out.map((s) => s.key)).toEqual([ + HYDRATION_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + ]); + expect(out[0].points).toEqual([{ day: dayN(0), value: 1500 }]); + expect(out[1].points).toEqual([{ day: dayN(1), value: 95 }]); + }); + + it("admits the most-logged varying micronutrients, capped, never a constant", () => { + const rows = []; + // Six varying micronutrients with 30..25 days, one constant with 40. + const codes = [ + "zinc", + "iron", + "magnesium", + "vitamin_d", + "selenium", + "iodine", + ]; + codes.forEach((code, c) => { + for (let d = 0; d < 30 - c; d += 1) { + rows.push({ day: dayN(d), nutrient: code, amount: 10 + (d % 3) }); + } + }); + for (let d = 0; d < 40; d += 1) { + rows.push({ day: dayN(d), nutrient: "vitamin_c", amount: 500 }); + } + // One varying code below the day floor. + for (let d = 0; d < 10; d += 1) { + rows.push({ day: dayN(d), nutrient: "copper", amount: d }); + } + const keys = buildNutrientDailySeries(rows, { minDays: 20 }) + .map((s) => s.key) + .slice(2); + expect(keys).toHaveLength(MAX_MICRONUTRIENT_CHANNELS); + expect(keys).toEqual([ + "NUTRIENT:zinc", + "NUTRIENT:iron", + "NUTRIENT:magnesium", + "NUTRIENT:vitamin_d", + ]); + }); +}); + +describe("buildAlcoholDailySeries", () => { + const at = (n: number, hour = 20) => + new Date(`${dayN(n)}T${String(hour).padStart(2, "0")}:00:00.000Z`); + + it("marks a day 1 when any entry is tagged, 0 when entries are untagged", () => { + const entries = []; + for (let d = 0; d < 12; d += 1) { + entries.push({ at: at(d, 9), tagged: false }); + entries.push({ at: at(d, 21), tagged: d % 2 === 0 }); + } + const out = buildAlcoholDailySeries(entries, "UTC"); + expect(out.key).toBe(ALCOHOL_CHANNEL_KEY); + expect(out.points).toHaveLength(12); + expect(out.points.filter((p) => p.value === 1)).toHaveLength(6); + expect(out.points[0]).toEqual({ day: dayN(0), value: 1 }); + expect(out.points[1]).toEqual({ day: dayN(1), value: 0 }); + }); + + it("is empty below the tagged-day floor or with nothing to compare against", () => { + const few = Array.from({ length: 20 }, (_, d) => ({ + at: at(d), + tagged: d < MIN_ALCOHOL_DAYS - 1, + })); + expect(buildAlcoholDailySeries(few, "UTC").points).toEqual([]); + const all = Array.from({ length: 20 }, (_, d) => ({ + at: at(d), + tagged: true, + })); + expect(buildAlcoholDailySeries(all, "UTC").points).toEqual([]); + }); +}); + +describe("buildWorkoutMinutesSeries / buildTrainingLoadSeries", () => { + it("sums a day's sessions and zero-fills only between the first and last", () => { + const out = buildWorkoutMinutesSeries( + [ + { startedAt: new Date(`${dayN(0)}T07:00:00Z`), durationSec: 1800 }, + { startedAt: new Date(`${dayN(0)}T18:00:00Z`), durationSec: 900 }, + { startedAt: new Date(`${dayN(3)}T07:00:00Z`), durationSec: 3600 }, + ], + "UTC", + ); + expect(out.points).toEqual([ + { day: dayN(0), value: 45 }, + { day: dayN(1), value: 0 }, + { day: dayN(2), value: 0 }, + { day: dayN(3), value: 60 }, + ]); + }); + + it("keys a session on the person's day, not UTC's", () => { + const out = buildWorkoutMinutesSeries( + [{ startedAt: new Date(`${dayN(0)}T23:30:00Z`), durationSec: 600 }], + "Europe/Berlin", + ); + expect(out.points).toEqual([{ day: dayN(1), value: 10 }]); + }); + + it("fills a missing strain day with zero impulse", () => { + const out = buildTrainingLoadSeries([ + { day: dayN(0), dayTrimp: 80 }, + { day: dayN(2), dayTrimp: 40 }, + ]); + expect(out.points).toEqual([ + { day: dayN(0), value: 80 }, + { day: dayN(1), value: 0 }, + { day: dayN(2), value: 40 }, + ]); + }); +}); + +describe("buildOvernightClimateSeries", () => { + it("keys 21:00–07:00 on the waking day, averages hours, drops thin nights", () => { + const rows = []; + // Night into day 1: 21–23 on day 0 (600 ppm), 0–4 on day 1 (900 ppm). + for (const h of [21, 22, 23]) { + rows.push({ type: "INDOOR_CO2", day: dayN(0), hour: h, mean: 600 }); + } + for (const h of [0, 1, 2, 3, 4]) { + rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: h, mean: 900 }); + } + // Daytime hours never count. + rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: 12, mean: 5000 }); + // A thin night into day 2. + for (let h = 0; h < MIN_OVERNIGHT_HOURS - 1; h += 1) { + rows.push({ type: "INDOOR_CO2", day: dayN(2), hour: h, mean: 700 }); + } + const [co2, temp] = buildOvernightClimateSeries( + rows, + ["INDOOR_CO2", "INDOOR_TEMPERATURE"], + INDOOR_CLIMATE_TARGETS, + ); + // (3·600 + 5·900) / 8 = 787.5 + expect(co2.points).toEqual([{ day: dayN(1), value: 787.5 }]); + expect(co2.lagDays).toBe(0); + expect(co2.phrasing).toBe("overnight"); + expect(co2.targets).toEqual(["SLEEP_DURATION"]); + expect(temp.points).toEqual([]); + }); +}); + +describe("discoverCorrelations — targets and phrasing", () => { + // 60 days of a behaviour alternating in blocks, an outcome that is an exact + // linear function of the previous day's behaviour: r = −1 after any + // detrending, n = 59 lag-joined days. + const x = Array.from({ length: 60 }, (_, d) => (d % 4 < 2 ? 100 : 300)); + const sleepNextDay = [420, ...x.slice(0, 59).map((v) => 480 - 0.5 * v)]; + + it("finds caffeine → next-day sleep with the exact n and r", () => { + const series: NamedSeries[] = [ + { key: CAFFEINE_CHANNEL_KEY, role: "behaviour", points: daily(x) }, + { key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) }, + ]; + const result = discoverCorrelations(series, { locale: "en" }); + expect(result.pairsTested).toBe(1); + const pair = result.discovered[0]; + expect(pair.behaviour).toBe(CAFFEINE_CHANNEL_KEY); + expect(pair.n).toBe(59); + expect(pair.r).toBeCloseTo(-1, 9); + expect(pair.interpretation).toContain("caffeine intake"); + }); + + it("tests a targeted channel against its targets only", () => { + const series: NamedSeries[] = [ + { + key: "INDOOR_CO2", + role: "behaviour", + lagDays: 0, + phrasing: "overnight", + targets: INDOOR_CLIMATE_TARGETS, + points: daily(x), + }, + { + key: "SLEEP_DURATION", + role: "outcome", + points: daily(x.map((v) => 480 - 0.5 * v)), + }, + { key: "MOOD", role: "outcome", points: daily(x.map((v) => v / 100)) }, + { + key: "RESTING_HEART_RATE", + role: "outcome", + points: daily(x.map((v) => 50 + v / 50)), + }, + ]; + const result = discoverCorrelations(series, { locale: "en" }); + expect(result.pairsTested).toBe(1); + expect(result.discovered).toHaveLength(1); + const pair = result.discovered[0]; + expect(pair.outcome).toBe("SLEEP_DURATION"); + expect(pair.lagDays).toBe(0); + expect(pair.n).toBe(60); + expect(pair.r).toBeCloseTo(-1, 9); + expect(pair.interpretation).toContain("overnight"); + expect(pair.interpretation).toContain("that night"); + }); + + it("names a micronutrient by its catalog name in the reader's language", () => { + const series: NamedSeries[] = [ + { key: "NUTRIENT:magnesium", role: "behaviour", points: daily(x) }, + { key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) }, + ]; + const [pair] = discoverCorrelations(series, { locale: "de" }).discovered; + expect(pair.interpretation).toContain("die Zufuhr von Magnesium"); + }); +}); + +describe("families and the model fence", () => { + it("puts State of Mind in mood's family and the new families in their own", () => { + expect(metricFamily("STATE_OF_MIND_VALENCE")).toBe("MOOD"); + expect(metricFamily("INDOOR_CO2")).toBe("INDOOR"); + expect(metricFamily("TRAINING_LOAD")).toBe("TRAINING"); + expect(metricFamily("WORKOUT_MINUTES")).toBe("TRAINING"); + expect(metricFamily("NUTRIENT:zinc")).toBe("NUTRIENTS"); + expect(metricFamily(CAFFEINE_CHANNEL_KEY)).toBe("NUTRIENTS"); + }); + + it("drops every pair that touches a fenced channel", () => { + const pairs = [ + { behaviour: ALCOHOL_CHANNEL_KEY, outcome: "SLEEP_DURATION" }, + { behaviour: "MINDFUL_MINUTES", outcome: "MOOD" }, + { behaviour: "ACTIVITY_STEPS", outcome: "STATE_OF_MIND_VALENCE" }, + { behaviour: CAFFEINE_CHANNEL_KEY, outcome: "SLEEP_DURATION" }, + ]; + expect(withoutModelFencedPairs(pairs)).toEqual([pairs[3]]); + expect([...MODEL_FENCED_CHANNELS].sort()).toEqual([ + "ALCOHOL", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + ]); + }); +}); diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index 8f53be251..daba264d1 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -48,6 +48,29 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({ fetchSymptomEventSeries: vi.fn(async () => [ { key: "SYMPTOM:def1", label: "Aura", role: "outcome", points: pts }, ]), + fetchNutrientSeries: vi.fn(async () => [ + { key: "HYDRATION", role: "behaviour", points: pts }, + { key: "CAFFEINE", role: "behaviour", points: pts }, + { key: "NUTRIENT:zinc", role: "behaviour", points: pts }, + ]), + fetchAlcoholSeries: vi.fn(async () => ({ + key: "ALCOHOL", + role: "behaviour", + points: pts, + })), + fetchTrainingSeries: vi.fn(async () => [ + { key: "WORKOUT_MINUTES", role: "behaviour", points: pts }, + { key: "TRAINING_LOAD", role: "behaviour", points: pts }, + ]), + fetchIndoorClimateSeries: vi.fn(async () => [ + { + key: "INDOOR_CO2", + role: "behaviour", + lagDays: 0, + targets: ["SLEEP_DURATION"], + points: pts, + }, + ]), })); vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(), @@ -58,8 +81,12 @@ import { maskSeriesByModules, } from "@/lib/insights/discovery-matrix"; import { + fetchAlcoholSeries, fetchEnvironmentSeries, + fetchIndoorClimateSeries, fetchMovementSeries, + fetchNutrientSeries, + fetchTrainingSeries, } from "@/lib/insights/correlation-channel-series"; const OPTS = { @@ -177,6 +204,77 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { }); }); +describe("assembleDiscoveryMatrix — the extended families (v1.44)", () => { + const EXTENDED = [ + "HYDRATION", + "CAFFEINE", + "NUTRIENT:zinc", + "ALCOHOL", + "WORKOUT_MINUTES", + "TRAINING_LOAD", + "EXERCISE_MINUTES", + "STAND_MINUTES", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "INDOOR_CO2", + ]; + + it("folds every extended family in after the earlier ones", async () => { + const { series, byMetric } = await assembleDiscoveryMatrix("u1", OPTS); + const keys = series.map((s) => s.key); + for (const key of EXTENDED) expect(keys, key).toContain(key); + // Earlier families keep their place: the first extended key comes after + // the last person-defined symptom. + expect(keys.indexOf("EXERCISE_MINUTES")).toBeGreaterThan( + keys.indexOf("SYMPTOM:def1"), + ); + expect(series.find((s) => s.key === "STATE_OF_MIND_VALENCE")?.role).toBe( + "outcome", + ); + // The extended types feed the scan only, never the narrative's metrics. + for (const key of ["EXERCISE_MINUTES", "STATE_OF_MIND_VALENCE"]) { + expect(byMetric.has(key), key).toBe(false); + } + }); + + it("reads and keeps each family only while its module is on", async () => { + const reads = [ + fetchNutrientSeries, + fetchAlcoholSeries, + fetchTrainingSeries, + fetchIndoorClimateSeries, + ].map((fn) => vi.mocked(fn)); + for (const read of reads) read.mockClear(); + const { series } = await assembleDiscoveryMatrix("u1", { + ...OPTS, + modules: { + nutrients: false, + mood: false, + workouts: false, + environment: false, + }, + }); + for (const read of reads) expect(read).not.toHaveBeenCalled(); + const keys = series.map((s) => s.key); + for (const gone of [ + "HYDRATION", + "CAFFEINE", + "NUTRIENT:zinc", + "ALCOHOL", + "WORKOUT_MINUTES", + "TRAINING_LOAD", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "INDOOR_CO2", + ]) { + expect(keys, gone).not.toContain(gone); + } + // Exercise and stand minutes are ambient movement and carry no owner. + expect(keys).toContain("EXERCISE_MINUTES"); + expect(keys).toContain("STAND_MINUTES"); + }); +}); + describe("maskSeriesByModules", () => { it("keeps the fold order of what remains", () => { const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({ diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index 0afd64e98..03273cfa8 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], user: { findUnique: vi.fn() }, measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 47ea68dbb..cce0f970f 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -30,6 +30,8 @@ import { } from "@/lib/analytics/compliance"; import { FACTOR_CHANNEL_PREFIX, + INDOOR_CLIMATE_TARGETS, + INDOOR_CLIMATE_TYPES, MEDICATION_COMPLIANCE_CHANNEL_KEY, MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, @@ -46,7 +48,12 @@ import { } from "@/lib/environment/fields"; import { isAirQualityActive } from "@/lib/environment/open-meteo-air-quality"; import { + buildAlcoholDailySeries, buildComplianceDailySeries, + buildNutrientDailySeries, + buildOvernightClimateSeries, + buildTrainingLoadSeries, + buildWorkoutMinutesSeries, buildSymptomEventDailySeries, buildSymptomSeverityDailySeries, type SymptomDayLogRow, @@ -62,7 +69,14 @@ import { pickMainNightAndNaps, type SleepStageRow, } from "@/lib/analytics/sleep-night"; -import { isNearUtc, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { + isNearUtc, + isValidTimezone, + shiftDateKey, + userDayKey, +} from "@/lib/tz/format"; +import { MIN_PAIRED_N } from "@/lib/insights/correlations"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { @@ -1155,3 +1169,171 @@ export async function fetchLabDraws( } return draws; } + +// ─── v1.44 — the extended channel families ───────────────────────────────── + +/** + * Upper bound on nutrient rows read for one scan: 180 days × 28 codes × two + * sources is about ten thousand; the cap falls on the oldest rows. + */ +const NUTRIENT_READ_CAP = 12_000; + +/** + * HYDRATION, CAFFEINE and up to four micronutrients from the nutrient log. + * The caller skips this read when the opt-in `nutrients` module is off. + */ +export async function fetchNutrientSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const rows = await prisma.nutrientIntakeDay.findMany({ + where: { userId, day: { gte: userDayKey(since, tz) } }, + orderBy: { day: "desc" }, + take: NUTRIENT_READ_CAP, + select: { day: true, nutrient: true, amount: true }, + }); + return buildNutrientDailySeries(rows, { minDays: MIN_PAIRED_N }); +} + +/** The mood read the alcohol channel shares its cap with. */ +const ALCOHOL_TAG_KEY = "alcohol"; + +/** + * The ALCOHOL channel from the mood log's `alcohol` tag: a structured tag + * link, or the key in the legacy flat `tags` list. Only the timestamp and the + * tag's presence are read; no note, no score. + */ +export async function fetchAlcoholSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "desc" }, + take: MOOD_READ_CAP, + select: { + moodLoggedAt: true, + tags: true, + tagLinks: { + where: { moodTag: { key: ALCOHOL_TAG_KEY } }, + select: { moodTagId: true }, + take: 1, + }, + }, + }); + return buildAlcoholDailySeries( + rows.map((row) => ({ + at: row.moodLoggedAt, + tagged: row.tagLinks.length > 0 || flatTagsInclude(row.tags), + })), + tz, + ); +} + +/** Whether a legacy flat `tags` JSON array names the alcohol tag. */ +function flatTagsInclude(raw: string | null): boolean { + if (!raw) return false; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) && parsed.includes(ALCOHOL_TAG_KEY); + } catch { + return false; + } +} + +/** Upper bound on workout rows read for one scan. */ +const WORKOUT_READ_CAP = 3000; + +/** + * WORKOUT_MINUTES (sessions collapsed across sources by the workout list's + * picker) and TRAINING_LOAD (the strain engine's stored day TRIMP). The caller + * skips this read when the `workouts` module is off. + */ +export async function fetchTrainingSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const [workouts, account, trimp] = await Promise.all([ + prisma.workout.findMany({ + where: { userId, startedAt: { gte: since } }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: WORKOUT_READ_CAP, + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + prisma.strainTrimpCache.findMany({ + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day (scoreDayKey) + where: { userId, day: { gte: since.toISOString().slice(0, 10) } }, + orderBy: { day: "asc" }, + take: 1000, + select: { day: true, dayTrimp: true }, + }), + ]); + const sessions = pickCanonicalWorkoutRows( + workouts, + account?.sourcePriorityJson ?? null, + ); + return [ + buildWorkoutMinutesSeries(sessions, tz), + buildTrainingLoadSeries(trimp), + ]; +} + +/** + * The room-climate channels, one per type, as overnight means keyed on the + * waking day. Postgres folds the readings to one row per local hour (a sensor + * reporting every minute is ~260k rows over the window; the hours are at most + * 180 × 24 per type), so the process never holds the raw readings. The caller + * skips this read when the `environment` module is off. + */ +export async function fetchIndoorClimateSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const zone = isValidTimezone(tz) ? tz : "UTC"; + const rows = await prisma.$queryRaw< + Array<{ type: string; day: string; hour: number; mean: number }> + >` + WITH src AS ( + SELECT + m."type"::text AS type, + (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${zone} AS local_at, + m."value" AS value + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" IN ('INDOOR_CO2'::measurement_type, 'INDOOR_TEMPERATURE'::measurement_type, 'INDOOR_HUMIDITY'::measurement_type) + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${since} + ) + SELECT + type, + to_char(local_at, 'YYYY-MM-DD') AS day, + EXTRACT(HOUR FROM local_at)::int AS hour, + AVG(value)::double precision AS mean + FROM src + GROUP BY type, day, hour + `; + return buildOvernightClimateSeries( + rows.map((r) => ({ + type: r.type, + day: r.day, + hour: Number(r.hour), + mean: Number(r.mean), + })), + INDOOR_CLIMATE_TYPES, + INDOOR_CLIMATE_TARGETS, + ); +} diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index c08f20f70..11e833f89 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -138,6 +138,20 @@ export function metricFamily(key: string): string { if (key.startsWith("ENV_")) return "ENVIRONMENT"; if (key.startsWith("MOVEMENT_")) return "MOVEMENT"; if (key.startsWith("CUSTOM_METRIC:")) return key; + // v1.44 — State of Mind is a mood self-report from another app: pairing it + // with the logged mood would correlate one feeling with itself. + if (key === "STATE_OF_MIND_VALENCE") return "MOOD"; + // The room-climate readings are one room's air; the training channels one + // training record; the intake channels one supplement log. + if (key.startsWith("INDOOR_")) return "INDOOR"; + if (key === WORKOUT_MINUTES_CHANNEL_KEY || key === TRAINING_LOAD_CHANNEL_KEY) + return "TRAINING"; + if ( + key === HYDRATION_CHANNEL_KEY || + key === CAFFEINE_CHANNEL_KEY || + key.startsWith(NUTRIENT_CHANNEL_PREFIX) + ) + return "NUTRIENTS"; // v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form // their OWN single-channel family, so the loop's same-family guard collapses // only the self-lag (compliance→compliance, symptom→symptom). The returned @@ -196,6 +210,19 @@ export interface NamedSeries { * there means the averaged 0–1 window, tested as one hypothesis. */ lagDays?: number; + /** + * Behaviour channels only: the outcomes this channel is tested against. + * Absent means every outcome. A channel with one plausible question (the + * bedroom's air and the night's sleep) names it, so the pairs nobody asked + * about do not widen the false-discovery family for every other pair. + */ + targets?: readonly string[]; + /** + * Behaviour channels only: how the sentence names the exposure window when + * it is not the default next day. `overnight` is the night's mean, keyed on + * the day the person woke up (`lagDays: 0`). + */ + phrasing?: "overnight"; } /** One discovered, FDR-surviving correlation pair. */ @@ -325,6 +352,7 @@ interface RawPair { lagDays: number; behaviourLabel?: string; outcomeLabel?: string; + phrasing?: "overnight"; } /** @@ -374,6 +402,7 @@ function interpret( t: Translate, behaviourLabel?: string, outcomeLabel?: string, + phrasing?: "overnight", ): string { const params = { behaviour: behaviourLabel ?? humanise(behaviour, t), @@ -389,7 +418,13 @@ function interpret( tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate"; // Lag 0 is the averaged same-day window of the exposure channels ("on the // day and the day before"); every other pair reads as next-day. - const template = lagDays === 0 ? "dailySameDay" : "daily"; + // The overnight room climate reads as "overnight … that night". + const template = + phrasing === "overnight" + ? "dailyOvernight" + : lagDays === 0 + ? "dailySameDay" + : "daily"; return t(`insights.correlation.${template}.${tierKey}`, params); } @@ -417,6 +452,12 @@ function humanise(key: string, t: Translate): string { .toLowerCase(); return t("insights.correlation.channelFactor", { factor }); } + if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) { + const code = key.slice(NUTRIENT_CHANNEL_PREFIX.length); + const nutrient = + translateOrNull(t, `nutrients.names.${code}`) ?? code.replace(/_/g, " "); + return t("insights.correlation.channelNutrient", { nutrient }); + } const labelKey = `insights.correlation.channel.${key}`; // An unmapped channel falls back to a readable prettified form, never a // raw key. @@ -463,6 +504,95 @@ export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY"; export const MOVEMENT_AWAY_CHANNEL_KEY = "MOVEMENT_AWAY"; export const MOVEMENT_DISTANCE_CHANNEL_KEY = "MOVEMENT_DISTANCE"; +// ─── v1.44 — the extended channel families ───────────────────────────────── +// +// Folded by the assembler beside the declared matrix rather than into +// `DISCOVERY_BEHAVIOURS` / `DISCOVERY_OUTCOMES`: those two lists also feed the +// per-metric status fingerprint (`status-cache.ts`), and a channel added there +// would make every written assessment card go stale on a reading of a type no +// card is about. Every family below is a behaviour except State of Mind, and +// each degrades to absent below the n ≥ 20 floor like every other channel. + +/** Daily water intake (mL), from the nutrient log. Owned by `nutrients`. */ +export const HYDRATION_CHANNEL_KEY = "HYDRATION"; +/** Daily caffeine intake (mg), from the nutrient log. Owned by `nutrients`. */ +export const CAFFEINE_CHANNEL_KEY = "CAFFEINE"; +/** One micronutrient's daily intake: `NUTRIENT:`. */ +export const NUTRIENT_CHANNEL_PREFIX = "NUTRIENT:"; +/** + * Days with alcohol, from the `alcohol` tag on mood entries: 1 on a day with a + * tagged entry, 0 on a day with entries and no tag, nothing on a day without + * an entry. Owned by `mood`. AUDIT-C is not a channel: it is a questionnaire + * answered every few months about the past year, and has no day to pair. + */ +export const ALCOHOL_CHANNEL_KEY = "ALCOHOL"; +/** Minutes of workout per day, one per session. Owned by `workouts`. */ +export const WORKOUT_MINUTES_CHANNEL_KEY = "WORKOUT_MINUTES"; +/** The day's heart-rate training impulse (TRIMP). Owned by `workouts`. */ +export const TRAINING_LOAD_CHANNEL_KEY = "TRAINING_LOAD"; + +/** Apple Health day totals read as behaviours (minutes per day). */ +export const EXTENDED_BEHAVIOUR_TYPES = [ + "EXERCISE_MINUTES", + "STAND_MINUTES", + "MINDFUL_MINUTES", +] as const; + +/** Apple Health readings read as outcomes. */ +export const EXTENDED_OUTCOME_TYPES = ["STATE_OF_MIND_VALENCE"] as const; + +/** + * Room-climate readings, paired with the night's sleep only. The value is the + * mean of the readings between 21:00 and 07:00 local time, keyed on the day + * the person woke up, so it pairs with that night's sleep at lag 0. + */ +export const INDOOR_CLIMATE_TYPES = [ + "INDOOR_CO2", + "INDOOR_TEMPERATURE", + "INDOOR_HUMIDITY", +] as const; +export const INDOOR_CLIMATE_TARGETS: readonly string[] = ["SLEEP_DURATION"]; + +/** Every fixed channel key the extended families can write. */ +export const EXTENDED_CHANNEL_KEYS: readonly string[] = [ + HYDRATION_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + ALCOHOL_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, + TRAINING_LOAD_CHANNEL_KEY, + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, + ...INDOOR_CLIMATE_TYPES, +]; + +/** + * Channels whose pairs are statistics for the person only and never reach a + * model: the Coach tool, the period narrative and the assessment cards drop + * every pair that touches one. State of Mind and mindfulness sit in the + * report's sensitive group and are kept out of the Coach snapshot and MCP; + * alcohol reaches a model only through the health profile's own opt-in. + * + * The scan itself is the same on every surface, so the statistics of the + * remaining pairs do not depend on who is reading; the fence is applied to + * the result. + */ +export const MODEL_FENCED_CHANNELS: ReadonlySet = new Set([ + "STATE_OF_MIND_VALENCE", + "MINDFUL_MINUTES", + ALCOHOL_CHANNEL_KEY, +]); + +/** Drop the pairs that touch a {@link MODEL_FENCED_CHANNELS} channel. */ +export function withoutModelFencedPairs< + T extends { behaviour: string; outcome: string }, +>(pairs: readonly T[]): T[] { + return pairs.filter( + (p) => + !MODEL_FENCED_CHANNELS.has(p.behaviour) && + !MODEL_FENCED_CHANNELS.has(p.outcome), + ); +} + /** * v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum * values: each is backed by a different model (MOOD → MoodEntry, @@ -543,6 +673,7 @@ export function discoverCorrelations( // auto-correlation tautology, not a cross-domain insight; excluding it // before testing stops it crowding out genuine cross-metric links. if (metricFamily(b.key) === metricFamily(o.key)) continue; + if (b.targets && !b.targets.includes(o.key)) continue; const pairLag = b.lagDays ?? lagDays; const pairs = lagJoinPairs(b.points, o.points, pairLag); if (pairs.length < minPairs) continue; @@ -558,6 +689,7 @@ export function discoverCorrelations( lagDays: pairLag, behaviourLabel: b.label, outcomeLabel: o.label, + ...(b.phrasing ? { phrasing: b.phrasing } : {}), }); } } @@ -612,6 +744,7 @@ export function discoverCorrelations( translate, t.behaviourLabel, t.outcomeLabel, + t.phrasing, ), lagDays: t.lagDays, })) diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts index a34095560..dc3381884 100644 --- a/src/lib/insights/correlation-series-builders.ts +++ b/src/lib/insights/correlation-series-builders.ts @@ -38,8 +38,14 @@ import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { shiftDateKey } from "@/lib/tz/format"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { + ALCOHOL_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + HYDRATION_CHANNEL_KEY, MEDICATION_COMPLIANCE_CHANNEL_KEY, + NUTRIENT_CHANNEL_PREFIX, SYMPTOM_SEVERITY_CHANNEL_KEY, + TRAINING_LOAD_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; @@ -244,3 +250,239 @@ export function buildSymptomEventDailySeries(args: { points, }; } + +// ─── v1.44 — the extended channel families ───────────────────────────────── + +/** One stored nutrient day total (one row per day, nutrient and source). */ +export interface NutrientDayRow { + day: string; + nutrient: string; + amount: number; +} + +/** Ceiling on `NUTRIENT:` channels in one scan. */ +export const MAX_MICRONUTRIENT_CHANNELS = 4; + +/** + * The intake channels from the nutrient log: water as HYDRATION, caffeine as + * CAFFEINE, and at most {@link MAX_MICRONUTRIENT_CHANNELS} micronutrients as + * `NUTRIENT:`. + * + * A day's amount is the sum across its sources (a manual water entry and the + * Apple Health total are two rows, and reads sum them). A day without a row is + * no point: the log does not say whether nothing was taken or nothing was + * recorded. The micronutrients admitted are those with at least `minDays` + * logged days and any day-to-day variance, the most-logged first (code as the + * tie-break): a daily supplement at a fixed dose is a constant, and a + * constant cannot correlate with anything. Pure. + */ +export function buildNutrientDailySeries( + rows: readonly NutrientDayRow[], + opts: { minDays: number }, +): NamedSeries[] { + const byNutrient = new Map>(); + for (const row of rows) { + if (!Number.isFinite(row.amount)) continue; + const days = byNutrient.get(row.nutrient) ?? new Map(); + days.set(row.day, (days.get(row.day) ?? 0) + row.amount); + byNutrient.set(row.nutrient, days); + } + const pointsOf = (code: string): DailySeriesPoint[] => + [...(byNutrient.get(code) ?? new Map()).entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + + const out: NamedSeries[] = [ + { + key: HYDRATION_CHANNEL_KEY, + role: "behaviour", + points: pointsOf("water"), + }, + { + key: CAFFEINE_CHANNEL_KEY, + role: "behaviour", + points: pointsOf("caffeine"), + }, + ]; + const candidates = [...byNutrient.keys()] + .filter((code) => code !== "water" && code !== "caffeine") + .map((code) => ({ code, points: pointsOf(code) })) + .filter( + ({ points }) => + points.length >= opts.minDays && + points.some((p) => p.value !== points[0].value), + ) + .sort( + (a, b) => b.points.length - a.points.length || (a.code < b.code ? -1 : 1), + ) + .slice(0, MAX_MICRONUTRIENT_CHANNELS); + for (const { code, points } of candidates) { + out.push({ + key: `${NUTRIENT_CHANNEL_PREFIX}${code}`, + role: "behaviour", + points, + }); + } + return out; +} + +/** One mood entry as the alcohol channel reads it. */ +export interface AlcoholTagEntry { + at: Date; + tagged: boolean; +} + +/** Fewest tagged days before the alcohol channel is offered at all. */ +export const MIN_ALCOHOL_DAYS = 5; + +/** + * The ALCOHOL behaviour series: per day with at least one mood entry, 1 when + * any entry that day carries the `alcohol` tag and 0 otherwise. A day without + * an entry is no point. Below {@link MIN_ALCOHOL_DAYS} tagged days (or with no + * untagged day to compare against) the series is empty: a handful of ones is + * what a spurious point-biserial r is made of. Pure. + */ +export function buildAlcoholDailySeries( + entries: readonly AlcoholTagEntry[], + tz: string, +): NamedSeries { + const byDay = new Map(); + for (const entry of entries) { + const day = dayKey(entry.at, tz); + byDay.set(day, Math.max(byDay.get(day) ?? 0, entry.tagged ? 1 : 0)); + } + const points = [...byDay.entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + const tagged = points.filter((p) => p.value === 1).length; + const usable = tagged >= MIN_ALCOHOL_DAYS && tagged < points.length; + return { + key: ALCOHOL_CHANNEL_KEY, + role: "behaviour", + points: usable ? points : [], + }; +} + +/** One canonical workout session as the training channels read it. */ +export interface WorkoutSessionRow { + startedAt: Date; + durationSec: number; +} + +/** + * Fill the days between the first and the last key with 0. Inside that span + * the person was recording training, so a day without a session is a rest + * day; outside it nothing says whether they were recording at all. Pure. + */ +function zeroFillSpan(byDay: ReadonlyMap): DailySeriesPoint[] { + const keys = [...byDay.keys()].sort(); + const points: DailySeriesPoint[] = []; + if (keys.length === 0) return points; + const last = keys[keys.length - 1]; + for (let day = keys[0]; day <= last; day = shiftDateKey(day, 1)) { + points.push({ day, value: byDay.get(day) ?? 0 }); + } + return points; +} + +/** + * WORKOUT_MINUTES: the day's summed session minutes (user zone, by start), + * over sessions the caller has already collapsed across sources, zero-filled + * between the first and the last session. Pure. + */ +export function buildWorkoutMinutesSeries( + sessions: readonly WorkoutSessionRow[], + tz: string, +): NamedSeries { + const byDay = new Map(); + for (const s of sessions) { + if (!Number.isFinite(s.durationSec) || s.durationSec <= 0) continue; + const day = dayKey(s.startedAt, tz); + byDay.set(day, (byDay.get(day) ?? 0) + s.durationSec / 60); + } + return { + key: WORKOUT_MINUTES_CHANNEL_KEY, + role: "behaviour", + points: zeroFillSpan(byDay), + }; +} + +/** + * TRAINING_LOAD: the stored day-total TRIMP, zero-filled between the first and + * the last stored day. The nightly strain run writes a row only on a scored + * day, so a missing day inside the span carried no training impulse. The day + * keys are the strain engine's UTC days (the same tolerance the DAY rollups + * carry). Pure. + */ +export function buildTrainingLoadSeries( + rows: ReadonlyArray<{ day: string; dayTrimp: number }>, +): NamedSeries { + const byDay = new Map(); + for (const row of rows) { + if (!Number.isFinite(row.dayTrimp) || row.dayTrimp < 0) continue; + byDay.set(row.day, row.dayTrimp); + } + return { + key: TRAINING_LOAD_CHANNEL_KEY, + role: "behaviour", + points: zeroFillSpan(byDay), + }; +} + +/** One local hour's mean of one room-climate type. */ +export interface ClimateHourRow { + type: string; + /** Local calendar day of the hour, YYYY-MM-DD. */ + day: string; + /** Local hour, 0–23. */ + hour: number; + mean: number; +} + +/** The overnight window, local time: from this hour … */ +export const OVERNIGHT_FROM_HOUR = 21; +/** … until (exclusive) this hour of the next morning. */ +export const OVERNIGHT_TO_HOUR = 7; +/** Fewest hours inside the window before a night has a value. */ +export const MIN_OVERNIGHT_HOURS = 4; + +/** + * One series per room-climate type: the night's mean of its hourly means, the + * night running 21:00 to 07:00 local time and keyed on the morning's day, the + * key the sleep series uses. Averaging hours rather than readings keeps a + * sensor that reports every minute from outweighing one that reports every + * quarter hour. A night with fewer than {@link MIN_OVERNIGHT_HOURS} hours has + * no point. Each series pairs with the night's sleep only, at lag 0. Pure. + */ +export function buildOvernightClimateSeries( + rows: readonly ClimateHourRow[], + types: readonly string[], + targets: readonly string[], +): NamedSeries[] { + const nights = new Map>(); + for (const row of rows) { + if (!Number.isFinite(row.mean)) continue; + let night: string; + if (row.hour >= OVERNIGHT_FROM_HOUR) night = shiftDateKey(row.day, 1); + else if (row.hour < OVERNIGHT_TO_HOUR) night = row.day; + else continue; + const byNight = + nights.get(row.type) ?? new Map(); + const acc = byNight.get(night) ?? { sum: 0, n: 0 }; + acc.sum += row.mean; + acc.n += 1; + byNight.set(night, acc); + nights.set(row.type, byNight); + } + return types.map((type) => ({ + key: type, + role: "behaviour" as const, + lagDays: 0, + phrasing: "overnight" as const, + targets, + points: [...(nights.get(type) ?? new Map()).entries()] + .filter(([, acc]) => acc.n >= MIN_OVERNIGHT_HOURS) + .map(([day, acc]) => ({ day, value: acc.sum / acc.n })) + .sort((a, b) => (a.day < b.day ? -1 : 1)), + })); +} diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index cfbc2e8eb..78a594444 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -35,6 +35,11 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], measurement: { findMany: (a: unknown) => measurementFindMany(a), findFirst: (a: unknown) => measurementFindFirst(a), diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index dd7ee058a..9cddb927f 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -36,18 +36,28 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { + ALCOHOL_CHANNEL_KEY, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, discoveryMeasurementTypes, + EXTENDED_BEHAVIOUR_TYPES, + EXTENDED_OUTCOME_TYPES, + HYDRATION_CHANNEL_KEY, + INDOOR_CLIMATE_TYPES, MEDICATION_COMPLIANCE_CHANNEL_KEY, MOVEMENT_AWAY_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { buildMeasurementDailySeries, + fetchAlcoholSeries, fetchComplianceSeries, + fetchIndoorClimateSeries, + fetchNutrientSeries, + fetchTrainingSeries, fetchCustomMetricBehaviourSeries, fetchSymptomEventSeries, fetchEnvironmentSeries, @@ -162,6 +172,14 @@ export interface DiscoveryMatrixDiagnostics { moodFactorChannels: number; /** `SYMPTOM:*` channels read (before the module mask). */ symptomEventChannels: number; + /** Stored daily points summed across the intake channels (v1.44). */ + nutrientDays: number; + /** Days in the ALCOHOL series (0 below its tagged-day floor). */ + alcoholDays: number; + /** Daily points summed across the two training channels. */ + trainingDays: number; + /** Nights summed across the room-climate channels. */ + indoorNights: number; } export interface DiscoveryMatrix { @@ -260,6 +278,11 @@ async function readMeasurementDaily( }; } +/** Daily points summed across a family's series. */ +function daysIn(family: readonly NamedSeries[]): number { + return family.reduce((sum, s) => sum + s.points.length, 0); +} + /** * Build the full discovery matrix for a user over a window. * @@ -292,8 +315,18 @@ export async function assembleDiscoveryMatrix( ...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS), ...discoveryMeasurementTypes(DISCOVERY_OUTCOMES), ...(opts.extraMeasurementTypes ?? []), + // v1.44 — the Apple Health day totals and State of Mind read through the + // same measurement path; they are left out of `byMetric` below. + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, ]), ) as MeasurementType[]; + const extendedTypes = new Set([ + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, + ]); + const visible = (channelKey: string): boolean => + isSurfaceVisible(correlationChannelSurfaceId(channelKey), opts.modules); const [ measurements, @@ -304,6 +337,10 @@ export async function assembleDiscoveryMatrix( customMetricSeries, symptomEventSeries, movementSeries, + nutrientSeries, + alcoholSeries, + trainingSeries, + indoorSeries, ] = await Promise.all([ readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode), readMood(userId, tz, since, opts.includeMoodFactors === true), @@ -329,6 +366,20 @@ export async function assembleDiscoveryMatrix( ) ? fetchMovementSeries(userId, tz, since) : Promise.resolve([] as NamedSeries[]), + // v1.44 — each extended family is read only while its module is on; the + // mask below would drop it anyway. + visible(HYDRATION_CHANNEL_KEY) + ? fetchNutrientSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), + visible(ALCOHOL_CHANNEL_KEY) + ? fetchAlcoholSeries(userId, tz, since) + : Promise.resolve(null), + visible(WORKOUT_MINUTES_CHANNEL_KEY) + ? fetchTrainingSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), + visible(INDOOR_CLIMATE_TYPES[0]) + ? fetchIndoorClimateSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), ]); const { factorSeries } = mood; @@ -366,6 +417,21 @@ export async function assembleDiscoveryMatrix( // channel and its own family, capped at eight; the illness module owns them // through the `SYMPTOM:` prefix rule in the surface map. series.push(...symptomEventSeries); + // v1.44 — the extended families, folded after every earlier family so the + // ranking of the pairs that existed before is unchanged on a tie. The + // intake, alcohol and training channels pair D → D+1; the room climate is + // the overnight mean keyed on the waking day and pairs with that night's + // sleep only. Exercise, stand and mindful minutes are behaviours; State of + // Mind is an outcome in the mood family. + for (const key of EXTENDED_BEHAVIOUR_TYPES) { + series.push({ key, role: "behaviour", points: points(key) }); + } + for (const key of EXTENDED_OUTCOME_TYPES) { + series.push({ key, role: "outcome", points: points(key) }); + } + series.push(...nutrientSeries); + if (alcoholSeries) series.push(alcoholSeries); + series.push(...trainingSeries, ...indoorSeries); // A factor is plausibly both a lag source ("rated work today → next-day // sleep") and a lag target ("steps today → next-day rated energy"), so it // enters as both roles. The engine skips self-pairs — every factor shares @@ -376,6 +442,9 @@ export async function assembleDiscoveryMatrix( } const byMetric = new Map(measurements.byType); + // The extended types feed the scan only. `byMetric` is what the narrative + // cites deltas from, and none of them is a metric it describes. + for (const key of extendedTypes) byMetric.delete(key); if (mood.moodDaily.length > 0) byMetric.set("MOOD", mood.moodDaily); for (const [key, factorPoints] of factorSeries) { byMetric.set(key, factorPoints); @@ -408,6 +477,10 @@ export async function assembleDiscoveryMatrix( ), moodFactorChannels: factorSeries.size, symptomEventChannels: symptomEventSeries.length, + nutrientDays: daysIn(nutrientSeries), + alcoholDays: alcoholSeries?.points.length ?? 0, + trainingDays: daysIn(trainingSeries), + indoorNights: daysIn(indoorSeries), }, }; } diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index e9cfb3bd0..9a9ea9354 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -32,6 +32,7 @@ import { discoverCorrelations, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, + withoutModelFencedPairs, } from "@/lib/insights/correlation-discovery"; import { resolveModuleMap } from "@/lib/modules/gate"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; @@ -133,6 +134,9 @@ export async function getRelevantCorrelationsForMetric( // written in its place rather than after it; the docstring, the name // and the `channelKeyForType` guard above all kept saying otherwise. .filter((d) => d.behaviour === channel || d.outcome === channel) + // The card's prompt is model input: a pair on a fenced channel stays + // on the correlations page. + .filter((d) => withoutModelFencedPairs([d]).length === 1) .filter((d) => { const decision = decisionForEvidence(decisions, { factorKey: d.behaviour, diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index d208195f0..92125c7c2 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -38,6 +38,11 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], user: { findUnique: (a: unknown) => userFindUnique(a) }, measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index d51cdec1b..3a9311a5e 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -48,6 +48,7 @@ import { annotate } from "@/lib/logging/context"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, + withoutModelFencedPairs, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; @@ -422,7 +423,10 @@ export function assemblePeriodNarrativeContext( // ── drivers (FDR-surviving correlations, descriptive-only) ────────────── const discovery = discoverCorrelations(discoverySeries, { locale }); - const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({ + // The narrative is model input: pairs on a fenced channel stay out of it. + const drivers: NarrativeDriver[] = withoutModelFencedPairs( + discovery.discovered, + ).map((d) => ({ behaviour: d.behaviour, outcome: d.outcome, r: d.r, diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 7bbf00579..efcd35cbc 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -60,6 +60,8 @@ import { MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, DISCOVERY_OUTCOMES, + EXTENDED_CHANNEL_KEYS, + NUTRIENT_CHANNEL_PREFIX, FACTOR_CHANNEL_PREFIX, } from "@/lib/insights/correlation-discovery"; import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry"; @@ -197,6 +199,9 @@ describe("every surface id names a surface that exists", () => { MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, "LAB_DRAWS", + ...EXTENDED_CHANNEL_KEYS, + // The fold target of every `NUTRIENT:` channel. + "NUTRIENT", ]); for (const id of localIds("correlation")) { expect(channels.has(id), id).toBe(true); @@ -209,6 +214,21 @@ describe("every surface id names a surface that exists", () => { expect(correlationChannelSurfaceId(`${SYMPTOM_CHANNEL_PREFIX}abc`)).toBe( "correlation:SYMPTOM_SEVERITY", ); + expect(correlationChannelSurfaceId(`${NUTRIENT_CHANNEL_PREFIX}zinc`)).toBe( + "correlation:NUTRIENT", + ); + }); + + it("every extended channel has an owner or is reviewed as unowned", () => { + const unowned = new Set(["EXERCISE_MINUTES", "STAND_MINUTES"]); + for (const key of EXTENDED_CHANNEL_KEYS) { + if (unowned.has(key)) { + expect(surfaceModule(`correlation:${key}`), key).toBeUndefined(); + } else { + expect(surfaceModule(`correlation:${key}`), key).toBeDefined(); + } + } + expect(surfaceModule("correlation:NUTRIENT")).toBe("nutrients"); }); }); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 8d0c9b029..b70d84516 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -228,6 +228,24 @@ const STATIC_SURFACE_MODULE = { // v1.43 — the Dawarich movement channels ride the environment module. "correlation:MOVEMENT_AWAY": "environment", "correlation:MOVEMENT_DISTANCE": "environment", + // v1.44 — the extended families. Intake rides the opt-in nutrient log (the + // `NUTRIENT:` micronutrient channels fold onto `correlation:NUTRIENT`), + // alcohol days are read from mood entries' tag, and the two training + // channels from the workout record. State of Mind and mindfulness are + // self-reports about feeling, so they follow mood. The room climate rides + // the environment module like the weather. Exercise and stand minutes are + // ambient movement like steps and carry no owner. + "correlation:HYDRATION": "nutrients", + "correlation:CAFFEINE": "nutrients", + "correlation:NUTRIENT": "nutrients", + "correlation:ALCOHOL": "mood", + "correlation:WORKOUT_MINUTES": "workouts", + "correlation:TRAINING_LOAD": "workouts", + "correlation:MINDFUL_MINUTES": "mood", + "correlation:STATE_OF_MIND_VALENCE": "mood", + "correlation:INDOOR_CO2": "environment", + "correlation:INDOOR_TEMPERATURE": "environment", + "correlation:INDOOR_HUMIDITY": "environment", // ── Day view sections (v1.42) ── // The readings carry no owner here: each value follows its own type's @@ -355,5 +373,8 @@ export function correlationChannelSurfaceId(channelKey: string): string { // A person-defined symptom (`SYMPTOM:`, v1.40) rides the illness // module, like the symptom-burden channel it sits beside. if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY"; + // One micronutrient's intake (`NUTRIENT:`, v1.44) rides the nutrient + // log like water and caffeine. + if (channelKey.startsWith("NUTRIENT:")) return "correlation:NUTRIENT"; return `correlation:${channelKey}`; } diff --git a/tests/integration/correlation-extended-channels.test.ts b/tests/integration/correlation-extended-channels.test.ts new file mode 100644 index 000000000..30c5c8bf0 --- /dev/null +++ b/tests/integration/correlation-extended-channels.test.ts @@ -0,0 +1,244 @@ +/** + * v1.44 — the extended correlation channel reads against a real Postgres. + * + * The unit battery (`correlation-extended-channels.test.ts`) pins the pure + * shapers; this suite proves the reads that feed them: the room-climate + * hour-mean SQL and its zone conversion, the nutrient day sum across sources, + * the alcohol tag read from the legacy flat list, and the training read with + * a session stored by two services. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + fetchAlcoholSeries, + fetchIndoorClimateSeries, + fetchNutrientSeries, + fetchTrainingSeries, +} from "@/lib/insights/correlation-channel-series"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; +const TZ = "Europe/Berlin"; + +let seq = 0; +async function seedUser(prisma: ReturnType) { + seq += 1; + return prisma.user.create({ + data: { + username: `corr-extended-${seq}`, + email: `corr-extended-${seq}@example.test`, + role: "USER", + timezone: TZ, + }, + }); +} + +/** A UTC day key `daysAgo` days back. */ +function dayKey(daysAgo: number): string { + return new Date(Date.now() - daysAgo * DAY_MS).toISOString().slice(0, 10); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("extended correlation channels — reads", () => { + it("folds the room climate to overnight means on the waking day, in the person's zone", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const wake = dayKey(5); + const evening = dayKey(6); + // Berlin is UTC+1 or +2; 20:30 UTC on the evening is at least 21:30 + // local, 02:00 UTC on the waking day at least 03:00 local, both inside + // the 21:00–07:00 window. Four hours each side, one reading per quarter + // hour at 600 ppm before midnight and 1000 ppm after. + const rows = []; + for (let h = 0; h < 2; h += 1) { + for (let q = 0; q < 4; q += 1) { + rows.push({ + value: 600, + measuredAt: new Date( + `${evening}T2${h}:${String(q * 15).padStart(2, "0")}:00Z`, + ), + }); + } + } + for (let h = 1; h < 4; h += 1) { + for (let q = 0; q < 4; q += 1) { + rows.push({ + value: 1000, + measuredAt: new Date( + `${wake}T0${h}:${String(q * 15).padStart(2, "0")}:00Z`, + ), + }); + } + } + // Midday on the waking day is outside the window. + rows.push({ value: 5000, measuredAt: new Date(`${wake}T11:00:00Z`) }); + await prisma.measurement.createMany({ + data: rows.map((r) => ({ + userId: user.id, + type: "INDOOR_CO2" as const, + unit: "ppm", + source: "EXTERNAL" as const, + ...r, + })), + }); + + const series = await fetchIndoorClimateSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + const co2 = series.find((s) => s.key === "INDOOR_CO2"); + // Two evening hours at 600, three morning hours at 1000: (1200 + 3000) / 5. + expect(co2?.points).toEqual([{ day: wake, value: 840 }]); + expect(co2?.targets).toEqual(["SLEEP_DURATION"]); + }); + + it("sums a nutrient day across sources", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + await prisma.nutrientIntakeDay.createMany({ + data: [ + { + userId: user.id, + day: dayKey(3), + nutrient: "water", + amount: 1500, + unit: "ml", + source: "APPLE_HEALTH", + }, + { + userId: user.id, + day: dayKey(3), + nutrient: "water", + amount: 250, + unit: "ml", + source: "MANUAL", + }, + { + userId: user.id, + day: dayKey(3), + nutrient: "caffeine", + amount: 160, + unit: "mg", + source: "APPLE_HEALTH", + }, + ], + }); + const series = await fetchNutrientSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(series.find((s) => s.key === "HYDRATION")?.points).toEqual([ + { day: dayKey(3), value: 1750 }, + ]); + expect(series.find((s) => s.key === "CAFFEINE")?.points).toEqual([ + { day: dayKey(3), value: 160 }, + ]); + }); + + it("reads alcohol days from the flat tag list and skips deleted entries", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const data = []; + for (let d = 1; d <= 12; d += 1) { + data.push({ + userId: user.id, + date: dayKey(d), + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: new Date(`${dayKey(d)}T12:00:00Z`), + tags: d % 2 === 0 ? JSON.stringify(["alcohol"]) : null, + }); + } + data.push({ + userId: user.id, + date: dayKey(13), + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: new Date(`${dayKey(13)}T12:00:00Z`), + tags: JSON.stringify(["alcohol"]), + deletedAt: new Date(), + }); + await prisma.moodEntry.createMany({ data }); + const series = await fetchAlcoholSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(series.points).toHaveLength(12); + expect(series.points.filter((p) => p.value === 1)).toHaveLength(6); + }); + + it("counts a workout stored by two services once and reads the stored impulse", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const start = new Date(`${dayKey(4)}T10:00:00Z`); + await prisma.workout.createMany({ + data: [ + { + userId: user.id, + sportType: "RUNNING", + source: "APPLE_HEALTH", + startedAt: start, + endedAt: new Date(start.getTime() + 1800_000), + durationSec: 1800, + externalId: "a", + }, + { + userId: user.id, + sportType: "RUNNING", + source: "STRAVA", + startedAt: new Date(start.getTime() + 30_000), + endedAt: new Date(start.getTime() + 1830_000), + durationSec: 1795, + externalId: "s", + }, + ], + }); + await prisma.strainTrimpCache.createMany({ + data: [ + { + userId: user.id, + day: dayKey(4), + dayTrimp: 70, + refPersonal: 150, + anchor: "population", + trainingDays: 1, + }, + { + userId: user.id, + day: dayKey(2), + dayTrimp: 30, + refPersonal: 150, + anchor: "population", + trainingDays: 2, + }, + ], + }); + const [minutes, load] = await fetchTrainingSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(minutes.points).toEqual([{ day: dayKey(4), value: 30 }]); + expect(load.points).toEqual([ + { day: dayKey(4), value: 70 }, + { day: dayKey(3), value: 0 }, + { day: dayKey(2), value: 30 }, + ]); + }); +}); From 211053d9000e696c647c110157e53ac99ad16711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:40:06 +0200 Subject: [PATCH 35/98] fix(migrations): idempotent 0440, refill emptied insights cache, deploy note 0440 adds its columns with ADD COLUMN IF NOT EXISTS like 0424 and 0432; a structural test holds every migration from 0422 on to that form. 0430 clears insights_cached_at where no sealed payload is left, so the nightly pregenerate refills an account whose only copy was the readable column instead of keeping an empty card fresh. The deploy runbook notes that 0430 drops a users column: an old process fails on its next full user read, so stop before starting rather than running old and new side by side. --- docs/ops/deploy.md | 11 +++ .../migration.sql | 9 +++ .../migration.sql | 4 +- .../migration-add-column-idempotent.test.ts | 61 ++++++++++++++++ .../migration-0430-insights-stamp.test.ts | 71 +++++++++++++++++++ 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/migration-add-column-idempotent.test.ts create mode 100644 tests/integration/migration-0430-insights-stamp.test.ts diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index c87d7926b..c57c738ce 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -109,6 +109,17 @@ that for you, and each boot retries). Anything else is a session you can judge: a stuck client may be ended with `SELECT pg_terminate_backend()`. You never need to run `migrate resolve` by hand for this case. +### v1.44.0 notes + +Migration `0430` drops the `insights_cached_text` column from `users`. A +process still running the previous image selects that column on every full +user read and fails once it is gone, so do not run old and new containers side +by side across this release: stop the old app container, let the new one +apply the migrations, then start it (the sequence above does this on a single +host). A web/worker split deployment stops both roles before the first new +container starts. Rolling back to an image older than v1.44.0 needs the column +back first (see Rollback below). + ### v1.11.0 notes Migrations `0110`–`0114` are additive (new tables / columns for the diff --git a/prisma/migrations/0430_drop_insights_cached_text/migration.sql b/prisma/migrations/0430_drop_insights_cached_text/migration.sql index 39724eb38..07620ff66 100644 --- a/prisma/migrations/0430_drop_insights_cached_text/migration.sql +++ b/prisma/migrations/0430_drop_insights_cached_text/migration.sql @@ -13,3 +13,12 @@ DROP INDEX IF EXISTS "users_insights_cached_text_backfill_idx"; ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_cached_text"; + +-- A row whose only copy of the payload was the readable one now holds no +-- cached text at all, while its stamp still says the cache is fresh. Clear the +-- stamp so the nightly pregenerate picks the account up again instead of +-- waiting out the staleness window with an empty card. +UPDATE "users" + SET "insights_cached_at" = NULL + WHERE "insights_cached_text_encrypted" IS NULL + AND "insights_cached_at" IS NOT NULL; diff --git a/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql index 6e9beaa6c..be965332f 100644 --- a/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql +++ b/prisma/migrations/0440_settings_list_layout_and_preview_features/migration.sql @@ -13,5 +13,5 @@ -- Reversibility: dropping the columns returns every list to its default -- presentation and switches every preview feature off. -ALTER TABLE "users" ADD COLUMN "module_list_layout_json" JSONB; -ALTER TABLE "users" ADD COLUMN "preview_features_json" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "module_list_layout_json" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "preview_features_json" JSONB; diff --git a/src/__tests__/migration-add-column-idempotent.test.ts b/src/__tests__/migration-add-column-idempotent.test.ts new file mode 100644 index 000000000..65a8b4401 --- /dev/null +++ b/src/__tests__/migration-add-column-idempotent.test.ts @@ -0,0 +1,61 @@ +/** + * Structural guard: a column a migration adds is added idempotently. + * + * Parallel release branches each add columns to `users`, and a host that ran + * a beta image already holds the columns a later rebuilt migration adds again. + * A bare `ADD COLUMN` then fails the boot with "column already exists". Every + * migration from `0422` on (the first one after the last release that shipped + * a bare form) writes `ADD COLUMN IF NOT EXISTS`, as `0424` and `0432` did from + * the start. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const MIGRATIONS_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../prisma/migrations", +); + +const FIRST_GUARDED = 422; + +/** Lines that add a column without `IF NOT EXISTS`, comments stripped. */ +function bareAddColumns(sql: string): string[] { + return sql + .split("\n") + .map((line) => line.replace(/--.*$/, "")) + .filter((line) => /\bADD\s+COLUMN\b/i.test(line)) + .filter((line) => !/\bADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\b/i.test(line)) + .map((line) => line.trim()); +} + +describe("migrations add columns idempotently", () => { + it("recognises the bare form and accepts the guarded one", () => { + expect( + bareAddColumns('ALTER TABLE "u" ADD COLUMN "a" JSONB;'), + ).toHaveLength(1); + expect( + bareAddColumns('ALTER TABLE "u" ADD COLUMN IF NOT EXISTS "a" JSONB;'), + ).toEqual([]); + expect(bareAddColumns("-- ADD COLUMN in a comment")).toEqual([]); + }); + + it(`uses ADD COLUMN IF NOT EXISTS from ${FIRST_GUARDED} on`, () => { + const offenders: string[] = []; + for (const entry of readdirSync(MIGRATIONS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const number = Number.parseInt(entry.name.slice(0, 4), 10); + if (!Number.isFinite(number) || number < FIRST_GUARDED) continue; + const sql = readFileSync( + resolve(MIGRATIONS_DIR, entry.name, "migration.sql"), + "utf8", + ); + for (const line of bareAddColumns(sql)) { + offenders.push(`${entry.name}: ${line}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/integration/migration-0430-insights-stamp.test.ts b/tests/integration/migration-0430-insights-stamp.test.ts new file mode 100644 index 000000000..f48003037 --- /dev/null +++ b/tests/integration/migration-0430-insights-stamp.test.ts @@ -0,0 +1,71 @@ +/** + * Migration 0430 drops the readable copy of the cached insights payload. A row + * whose only copy was the readable one keeps no text afterwards, so the same + * migration clears its `insights_cached_at` stamp: the nightly pregenerate + * selects `insights_cached_at IS NULL OR < stale`, and a fresh stamp over an + * empty cache would have kept the card empty for the whole staleness window. + * + * The schema here is already migrated; the statement is replayed from the + * migration file against rows seeded in both shapes. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION = resolve( + __dirname, + "../../prisma/migrations/0430_drop_insights_cached_text/migration.sql", +); + +function stampStatement(): string { + const sql = readFileSync(MIGRATION, "utf8"); + const start = sql.indexOf('UPDATE "users"'); + if (start < 0) throw new Error("0430 carries no stamp reset"); + return sql.slice(start, sql.indexOf(";", start) + 1); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("migration 0430 clears the stamp of an emptied cache", () => { + it("resets the stamp only where no sealed payload is left", async () => { + const prisma = getPrismaClient(); + const stamp = new Date("2026-10-20T04:30:00Z"); + await prisma.user.create({ + data: { + id: "emptied", + username: "emptied", + email: "emptied@example.test", + role: "USER", + insightsCachedAt: stamp, + insightsCachedTextEncrypted: null, + }, + }); + await prisma.user.create({ + data: { + id: "sealed", + username: "sealed", + email: "sealed@example.test", + role: "USER", + insightsCachedAt: stamp, + insightsCachedTextEncrypted: new Uint8Array([1, 2, 3]), + }, + }); + + await prisma.$executeRawUnsafe(stampStatement()); + + const rows = await prisma.user.findMany({ + where: { id: { in: ["emptied", "sealed"] } }, + select: { id: true, insightsCachedAt: true }, + orderBy: { id: "asc" }, + }); + expect(rows).toEqual([ + { id: "emptied", insightsCachedAt: null }, + { id: "sealed", insightsCachedAt: stamp }, + ]); + }); +}); From a9700bfa6242c66bafa8fb8bd272b6092ea7be93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:40:06 +0200 Subject: [PATCH 36/98] test(oura): grant the workout and heartrate scopes in the callback fixture The fixture granted the scopes Oura was asked for before workouts and intraday heart rate joined; a full grant now names those two as well. --- tests/integration/oauth-callback-single-use.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/oauth-callback-single-use.test.ts b/tests/integration/oauth-callback-single-use.test.ts index 4c5a1b824..6f71de311 100644 --- a/tests/integration/oauth-callback-single-use.test.ts +++ b/tests/integration/oauth-callback-single-use.test.ts @@ -128,7 +128,7 @@ describe("Oura callback delivered twice", () => { refresh_token: "oura-refresh", expires_in: 86400, scope: - "extapi:daily extapi:personal extapi:spo2 extapi:heart_health extapi:stress", + "extapi:daily extapi:personal extapi:spo2 extapi:heart_health extapi:stress extapi:workout extapi:heartrate", }), { status: 200, headers: { "content-type": "application/json" } }, ); @@ -169,7 +169,8 @@ describe("Oura callback delivered twice", () => { access_token: "oura-access", refresh_token: "oura-refresh", expires_in: 86400, - scope: "extapi:daily extapi:personal extapi:spo2 extapi:heart_health", + scope: + "extapi:daily extapi:personal extapi:spo2 extapi:heart_health extapi:workout extapi:heartrate", }), { status: 200, headers: { "content-type": "application/json" } }, ); From 557865accaec20910365909a38ba2a832e0d77fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:43:14 +0200 Subject: [PATCH 37/98] test(measurements): golden comparison of set-based batch insert and reconciler Posts one batch for two accounts with the same stored rows, once as the route runs and once with the set-based insert forced onto its fallback, and asserts identical per-entry outcomes and stored rows across fresh samples, a stored duplicate, a tombstone, a stats: overwrite, an external id twice in the batch and a natural-key collision. --- ...asurements-batch-set-insert-golden.test.ts | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 tests/integration/measurements-batch-set-insert-golden.test.ts diff --git a/tests/integration/measurements-batch-set-insert-golden.test.ts b/tests/integration/measurements-batch-set-insert-golden.test.ts new file mode 100644 index 000000000..1d1b8eaf6 --- /dev/null +++ b/tests/integration/measurements-batch-set-insert-golden.test.ts @@ -0,0 +1,257 @@ +/** + * Golden comparison for the set-based insert in `POST /api/measurements/batch`. + * + * Fresh samples are inserted with one `INSERT … ON CONFLICT DO NOTHING` per + * chunk; whatever that skips goes through the per-entry reconciler. The claim + * is that this changes nothing a client can observe. The same batch is posted + * for two accounts holding the same stored rows: once as the route runs, and + * once with the set-based insert forced to fail, which sends every entry + * through the reconciler (the route's own fallback). Per-entry outcomes and + * the stored rows must be identical, across fresh samples, an exact stored + * duplicate, a tombstoned sample, a `stats:` overwrite, an external id twice + * in one batch, and a natural-key collision under a new external id. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const SET_USER = "golden-set-based"; +const RECONCILER_USER = "golden-reconciler"; + +interface Entry { + hkIdentifier: string; + value: number; + unit: string; + startDate: string; + endDate: string; + externalId: string; +} + +function weight(externalId: string, at: string, value: number): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value, + unit: "kg", + startDate: at, + endDate: at, + externalId, + }; +} + +function pulse(externalId: string, at: string, value: number): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value, + unit: "count/min", + startDate: at, + endDate: at, + externalId, + }; +} + +function steps(day: string, value: number): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value, + unit: "count", + startDate: `${day}T00:00:00.000Z`, + endDate: `${day}T18:00:00.000Z`, + externalId: `stats:HKQuantityTypeIdentifierStepCount:${day}`, + }; +} + +/** What both accounts hold before the batch. */ +const SEED: Entry[] = [ + weight("uuid-live", "2026-07-10T07:00:00.000Z", 80.1), + weight("uuid-tomb", "2026-07-11T07:00:00.000Z", 80.4), + weight("uuid-natural", "2026-07-12T07:00:00.000Z", 80.2), + steps("2026-07-12", 5_000), +]; + +/** The batch under comparison. */ +const BATCH: Entry[] = [ + weight("uuid-fresh-1", "2026-07-13T07:00:00.000Z", 79.9), + pulse("uuid-fresh-2", "2026-07-13T08:00:00.000Z", 61), + // Exactly what is stored. + weight("uuid-live", "2026-07-10T07:00:00.000Z", 80.1), + // A sample its owner deleted, sent again. + weight("uuid-tomb", "2026-07-11T07:00:00.000Z", 80.4), + // A day total HealthKit recomputed. + steps("2026-07-12", 6_200), + // One external id twice in the batch. + pulse("uuid-twice", "2026-07-14T08:00:00.000Z", 63), + pulse("uuid-twice", "2026-07-14T08:00:00.000Z", 63), + // A new id on the instant and source of a stored reading. + weight("uuid-natural-new", "2026-07-12T07:00:00.000Z", 80.3), + weight("uuid-fresh-3", "2026-07-15T07:00:00.000Z", 79.7), +]; + +function request(entries: Entry[]): NextRequest { + return new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ entries }), + }); +} + +async function signIn(userId: string) { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id: userId, username: userId, email: `${userId}@example.test` }, + }); + const session = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +type Outcome = { index: number; status: string; reason?: string }; + +async function post(entries: Entry[]): Promise { + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST(request(entries)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { entries: Outcome[] } }; + return body.data.entries.map(({ index, status, reason }) => ({ + index, + status, + ...(reason ? { reason } : {}), + })); +} + +async function seed(userId: string) { + await signIn(userId); + const outcomes = await post(SEED); + expect(outcomes.every((o) => o.status === "inserted")).toBe(true); + await getPrismaClient().measurement.updateMany({ + where: { userId, externalId: "uuid-tomb" }, + data: { deletedAt: new Date("2026-07-12T00:00:00Z") }, + }); +} + +async function storedRows(userId: string) { + const rows = await getPrismaClient().measurement.findMany({ + where: { userId }, + select: { + type: true, + source: true, + value: true, + unit: true, + measuredAt: true, + externalId: true, + deletedAt: true, + }, + orderBy: [{ externalId: "asc" }, { measuredAt: "asc" }], + }); + return rows.map((r) => ({ ...r, deletedAt: r.deletedAt !== null })); +} + +/** + * Make the route's set-based insert fail, so every entry it buffered goes + * through the reconciler instead. Only `createManyAndReturn` inside an + * interactive transaction is refused; the reconciler never calls it. + */ +async function forceReconcilerPath() { + const { prisma } = await import("@/lib/db"); + const original = prisma.$transaction.bind(prisma); + let refused = 0; + vi.spyOn(prisma, "$transaction").mockImplementation((( + arg: unknown, + options?: unknown, + ) => { + if (typeof arg !== "function") { + return (original as (a: unknown, o?: unknown) => unknown)(arg, options); + } + return (original as (a: unknown, o?: unknown) => unknown)( + (tx: Record) => + (arg as (t: unknown) => unknown)( + new Proxy(tx, { + get(target, key) { + const value = Reflect.get(target, key); + if (key !== "measurement") return value; + return new Proxy(value as object, { + get(model, method) { + if (method === "createManyAndReturn") { + return async () => { + refused++; + throw new Error("set-based insert switched off"); + }; + } + return Reflect.get(model, method); + }, + }); + }, + }), + ), + options, + ); + }) as never); + return () => refused; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("batch set-based insert matches the reconciler entry for entry", () => { + it("answers and stores the same for both paths", async () => { + await seed(SET_USER); + const viaSet = await post(BATCH); + + cookieJar.clear(); + await seed(RECONCILER_USER); + const refusals = await forceReconcilerPath(); + const viaReconciler = await post(BATCH); + // The comparison only means something if the fallback really ran. + expect(refusals()).toBeGreaterThan(0); + + expect(viaSet).toEqual(viaReconciler); + expect(await storedRows(SET_USER)).toEqual( + await storedRows(RECONCILER_USER), + ); + + // And the shared answer is the documented one for each case. + const byIndex = new Map(viaSet.map((o) => [o.index, o.status])); + expect(byIndex.get(0)).toBe("inserted"); + expect(byIndex.get(1)).toBe("inserted"); + expect(byIndex.get(2)).toBe("duplicate"); + expect(byIndex.get(4)).toBe("updated"); + expect([byIndex.get(5), byIndex.get(6)].sort()).toEqual([ + "duplicate", + "inserted", + ]); + expect(byIndex.get(8)).toBe("inserted"); + }); +}); From cb8ed92d78c67fac72a76777cee1ee6637f69835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:44:29 +0200 Subject: [PATCH 38/98] feat(stats): add segmented regression with Newey-West intervals A four-parameter interrupted time series fit (pre-trend, level change, slope change) by OLS, with a Bartlett-kernel HAC covariance, the n/(n-k) small-sample factor and Student t intervals. The tests pin the coefficients, standard errors and intervals to independently computed reference values on contiguous and gapped series. --- ...spread-single-implementation-guard.test.ts | 2 + .../__tests__/segmented-regression.test.ts | 114 +++++++ src/lib/stats/segmented-regression.ts | 285 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 src/lib/stats/__tests__/segmented-regression.test.ts create mode 100644 src/lib/stats/segmented-regression.ts diff --git a/src/__tests__/spread-single-implementation-guard.test.ts b/src/__tests__/spread-single-implementation-guard.test.ts index cf7191b6e..da69c33f5 100644 --- a/src/__tests__/spread-single-implementation-guard.test.ts +++ b/src/__tests__/spread-single-implementation-guard.test.ts @@ -30,6 +30,8 @@ const ALLOWED: Record = { "The correlation engine's centred sums and the Welch t-test variances.", "lib/insights/mood-tag-influence.ts": "Welch t-test variances.", "lib/insights/mood-pattern-cards.ts": "A pooled variance for a t statistic.", + "lib/stats/segmented-regression.ts": + "The total sum of squares behind R² and the HAC standard errors of a segmented fit; neither is reported as a spread.", "lib/insights/derived/trajectory.ts": "The residual standard error of a regression fit (÷ n − 2).", "lib/insights/derived/sleep-score.ts": diff --git a/src/lib/stats/__tests__/segmented-regression.test.ts b/src/lib/stats/__tests__/segmented-regression.test.ts new file mode 100644 index 000000000..57001444b --- /dev/null +++ b/src/lib/stats/__tests__/segmented-regression.test.ts @@ -0,0 +1,114 @@ +/** + * Exact statistics for the segmented (interrupted time series) regression. + * + * The reference values for the noisy fixtures were computed independently + * with statsmodels 0.14 (`OLS(...).fit(cov_type="HAC", cov_kwds={"maxlags": + * L, "use_correction": True}, use_t=True)`, L = floor(4·(n/100)^(2/9))) and + * scipy's `stats.t`. The fixture generator below is the same deterministic + * formula the reference script used, so a drift in the estimator, the HAC + * meat, the small-sample factor or the t quantile fails here. + */ +import { describe, expect, it } from "vitest"; + +import { + fitSegmentedRegression, + neweyWestLag, + studentTCdf, + studentTQuantile, + type SegmentedPoint, +} from "@/lib/stats/segmented-regression"; + +/** y = 140 − 0.05·t − 8·D − 0.1·t·D + deterministic wobble. */ +function fixture( + nPre: number, + nPost: number, + opts: { gaps?: boolean; noise?: boolean } = {}, +): SegmentedPoint[] { + const out: SegmentedPoint[] = []; + for (let t = -nPre; t < nPost; t++) { + if (opts.gaps && ((t % 5) + 5) % 5 === 2) continue; + // Python's `%` is a floored modulo; JS `%` truncates. Match Python. + const pyMod = (((0.45 * t * t) % 7) + 7) % 7; + const noise = + opts.noise === false ? 0 : 3 * Math.sin(1.7 * t) + 2 * Math.cos(pyMod); + const d = t >= 0 ? 1 : 0; + out.push({ t, y: 140 - 0.05 * t - 8 * d - 0.1 * t * d + noise }); + } + return out; +} + +describe("studentT", () => { + it("matches scipy quantiles", () => { + expect(studentTQuantile(0.975, 10)).toBeCloseTo(2.228138851986274, 9); + expect(studentTQuantile(0.975, 86)).toBeCloseTo(1.98793420623902, 9); + expect(studentTQuantile(0.95, 3)).toBeCloseTo(2.3533634348018233, 9); + }); + + it("matches the scipy CDF", () => { + expect(studentTCdf(-2.1, 7)).toBeCloseTo(0.036935598106461326, 10); + expect(studentTCdf(0, 5)).toBeCloseTo(0.5, 12); + }); +}); + +describe("fitSegmentedRegression", () => { + it("recovers a noise-free level and slope change exactly, with zero-width intervals", () => { + const fit = fitSegmentedRegression(fixture(30, 30, { noise: false })); + expect(fit).not.toBeNull(); + expect(fit!.intercept.estimate).toBeCloseTo(140, 9); + expect(fit!.preSlope.estimate).toBeCloseTo(-0.05, 9); + expect(fit!.levelChange.estimate).toBeCloseTo(-8, 9); + expect(fit!.slopeChange.estimate).toBeCloseTo(-0.1, 9); + expect(fit!.levelChange.standardError).toBeLessThan(1e-9); + expect(fit!.rSquared).toBeCloseTo(1, 12); + }); + + it("matches statsmodels HAC on a contiguous 90-day series", () => { + const fit = fitSegmentedRegression(fixture(40, 50))!; + expect(fit.n).toBe(90); + expect(fit.df).toBe(86); + expect(fit.hacLag).toBe(3); + expect(fit.intercept.estimate).toBeCloseTo(139.46950523140714, 9); + expect(fit.preSlope.estimate).toBeCloseTo(-0.09725785860840647, 10); + expect(fit.levelChange.estimate).toBeCloseTo(-7.336815685253282, 9); + expect(fit.slopeChange.estimate).toBeCloseTo(-0.03684505010294312, 10); + expect(fit.intercept.standardError).toBeCloseTo(0.3824128525477469, 9); + expect(fit.preSlope.standardError).toBeCloseTo(0.018189958785228614, 10); + expect(fit.levelChange.standardError).toBeCloseTo(0.5960977099276101, 9); + expect(fit.slopeChange.standardError).toBeCloseTo(0.02336733639963861, 10); + expect(fit.levelChange.ciLow).toBeCloseTo(-8.521818713079124, 8); + expect(fit.levelChange.ciHigh).toBeCloseTo(-6.151812657427441, 8); + expect(fit.slopeChange.ciLow).toBeCloseTo(-0.08329777744047885, 9); + expect(fit.slopeChange.ciHigh).toBeCloseTo(0.009607677234592618, 9); + expect(fit.rSquared).toBeCloseTo(0.8683372493940129, 10); + }); + + it("matches statsmodels HAC on a series with gaps", () => { + const fit = fitSegmentedRegression(fixture(30, 30, { gaps: true }))!; + expect(fit.n).toBe(48); + expect(fit.hacLag).toBe(3); + expect(fit.levelChange.estimate).toBeCloseTo(-6.123258149435133, 9); + expect(fit.levelChange.standardError).toBeCloseTo(0.9608527406825199, 9); + expect(fit.levelChange.ciLow).toBeCloseTo(-8.059729606822106, 8); + expect(fit.levelChange.ciHigh).toBeCloseTo(-4.18678669204816, 8); + expect(fit.slopeChange.estimate).toBeCloseTo(-0.11857109294626766, 10); + expect(fit.slopeChange.ciLow).toBeCloseTo(-0.23733292721328714, 9); + expect(fit.slopeChange.ciHigh).toBeCloseTo(0.0001907413207517944, 9); + expect(fit.rSquared).toBeCloseTo(0.8247246446176286, 10); + }); + + it("returns null when one side of the change has no points", () => { + expect( + fitSegmentedRegression(fixture(0, 30).filter((p) => p.t >= 0)), + ).toBeNull(); + }); + + it("returns null without residual degrees of freedom", () => { + expect(fitSegmentedRegression(fixture(2, 2, { noise: false }))).toBeNull(); + }); + + it("uses the automatic Newey–West lag", () => { + expect(neweyWestLag(90)).toBe(3); + expect(neweyWestLag(30)).toBe(3); + expect(neweyWestLag(10)).toBe(2); + }); +}); diff --git a/src/lib/stats/segmented-regression.ts b/src/lib/stats/segmented-regression.ts new file mode 100644 index 000000000..b0451c5cd --- /dev/null +++ b/src/lib/stats/segmented-regression.ts @@ -0,0 +1,285 @@ +/** + * Segmented regression for an interrupted time series. + * + * One change point, known in advance (a medication start), four parameters: + * + * y(t) = b0 + b1·t + b2·D(t) + b3·t·D(t) + e(t) + * + * where `t` is the time in days relative to the change point and `D(t)` is 1 + * at and after it, 0 before. `b1` is the trend before the change, `b2` the + * immediate shift in level, `b3` the change in slope. Fitted by ordinary + * least squares. + * + * Daily health series are autocorrelated: today's blood pressure says a lot + * about tomorrow's. Plain OLS standard errors ignore that and come out too + * narrow, so the covariance here is the Newey–West estimator (Bartlett + * kernel) with the usual automatic lag `floor(4·(n/100)^(2/9))` and the + * small-sample factor n/(n−k). Intervals use Student's t with n−k degrees of + * freedom. This matches `statsmodels` OLS `cov_type="HAC"` with + * `use_correction=True` and `use_t=True`; the unit tests pin it to values + * computed there. + * + * Observations are taken in the order given, which for a daily series with + * gaps treats a gap as adjacent days. That is a known approximation of the + * lag structure, not of the coefficients. + * + * Pure. No I/O, no rounding: callers round for display. + */ + +/** One observation: time in days relative to the change point, and a value. */ +export interface SegmentedPoint { + t: number; + y: number; +} + +/** A coefficient with its HAC standard error and two-sided interval. */ +export interface SegmentedEstimate { + estimate: number; + standardError: number; + ciLow: number; + ciHigh: number; +} + +export interface SegmentedFit { + /** Observations used. */ + n: number; + /** Residual degrees of freedom, n − 4. */ + df: number; + /** Confidence level of every interval, e.g. 0.95. */ + level: number; + /** Newey–West truncation lag that was applied. */ + hacLag: number; + intercept: SegmentedEstimate; + preSlope: SegmentedEstimate; + levelChange: SegmentedEstimate; + slopeChange: SegmentedEstimate; + /** Coefficient of determination of the fit. */ + rSquared: number; + /** Lag-1 autocorrelation of the residuals (a reader's sanity check). */ + residualAutocorrelation: number; +} + +const K = 4; + +/** Automatic Newey–West lag: floor(4·(n/100)^(2/9)). */ +export function neweyWestLag(n: number): number { + return Math.floor(4 * Math.pow(n / 100, 2 / 9)); +} + +/** Invert a small symmetric positive-definite matrix (Gauss–Jordan). */ +function invert(matrix: number[][]): number[][] | null { + const size = matrix.length; + const a = matrix.map((row, i) => [ + ...row, + ...Array.from({ length: size }, (_, j) => (i === j ? 1 : 0)), + ]); + for (let col = 0; col < size; col++) { + let pivot = col; + for (let r = col + 1; r < size; r++) { + if (Math.abs(a[r][col]) > Math.abs(a[pivot][col])) pivot = r; + } + if (Math.abs(a[pivot][col]) < 1e-12) return null; + [a[col], a[pivot]] = [a[pivot], a[col]]; + const p = a[col][col]; + for (let j = 0; j < 2 * size; j++) a[col][j] /= p; + for (let r = 0; r < size; r++) { + if (r === col) continue; + const f = a[r][col]; + if (f === 0) continue; + for (let j = 0; j < 2 * size; j++) a[r][j] -= f * a[col][j]; + } + } + return a.map((row) => row.slice(size)); +} + +function designRow(p: SegmentedPoint): number[] { + const d = p.t >= 0 ? 1 : 0; + return [1, p.t, d, p.t * d]; +} + +/** + * Fit the four-parameter segmented model. Returns null when the design is + * singular (no points on one side, or every point at one instant) or when + * there are no residual degrees of freedom. + */ +export function fitSegmentedRegression( + points: readonly SegmentedPoint[], + options: { level?: number; hacLag?: number } = {}, +): SegmentedFit | null { + const level = options.level ?? 0.95; + const n = points.length; + const df = n - K; + if (df < 1) return null; + + const X = points.map(designRow); + const y = points.map((p) => p.y); + + const xtx = Array.from({ length: K }, () => new Array(K).fill(0)); + const xty = new Array(K).fill(0); + for (let i = 0; i < n; i++) { + for (let r = 0; r < K; r++) { + xty[r] += X[i][r] * y[i]; + for (let c = 0; c < K; c++) xtx[r][c] += X[i][r] * X[i][c]; + } + } + const bread = invert(xtx); + if (!bread) return null; + const beta = bread.map((row) => row.reduce((s, v, c) => s + v * xty[c], 0)); + + const residuals = y.map( + (yi, i) => yi - X[i].reduce((s, v, c) => s + v * beta[c], 0), + ); + + // Meat: Σ e_t² x_t x_tᵀ + Σ_l w_l Σ_t e_t e_{t−l} (x_t x_{t−l}ᵀ + x_{t−l} x_tᵀ) + const lag = Math.max(0, Math.min(options.hacLag ?? neweyWestLag(n), n - 1)); + const meat = Array.from({ length: K }, () => new Array(K).fill(0)); + for (let l = 0; l <= lag; l++) { + const w = l === 0 ? 1 : 1 - l / (lag + 1); + for (let t = l; t < n; t++) { + const ee = residuals[t] * residuals[t - l] * w; + for (let r = 0; r < K; r++) { + for (let c = 0; c < K; c++) { + meat[r][c] += + l === 0 + ? ee * X[t][r] * X[t][c] + : ee * (X[t][r] * X[t - l][c] + X[t - l][r] * X[t][c]); + } + } + } + } + const correction = n / df; + const cov = Array.from({ length: K }, (_, r) => + Array.from({ length: K }, (_, c) => { + let s = 0; + for (let i = 0; i < K; i++) { + for (let j = 0; j < K; j++) s += bread[r][i] * meat[i][j] * bread[j][c]; + } + return s * correction; + }), + ); + + const tCrit = studentTQuantile(1 - (1 - level) / 2, df); + const estimateAt = (i: number): SegmentedEstimate => { + const se = Math.sqrt(Math.max(0, cov[i][i])); + return { + estimate: beta[i], + standardError: se, + ciLow: beta[i] - tCrit * se, + ciHigh: beta[i] + tCrit * se, + }; + }; + + const meanY = y.reduce((s, v) => s + v, 0) / n; + let ssr = 0; + let sst = 0; + for (let i = 0; i < n; i++) { + ssr += residuals[i] ** 2; + sst += (y[i] - meanY) ** 2; + } + let acNum = 0; + for (let i = 1; i < n; i++) acNum += residuals[i] * residuals[i - 1]; + + return { + n, + df, + level, + hacLag: lag, + intercept: estimateAt(0), + preSlope: estimateAt(1), + levelChange: estimateAt(2), + slopeChange: estimateAt(3), + rSquared: sst > 0 ? 1 - ssr / sst : 1, + residualAutocorrelation: ssr > 0 ? acNum / ssr : 0, + }; +} + +// ── Student's t ──────────────────────────────────────────────────────── + +/** ln Γ(x), Lanczos approximation (g = 7, n = 9). */ +function logGamma(x: number): number { + const c = [ + 0.99999999999980993, 676.5203681218851, -1259.1392167224028, + 771.32342877765313, -176.61502916214059, 12.507343278686905, + -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7, + ]; + if (x < 0.5) { + return Math.log(Math.PI / Math.sin(Math.PI * x)) - logGamma(1 - x); + } + const z = x - 1; + let a = c[0]; + const t = z + 7.5; + for (let i = 1; i < 9; i++) a += c[i] / (z + i); + return ( + 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(a) + ); +} + +/** Continued fraction for the incomplete beta (Numerical Recipes betacf). */ +function betaContinuedFraction(x: number, a: number, b: number): number { + const tiny = 1e-300; + let c = 1; + let d = 1 - ((a + b) * x) / (a + 1); + if (Math.abs(d) < tiny) d = tiny; + d = 1 / d; + let h = d; + for (let m = 1; m <= 300; m++) { + const m2 = 2 * m; + let aa = (m * (b - m) * x) / ((a + m2 - 1) * (a + m2)); + d = 1 + aa * d; + if (Math.abs(d) < tiny) d = tiny; + c = 1 + aa / c; + if (Math.abs(c) < tiny) c = tiny; + d = 1 / d; + h *= d * c; + aa = (-(a + m) * (a + b + m) * x) / ((a + m2) * (a + m2 + 1)); + d = 1 + aa * d; + if (Math.abs(d) < tiny) d = tiny; + c = 1 + aa / c; + if (Math.abs(c) < tiny) c = tiny; + d = 1 / d; + const delta = d * c; + h *= delta; + if (Math.abs(delta - 1) < 1e-15) break; + } + return h; +} + +/** Regularised incomplete beta I_x(a, b). */ +function incompleteBeta(x: number, a: number, b: number): number { + if (x <= 0) return 0; + if (x >= 1) return 1; + const front = Math.exp( + logGamma(a + b) - + logGamma(a) - + logGamma(b) + + a * Math.log(x) + + b * Math.log(1 - x), + ); + if (x < (a + 1) / (a + b + 2)) { + return (front * betaContinuedFraction(x, a, b)) / a; + } + return 1 - (front * betaContinuedFraction(1 - x, b, a)) / b; +} + +/** Student's t cumulative distribution function. */ +export function studentTCdf(t: number, df: number): number { + const x = df / (df + t * t); + const tail = 0.5 * incompleteBeta(x, df / 2, 0.5); + return t >= 0 ? 1 - tail : tail; +} + +/** Student's t quantile, by bisection on the CDF (monotone, so it converges). */ +export function studentTQuantile(p: number, df: number): number { + if (p <= 0) return Number.NEGATIVE_INFINITY; + if (p >= 1) return Number.POSITIVE_INFINITY; + if (p === 0.5) return 0; + let lo = -1e3; + let hi = 1e3; + for (let i = 0; i < 200; i++) { + const mid = (lo + hi) / 2; + if (studentTCdf(mid, df) < p) lo = mid; + else hi = mid; + if (hi - lo < 1e-12) break; + } + return (lo + hi) / 2; +} From 90c9a4e2d0196e0813f78a99fabfc036e238696a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:47:39 +0200 Subject: [PATCH 39/98] fix(settings): list layout and preview switch serve the person, atomically Both routes resolved through bare requireAuth, which refuses a session acting for another record, so the list view controls and the preview switch failed in exactly the context they are used in. They are the person's own presentation and now resolve through requireActorAuth, admitted on the actor-surface list with their reasons. The layout PUT and the preview PATCH read and write the stored blob under a row lock in one transaction, so two saves at once no longer start from the same value and drop each other's change. --- src/__tests__/sharing-surface-guard.test.ts | 9 +- .../api/settings/module-list-layout/route.ts | 52 +++++---- .../api/settings/preview-features/route.ts | 37 +++--- .../integration/settings-prefs-routes.test.ts | 110 +++++++++++++++++- 4 files changed, 173 insertions(+), 35 deletions(-) diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d6ec5452e..9b5d0704a 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2072,6 +2072,10 @@ const ACTOR_ROUTES: Record = { "The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.", "app/api/auth/me/locale/route.ts": "The UI language belongs to the person reading the screen, never to the record on it. Under a record scope this would transplant a delegate's choice onto the owner's row and send the owner's cron mail in a language they may not read. It has to keep working rather than merely refuse safely: the switcher's mount-time backfill fires on every page load, including the shared dashboard.", + "app/api/settings/module-list-layout/route.ts": + "How the person's own labs, illness and checkups lists are laid out: card or list view, manual order, sort. It reads and writes the caller's own row only. Inside another record the ids of a saved order simply match nothing and the list keeps the server order, so serving the person's own choice there reveals nothing of the owner's and grants nothing; refusing it under a switch broke the list controls in exactly the context they are used in.", + "app/api/settings/preview-features/route.ts": + "The person's own opt-in to preview features. A preview entry is chrome of the person reading the screen, never a property of the record on it; it reads and writes the caller's own row and nothing of the owner's.", "app/api/feature-flags/route.ts": "Reads the `AppSettings` singleton and no user row at all, so there is no record for a switch to substitute. It answers about the deployment, which for this purpose is the caller's side of the request; the shell gates the Coach launcher and the assistant surfaces on it, so a 403 here is a piece of chrome deciding it does not exist.", }; @@ -2171,8 +2175,11 @@ const ACTOR_ROUTES: Record = { * * v1.44 -- a guardian connects and disconnects a managed profile's OAuth * providers: two guardian-only entries. 266 -> 268. + * + * v1.44 -- the module list layout and the preview-feature switch are the + * person's own presentation and join the actor list. 268 -> 270. */ -const FROZEN_ENTRY_COUNT = 268; +const FROZEN_ENTRY_COUNT = 270; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts index 7bd93d3b8..481e38867 100644 --- a/src/app/api/settings/module-list-layout/route.ts +++ b/src/app/api/settings/module-list-layout/route.ts @@ -8,11 +8,17 @@ * keeps for its fields. The blob lives on its own `User` column * (`module_list_layout_json`). * - * Both halves are the caller's own presentation. Inside somebody else's - * record the ids in a saved order simply do not match and the list keeps the - * server order, so no record gate is needed here. + * Both halves are the caller's own presentation, so this is an actor surface + * (`requireActorAuth`): a browser acting for another record still reads and + * saves the person's own layout instead of being refused. Inside somebody + * else's record the ids in a saved order simply do not match and the list + * keeps the server order, so no record gate is needed here. + * + * The PUT reads and writes the blob under a row lock in one transaction, so + * two saves for different lists (two tabs, a quick view switch and a reorder) + * cannot each start from the same stored value and drop the other's change. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireActorAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; import { prisma, toJson } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -25,8 +31,11 @@ import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs"; export const dynamic = "force-dynamic"; -async function readStored(userId: string): Promise { - const row = await prisma.user.findUnique({ +async function readStored( + userId: string, + db: Pick = prisma, +): Promise { + const row = await db.user.findUnique({ where: { id: userId }, select: { moduleListLayoutJson: true }, }); @@ -34,7 +43,7 @@ async function readStored(userId: string): Promise { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireActorAuth(); const layout = resolveModuleListLayout(await readStored(user.id)); annotate({ action: { name: "settings.module-list-layout.read" }, @@ -46,7 +55,7 @@ export const GET = apiHandler(async () => { }); export const PUT = apiHandler(async (request: Request) => { - const { user } = await requireAuth(); + const { user } = await requireActorAuth(); // 500 ids of up to 64 characters plus the envelope. const { data: body, error: jsonError } = await safeJson(request, { @@ -66,18 +75,21 @@ export const PUT = apiHandler(async (request: Request) => { // Preserve-when-absent: one stored read fills whichever field the body // left out, and every other list stays as stored. Only fields a person has // chosen are written, so a field never picked keeps following the default. - const stored = storedModuleListLayout(await readStored(user.id)); - const nextChoices = { - ...stored[list], - ...(view ? { view } : {}), - ...(order ? { order: [...new Set(order)] } : {}), - ...(sortDir ? { sortDir } : {}), - }; - const nextStored = { ...stored, [list]: nextChoices }; - - await prisma.user.update({ - where: { id: user.id }, - data: { moduleListLayoutJson: toJson({ version: 1, ...nextStored }) }, + const nextStored = await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`; + const stored = storedModuleListLayout(await readStored(user.id, tx)); + const nextChoices = { + ...stored[list], + ...(view ? { view } : {}), + ...(order ? { order: [...new Set(order)] } : {}), + ...(sortDir ? { sortDir } : {}), + }; + const next = { ...stored, [list]: nextChoices }; + await tx.user.update({ + where: { id: user.id }, + data: { moduleListLayoutJson: toJson({ version: 1, ...next }) }, + }); + return next; }); const layout = resolveModuleListLayout(nextStored); diff --git a/src/app/api/settings/preview-features/route.ts b/src/app/api/settings/preview-features/route.ts index 1b48c75fa..408b40a2f 100644 --- a/src/app/api/settings/preview-features/route.ts +++ b/src/app/api/settings/preview-features/route.ts @@ -5,8 +5,13 @@ * the flags the body names and keeps the rest; unknown flags are refused * with the multi-issue 422 so a typo cannot silently do nothing. The blob * lives on its own `User` column (`preview_features_json`). + * + * The opt-ins are the caller's own, so this is an actor surface + * (`requireActorAuth`): a browser acting for another record keeps reaching + * the person's own switch. The PATCH reads and writes under a row lock, so two + * flags switched at once both stay switched. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireActorAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; import { prisma, toJson } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -19,8 +24,11 @@ import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs"; export const dynamic = "force-dynamic"; -async function readStored(userId: string): Promise { - const row = await prisma.user.findUnique({ +async function readStored( + userId: string, + db: Pick = prisma, +): Promise { + const row = await db.user.findUnique({ where: { id: userId }, select: { previewFeaturesJson: true }, }); @@ -32,7 +40,7 @@ function enabledFlags(features: PreviewFeatures): string[] { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireActorAuth(); const features = resolvePreviewFeatures(await readStored(user.id)); annotate({ action: { name: "settings.preview-features.read" }, @@ -42,7 +50,7 @@ export const GET = apiHandler(async () => { }); export const PATCH = apiHandler(async (request: Request) => { - const { user } = await requireAuth(); + const { user } = await requireActorAuth(); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 1024, @@ -57,14 +65,17 @@ export const PATCH = apiHandler(async (request: Request) => { return returnAllZodIssues(parsed.error, 422); } - const current = resolvePreviewFeatures(await readStored(user.id)); - const next: PreviewFeatures = { - topics: parsed.data.topics ?? current.topics, - }; - - await prisma.user.update({ - where: { id: user.id }, - data: { previewFeaturesJson: toJson(next) }, + const next = await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`; + const current = resolvePreviewFeatures(await readStored(user.id, tx)); + const merged: PreviewFeatures = { + topics: parsed.data.topics ?? current.topics, + }; + await tx.user.update({ + where: { id: user.id }, + data: { previewFeaturesJson: toJson(merged) }, + }); + return merged; }); annotate({ diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts index 5dcc4d7c5..61aaa5a00 100644 --- a/tests/integration/settings-prefs-routes.test.ts +++ b/tests/integration/settings-prefs-routes.test.ts @@ -15,7 +15,7 @@ process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -214,3 +214,111 @@ describe("/api/settings/preview-features — integration", () => { expect(empty.status).toBe(422); }); }); + +describe("settings presentation follows the person, also while acting for a record", () => { + const LAYOUT = "http://localhost/api/settings/module-list-layout"; + const PREVIEW = "http://localhost/api/settings/preview-features"; + + async function actingForAnotherRecord() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "prefs-record-owner", + email: "prefs-owner@example.test", + role: "USER", + }, + }); + const actor = await prisma.user.create({ + data: { + username: "prefs-delegate", + email: "prefs-delegate@example.test", + role: "USER", + }, + }); + await prisma.accountGrant.create({ + data: { + grantorId: owner.id, + granteeId: actor.id, + access: "WRITE", + acceptedAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { userId: actor.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, owner.id); + return { ownerId: owner.id, actorId: actor.id }; + } + + it("reads and saves the actor's own layout and preview switch", async () => { + const { ownerId, actorId } = await actingForAnotherRecord(); + const layout = await import("@/app/api/settings/module-list-layout/route"); + const preview = await import("@/app/api/settings/preview-features/route"); + + expect((await (layout.GET as Handler)(new Request(LAYOUT))).status).toBe( + 200, + ); + const put = await (layout.PUT as Handler)( + json("PUT", LAYOUT, { version: 1, list: "labs", view: "cards" }), + ); + expect(put.status).toBe(200); + expect((await (preview.GET as Handler)(new Request(PREVIEW))).status).toBe( + 200, + ); + const patch = await (preview.PATCH as Handler)( + json("PATCH", PREVIEW, { topics: true }), + ); + expect(patch.status).toBe(200); + + const rows = await getPrismaClient().user.findMany({ + where: { id: { in: [ownerId, actorId] } }, + select: { + id: true, + moduleListLayoutJson: true, + previewFeaturesJson: true, + }, + }); + const actor = rows.find((r) => r.id === actorId)!; + const owner = rows.find((r) => r.id === ownerId)!; + expect(actor.moduleListLayoutJson).toMatchObject({ + labs: { view: "cards" }, + }); + expect(actor.previewFeaturesJson).toEqual({ topics: true }); + expect(owner.moduleListLayoutJson).toBeNull(); + expect(owner.previewFeaturesJson).toBeNull(); + }); +}); + +describe("layout saves for different lists do not overwrite each other", () => { + const URL = "http://localhost/api/settings/module-list-layout"; + + it("keeps every list a burst of concurrent saves touched", async () => { + const { userId } = await seedUser(); + const { PUT } = await import("@/app/api/settings/module-list-layout/route"); + const lists = ["labs", "illness", "vorsorge"] as const; + await Promise.all( + Array.from({ length: 9 }, (_, i) => + (PUT as Handler)( + json("PUT", URL, { + version: 1, + list: lists[i % 3], + ...(i < 3 ? { view: "cards" } : { order: [`item-${i}`] }), + }), + ), + ), + ); + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { moduleListLayoutJson: true }, + }); + const stored = row.moduleListLayoutJson as Record< + string, + { view?: string; order?: string[] } + >; + for (const list of lists) { + expect(stored[list]?.view, list).toBe("cards"); + expect(stored[list]?.order, list).toBeDefined(); + } + }); +}); From b25ee09f7dd71dd0f8540582b3127e802db69fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:50:13 +0200 Subject: [PATCH 40/98] fix(settings): keep moved checkup and session cards reachable in context The checkups page links to the checkup reminders under Notifications and the screening hints under the health profile, the two cards that left its settings in this release. Data & privacy gets a row pointing at sessions and devices under Security. --- messages/de.json | 5 +++ messages/en.json | 5 +++ messages/es.json | 5 +++ messages/fr.json | 5 +++ messages/it.json | 5 +++ messages/ko.json | 5 +++ messages/nl.json | 5 +++ messages/pl.json | 5 +++ .../__tests__/vorsorge-section.test.tsx | 10 ++++++ .../vorsorge-section.tsx | 33 ++++++++++++++++++- .../__tests__/privacy-section.test.tsx | 2 ++ src/components/settings/privacy-section.tsx | 20 +++++++++++ 12 files changed, 104 insertions(+), 1 deletion(-) diff --git a/messages/de.json b/messages/de.json index 925dd1686..b15ed4084 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7239,6 +7239,11 @@ "title": "Datenschutz-Einstellung", "description": "Steuere, wie viele deiner Daten an KI-Funktionen weitergegeben werden.", "openAi": "KI-Datenschutzmodus" + }, + "sessions": { + "title": "Sitzungen und Geräte", + "description": "Wo du angemeldet bist, welche Geräte den zweiten Faktor überspringen, und die Sicherheitsaktivität deines Kontos.", + "open": "Sicherheit öffnen" } }, "mcp": { diff --git a/messages/en.json b/messages/en.json index 275e51c39..288b95b00 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7239,6 +7239,11 @@ "title": "Privacy posture", "description": "Control how much of your data is shared with AI features.", "openAi": "AI privacy mode" + }, + "sessions": { + "title": "Sessions and devices", + "description": "Where you are signed in, the devices that skip the second factor, and the security activity of your account.", + "open": "Open security" } }, "mcp": { diff --git a/messages/es.json b/messages/es.json index 2b08e49a8..3fc3cfcab 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7239,6 +7239,11 @@ "title": "Configuración de privacidad", "description": "Controla cuántos de tus datos se comparten con las funciones de IA.", "openAi": "Modo de privacidad de IA" + }, + "sessions": { + "title": "Sesiones y dispositivos", + "description": "Dónde tienes la sesión iniciada, qué dispositivos omiten el segundo factor y la actividad de seguridad de tu cuenta.", + "open": "Abrir seguridad" } }, "mcp": { diff --git a/messages/fr.json b/messages/fr.json index 7df4a74fe..3121be5a9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7239,6 +7239,11 @@ "title": "Posture de confidentialité", "description": "Contrôlez la quantité de données partagées avec les fonctions d'IA.", "openAi": "Mode confidentialité IA" + }, + "sessions": { + "title": "Sessions et appareils", + "description": "Où tu es connecté, les appareils qui sautent le second facteur et l'activité de sécurité de ton compte.", + "open": "Ouvrir la sécurité" } }, "mcp": { diff --git a/messages/it.json b/messages/it.json index 6dd7ff8a7..aa59f96eb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7239,6 +7239,11 @@ "title": "Impostazione privacy", "description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA.", "openAi": "Modalità privacy IA" + }, + "sessions": { + "title": "Sessioni e dispositivi", + "description": "Dove hai effettuato l'accesso, i dispositivi che saltano il secondo fattore e l'attività di sicurezza del tuo account.", + "open": "Apri sicurezza" } }, "mcp": { diff --git a/messages/ko.json b/messages/ko.json index 5b88aee4e..3356413fa 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7239,6 +7239,11 @@ "title": "개인정보 보호 수준", "description": "AI 기능에 데이터를 어디까지 공유할지 정해요.", "openAi": "AI 개인정보 모드" + }, + "sessions": { + "title": "세션 및 기기", + "description": "로그인된 위치, 2단계 인증을 건너뛰는 기기, 계정의 보안 활동을 확인해요.", + "open": "보안 열기" } }, "mcp": { diff --git a/messages/nl.json b/messages/nl.json index 72dc4a3d4..8f8e2aaee 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -7239,6 +7239,11 @@ "title": "Privacyniveau", "description": "Bepaal hoeveel van je gegevens met AI-functies wordt gedeeld.", "openAi": "AI-privacymodus" + }, + "sessions": { + "title": "Sessies en apparaten", + "description": "Waar je bent ingelogd, welke apparaten de tweede factor overslaan en de beveiligingsactiviteit van je account.", + "open": "Beveiliging openen" } }, "mcp": { diff --git a/messages/pl.json b/messages/pl.json index f1b04e0bc..2e6a1501f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7239,6 +7239,11 @@ "title": "Ustawienia prywatności", "description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI.", "openAi": "Tryb prywatności AI" + }, + "sessions": { + "title": "Sesje i urządzenia", + "description": "Gdzie jesteś zalogowany, które urządzenia pomijają drugi składnik i aktywność zabezpieczeń twojego konta.", + "open": "Otwórz zabezpieczenia" } }, "mcp": { diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 72d5004fa..cd520529c 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -114,6 +114,16 @@ describe(" loading + empty", () => { expect(html).not.toContain('data-slot="vorsorge-loading"'); }); + it("links the checkups page to the reminder and screening settings that moved", () => { + remindersMock.mockReturnValue({ data: [], isLoading: false }); + const page = render(); + expect(page).toContain('href="/settings/notifications#vorsorge-reminders"'); + expect(page).toContain('href="/settings/anamnesis#screening-hints"'); + // The embedded settings card is already in Settings and carries no links. + const embedded = render(); + expect(embedded).not.toContain('data-slot="vorsorge-settings-links"'); + }); + it("renders reminder cards once data lands", () => { const reminder: MeasurementReminder = { id: "r1", diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index d813ebbcd..654cc6f9d 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -465,7 +465,38 @@ export function VorsorgeSection({ } /> - ) : ( + ) : null} + {variant === "page" && !inSharedRecord ? ( + // The reminder switches and the screening hints moved out of the + // checkups settings in v1.44 (Notifications, Health profile); both + // stay one tap away from the page they are about. +
+ + +
+ ) : null} + {variant === "page" ? null : ( — assembled Data & Privacy dashboard", () => { expect(html).not.toContain("settings-security-sessions-card"); expect(html).not.toContain("settings-trusted-devices-card"); expect(html).not.toContain('href="/settings/export"'); + // ...but stay one tap away from here, where people looked for them. + expect(html).toContain('href="/settings/security#sessions"'); }); it("shows a loading placeholder for the retention block before the summary resolves", () => { diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index c0653da6a..852dbb4ce 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -23,6 +23,7 @@ import { Clock, Sparkles, Info, + MonitorSmartphone, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -180,6 +181,25 @@ export function PrivacySection() { )} + {/* Sessions, trusted devices and the security activity live under + Security since v1.44; the row keeps them reachable from here, where + people looked for them before. */} + + + + + + + {/* Delete / reset — with the honest backup lag disclosure */} Date: Wed, 21 Oct 2026 21:50:27 +0200 Subject: [PATCH 41/98] feat(medications): show the change at the start as a segmented fit The efficacy tab keeps its before/after comparison and gains, per metric target, the level change and the change in weekly trend at the start, each with a 95 % interval. The fit covers up to 90 days before and 180 after the start and ends before the first dose change. After the start, days are left out when fewer than 80 % of the doses of the preceding 14 days were taken (cadence-aware, from the dose ledger) or when the medication was paused or between courses. Other medications that started, stopped, changed dose or paused inside the window are listed. Below 14 days with readings across 21 days on either side the view says what is missing instead of showing numbers. The efficacy response gains a nullable segmented block per target. --- docs/api/openapi.yaml | 211 +++++++++++ messages/de.json | 25 +- messages/en.json | 25 +- messages/es.json | 25 +- messages/fr.json | 25 +- messages/it.json | 25 +- messages/ko.json | 25 +- messages/nl.json | 25 +- messages/pl.json | 25 +- .../change-at-start-summary.test.tsx | 88 +++++ .../efficacy/change-at-start-summary.tsx | 155 ++++++++ .../detail/efficacy/efficacy-tab.tsx | 14 + .../__tests__/segmented-effect.test.ts | 208 +++++++++++ .../medications/efficacy/build-efficacy.ts | 43 ++- .../efficacy/load-segmented-effects.ts | 247 +++++++++++++ .../medications/efficacy/segmented-effect.ts | 345 ++++++++++++++++++ .../medications/efficacy/segmented-format.ts | 38 ++ src/lib/validations/medication-efficacy.ts | 76 ++++ 18 files changed, 1607 insertions(+), 18 deletions(-) create mode 100644 src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx create mode 100644 src/components/medications/detail/efficacy/change-at-start-summary.tsx create mode 100644 src/lib/medications/efficacy/__tests__/segmented-effect.test.ts create mode 100644 src/lib/medications/efficacy/load-segmented-effects.ts create mode 100644 src/lib/medications/efficacy/segmented-effect.ts create mode 100644 src/lib/medications/efficacy/segmented-format.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 339c7b7c5..a5bea3f73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -52711,6 +52711,216 @@ components: - present additionalProperties: false - type: "null" + segmented: + anyOf: + - type: object + properties: + present: + type: boolean + reason: + type: string + enum: + - no_start + - insufficient_before + - insufficient_after + - fit_failed + level: + type: number + window: + anyOf: + - type: object + properties: + from: + type: string + to: + type: string + endReason: + type: string + enum: + - today + - window + - dose_change + required: + - from + - to + - endReason + additionalProperties: false + - type: "null" + days: + type: object + properties: + before: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + after: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + excludedLowAdherence: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + excludedNotTaking: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - before + - after + - excludedLowAdherence + - excludedNotTaking + additionalProperties: false + adherence: + type: object + properties: + status: + type: string + enum: + - tracked + - not_tracked + floorPct: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lookbackDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - floorPct + - lookbackDays + additionalProperties: false + levelChange: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + slopeChangePerWeek: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + preSlopePerWeek: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + fit: + anyOf: + - type: object + properties: + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + df: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + rSquared: + type: number + residualAutocorrelation: + type: number + hacLag: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - n + - df + - rSquared + - residualAutocorrelation + - hacLag + additionalProperties: false + - type: "null" + confounders: + type: array + items: + type: object + properties: + medicationName: + type: string + event: + type: string + enum: + - started + - stopped + - dose_change + - paused + - resumed + date: + type: string + required: + - medicationName + - event + - date + additionalProperties: false + required: + - present + - level + - window + - days + - adherence + - levelChange + - slopeChangePerWeek + - preSlopePerWeek + - fit + - confounders + additionalProperties: false + - type: "null" required: - kind - key @@ -52721,6 +52931,7 @@ components: - series - beforeAfter - levelShift + - segmented additionalProperties: false adherence: type: array diff --git a/messages/de.json b/messages/de.json index a72dcb006..365d2274c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absolut", "scalePercentSinceStart": "% seit Start", - "scaleToggleLabel": "Diagrammskala" + "scaleToggleLabel": "Diagrammskala", + "segmented": { + "title": "Veränderung zum Start", + "level": "Direkt zum Start hat sich das Niveau um {value} {unit} verschoben (95-%-Bereich {low} bis {high}).", + "slope": "Nach dem Start hat sich der Wochentrend um {value} {unit} pro Woche verändert (95-%-Bereich {low} bis {high}).", + "preSlope": "Vor dem Start lag der Trend bei {value} {unit} pro Woche.", + "includesZero": "Wo ein Bereich die Null einschließt, passen diese Werte auch zu gar keiner Veränderung.", + "basis": "Grundlage: {before} Tage vor und {after} Tage nach dem Start ({from} bis {to}).", + "excludedAdherence": "{count} Tage nach dem Start wurden ausgelassen, weil in den {lookback} Tagen davor weniger als {floor} % der Dosen eingenommen wurden.", + "excludedNotTaking": "{count} Tage wurden ausgelassen, weil das Medikament pausiert war oder zwischen zwei Einnahmezeiträumen lag.", + "notTracked": "Für dieses Medikament wird die Einnahme nicht erfasst, deshalb wurden keine Tage wegen ausgelassener Dosen entfernt.", + "endDoseChange": "Der Zeitraum endet vor der ersten Dosisänderung und beschreibt deshalb die Anfangsdosis.", + "confoundersTitle": "Weitere Medikamentenänderungen in diesem Zeitraum, die diese Ansicht nicht von diesem Medikament trennen kann:", + "confounderStarted": "{name} begonnen am {date}", + "confounderStopped": "{name} beendet am {date}", + "confounderDoseChange": "{name}: Dosis geändert am {date}", + "confounderPaused": "{name} pausiert am {date}", + "confounderResumed": "{name} fortgesetzt am {date}", + "method": "So wird gerechnet: Eine Gerade wird durch die Tageswerte vor dem Start gelegt, eine zweite durch die danach. Der Sprung zwischen beiden am Start ist die Niveauänderung, der Unterschied ihrer Steigungen die Trendänderung. Die Bereiche berücksichtigen, dass Werte von einem Tag auf den nächsten nachwirken.", + "needStart": "Lege ein Startdatum fest, um die Veränderung zum Start zu sehen.", + "needBefore": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage vor dem Start.", + "needAfter": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage seit dem Start und an Tagen mit eingenommenen Dosen.", + "fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt." + } }, "import": { "resultSuccess": "{imported} Einnahmen importiert", diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..4881ff789 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absolute", "scalePercentSinceStart": "% since start", - "scaleToggleLabel": "Chart scale" + "scaleToggleLabel": "Chart scale", + "segmented": { + "title": "Change at the start", + "level": "Right at the start, the level moved by {value} {unit} (95 % range {low} to {high}).", + "slope": "After the start, the weekly trend changed by {value} {unit} per week (95 % range {low} to {high}).", + "preSlope": "Before the start, the trend was {value} {unit} per week.", + "includesZero": "Where a range includes zero, these readings are also consistent with no change.", + "basis": "Based on {before} days before and {after} days after the start ({from} to {to}).", + "excludedAdherence": "{count} days after the start were left out because fewer than {floor} % of the doses in the {lookback} days before them were taken.", + "excludedNotTaking": "{count} days were left out because the medication was paused or between courses.", + "notTracked": "Intake is not tracked for this medication, so no days were left out for missed doses.", + "endDoseChange": "The window ends before the first dose change, so this describes the starting dose.", + "confoundersTitle": "Other medication changes in this window, which this view cannot separate from this one:", + "confounderStarted": "{name} started on {date}", + "confounderStopped": "{name} stopped on {date}", + "confounderDoseChange": "{name}: dose changed on {date}", + "confounderPaused": "{name} paused on {date}", + "confounderResumed": "{name} resumed on {date}", + "method": "How this is worked out: one line is fitted to the daily values before the start and one after it. The jump between them at the start is the level change, the difference in their slopes the trend change. The ranges allow for readings that carry over from one day to the next.", + "needStart": "Set a start date to see the change at the start.", + "needBefore": "The change at the start needs readings on at least {days} days spread over {span} days before the start.", + "needAfter": "The change at the start needs readings on at least {days} days spread over {span} days since the start, on days the doses were taken.", + "fitFailed": "These readings could not be fitted, so no change at the start is shown." + } }, "import": { "resultSuccess": "{imported} intakes imported", diff --git a/messages/es.json b/messages/es.json index 3239be811..449ea8372 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absoluto", "scalePercentSinceStart": "% desde el inicio", - "scaleToggleLabel": "Escala del gráfico" + "scaleToggleLabel": "Escala del gráfico", + "segmented": { + "title": "Cambio al inicio", + "level": "Justo al inicio, el nivel se desplazó {value} {unit} (intervalo del 95 %: {low} a {high}).", + "slope": "Después del inicio, la tendencia semanal cambió {value} {unit} por semana (intervalo del 95 %: {low} a {high}).", + "preSlope": "Antes del inicio, la tendencia era de {value} {unit} por semana.", + "includesZero": "Cuando un intervalo incluye el cero, estos valores también son compatibles con ningún cambio.", + "basis": "Basado en {before} días antes y {after} días después del inicio ({from} a {to}).", + "excludedAdherence": "Se omitieron {count} días después del inicio porque en los {lookback} días anteriores se tomó menos del {floor} % de las dosis.", + "excludedNotTaking": "Se omitieron {count} días porque el medicamento estaba en pausa o entre dos tandas.", + "notTracked": "La toma de este medicamento no se registra, así que no se omitió ningún día por dosis olvidadas.", + "endDoseChange": "El periodo termina antes del primer cambio de dosis, así que describe la dosis inicial.", + "confoundersTitle": "Otros cambios de medicación en este periodo, que esta vista no puede separar de este:", + "confounderStarted": "{name} iniciado el {date}", + "confounderStopped": "{name} finalizado el {date}", + "confounderDoseChange": "{name}: dosis cambiada el {date}", + "confounderPaused": "{name} en pausa desde el {date}", + "confounderResumed": "{name} reanudado el {date}", + "method": "Cómo se calcula: se ajusta una recta a los valores diarios antes del inicio y otra a los posteriores. El salto entre ambas al inicio es el cambio de nivel y la diferencia de sus pendientes, el cambio de tendencia. Los intervalos tienen en cuenta que los valores de un día influyen en el siguiente.", + "needStart": "Indica una fecha de inicio para ver el cambio al inicio.", + "needBefore": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días antes del inicio.", + "needAfter": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días desde el inicio, en días con las dosis tomadas.", + "fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio." + } }, "import": { "resultSuccess": "{imported} tomas importadas", diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..d9c64674a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absolu", "scalePercentSinceStart": "% depuis le début", - "scaleToggleLabel": "Échelle du graphique" + "scaleToggleLabel": "Échelle du graphique", + "segmented": { + "title": "Changement au démarrage", + "level": "Dès le démarrage, le niveau s'est déplacé de {value} {unit} (intervalle à 95 % : {low} à {high}).", + "slope": "Après le démarrage, la tendance hebdomadaire a changé de {value} {unit} par semaine (intervalle à 95 % : {low} à {high}).", + "preSlope": "Avant le démarrage, la tendance était de {value} {unit} par semaine.", + "includesZero": "Lorsqu'un intervalle inclut zéro, ces valeurs sont aussi compatibles avec une absence de changement.", + "basis": "Sur la base de {before} jours avant et {after} jours après le démarrage ({from} au {to}).", + "excludedAdherence": "{count} jours après le démarrage ont été écartés, car moins de {floor} % des doses ont été prises dans les {lookback} jours précédents.", + "excludedNotTaking": "{count} jours ont été écartés, car le médicament était en pause ou entre deux périodes de prise.", + "notTracked": "Les prises de ce médicament ne sont pas suivies, aucun jour n'a donc été écarté pour des doses manquées.", + "endDoseChange": "La période s'arrête avant le premier changement de dose et décrit donc la dose de départ.", + "confoundersTitle": "Autres changements de médicaments sur cette période, que cette vue ne peut pas distinguer de celui-ci :", + "confounderStarted": "{name} commencé le {date}", + "confounderStopped": "{name} arrêté le {date}", + "confounderDoseChange": "{name} : dose modifiée le {date}", + "confounderPaused": "{name} mis en pause le {date}", + "confounderResumed": "{name} repris le {date}", + "method": "Méthode : une droite est ajustée aux valeurs quotidiennes avant le démarrage, une autre à celles d'après. Le saut entre les deux au démarrage est le changement de niveau, l'écart de leurs pentes le changement de tendance. Les intervalles tiennent compte du fait que les valeurs d'un jour influencent le lendemain.", + "needStart": "Indique une date de début pour voir le changement au démarrage.", + "needBefore": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours avant le démarrage.", + "needAfter": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours depuis le démarrage, les jours où les doses ont été prises.", + "fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché." + } }, "import": { "resultSuccess": "{imported} prises importées", diff --git a/messages/it.json b/messages/it.json index 85e890708..15be9a0fa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Assoluto", "scalePercentSinceStart": "% dall'inizio", - "scaleToggleLabel": "Scala del grafico" + "scaleToggleLabel": "Scala del grafico", + "segmented": { + "title": "Variazione all'inizio", + "level": "Subito all'inizio il livello si è spostato di {value} {unit} (intervallo al 95 %: da {low} a {high}).", + "slope": "Dopo l'inizio la tendenza settimanale è cambiata di {value} {unit} a settimana (intervallo al 95 %: da {low} a {high}).", + "preSlope": "Prima dell'inizio la tendenza era di {value} {unit} a settimana.", + "includesZero": "Quando un intervallo include lo zero, questi valori sono compatibili anche con nessuna variazione.", + "basis": "Basato su {before} giorni prima e {after} giorni dopo l'inizio (dal {from} al {to}).", + "excludedAdherence": "{count} giorni dopo l'inizio sono stati esclusi perché nei {lookback} giorni precedenti è stato assunto meno del {floor} % delle dosi.", + "excludedNotTaking": "{count} giorni sono stati esclusi perché il farmaco era in pausa o tra due cicli.", + "notTracked": "L'assunzione di questo farmaco non viene registrata, quindi nessun giorno è stato escluso per dosi saltate.", + "endDoseChange": "Il periodo termina prima della prima variazione di dose, quindi descrive la dose iniziale.", + "confoundersTitle": "Altre variazioni di farmaci in questo periodo, che questa vista non può separare da questo:", + "confounderStarted": "{name} iniziato il {date}", + "confounderStopped": "{name} terminato il {date}", + "confounderDoseChange": "{name}: dose modificata il {date}", + "confounderPaused": "{name} sospeso il {date}", + "confounderResumed": "{name} ripreso il {date}", + "method": "Come si calcola: una retta viene adattata ai valori giornalieri prima dell'inizio e un'altra a quelli successivi. Il salto tra le due all'inizio è la variazione di livello, la differenza delle pendenze la variazione di tendenza. Gli intervalli tengono conto del fatto che i valori di un giorno influenzano il successivo.", + "needStart": "Imposta una data di inizio per vedere la variazione all'inizio.", + "needBefore": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni prima dell'inizio.", + "needAfter": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni dall'inizio, nei giorni con le dosi assunte.", + "fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio." + } }, "import": { "resultSuccess": "{imported} assunzioni importate", diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..d943eb2f3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "절대값", "scalePercentSinceStart": "시작 이후 %", - "scaleToggleLabel": "차트 눈금" + "scaleToggleLabel": "차트 눈금", + "segmented": { + "title": "시작 시점의 변화", + "level": "시작 직후 수준이 {value} {unit} 이동했습니다(95% 범위 {low}~{high}).", + "slope": "시작 후 주간 추세가 주당 {value} {unit} 변했습니다(95% 범위 {low}~{high}).", + "preSlope": "시작 전 추세는 주당 {value} {unit}였습니다.", + "includesZero": "범위에 0이 포함되면 이 측정값은 변화가 없다는 것과도 일치합니다.", + "basis": "시작 전 {before}일과 시작 후 {after}일 기준({from}~{to}).", + "excludedAdherence": "직전 {lookback}일 동안 복용한 용량이 {floor}% 미만이어서 시작 후 {count}일을 제외했습니다.", + "excludedNotTaking": "약을 일시 중지했거나 복용 기간 사이였던 {count}일을 제외했습니다.", + "notTracked": "이 약은 복용을 기록하지 않으므로 놓친 용량 때문에 제외한 날이 없습니다.", + "endDoseChange": "기간이 첫 용량 변경 전에 끝나므로 시작 용량을 나타냅니다.", + "confoundersTitle": "이 기간의 다른 약 변경 사항으로, 이 화면에서는 이 약과 구분할 수 없습니다.", + "confounderStarted": "{name} {date} 시작", + "confounderStopped": "{name} {date} 중단", + "confounderDoseChange": "{name}: {date} 용량 변경", + "confounderPaused": "{name} {date} 일시 중지", + "confounderResumed": "{name} {date} 재개", + "method": "계산 방법: 시작 전 일별 값에 직선 하나를, 시작 후 값에 또 하나를 맞춥니다. 시작 시점의 두 직선 사이 간격이 수준 변화이고, 기울기 차이가 추세 변화입니다. 범위는 하루의 값이 다음 날로 이어지는 점을 반영합니다.", + "needStart": "시작 시점의 변화를 보려면 시작일을 설정하세요.", + "needBefore": "시작 시점의 변화를 보려면 시작 전 {span}일에 걸쳐 최소 {days}일의 측정값이 필요합니다.", + "needAfter": "시작 시점의 변화를 보려면 시작 후 {span}일에 걸쳐, 용량을 복용한 날 기준으로 최소 {days}일의 측정값이 필요합니다.", + "fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다." + } }, "import": { "resultSuccess": "복용 {imported}건을 불러왔어요", diff --git a/messages/nl.json b/messages/nl.json index c44560507..2bd95f72c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Absoluut", "scalePercentSinceStart": "% sinds start", - "scaleToggleLabel": "Schaal van grafiek" + "scaleToggleLabel": "Schaal van grafiek", + "segmented": { + "title": "Verandering bij de start", + "level": "Direct bij de start verschoof het niveau met {value} {unit} (95%-bereik {low} tot {high}).", + "slope": "Na de start veranderde de weektrend met {value} {unit} per week (95%-bereik {low} tot {high}).", + "preSlope": "Voor de start was de trend {value} {unit} per week.", + "includesZero": "Waar een bereik nul omvat, passen deze waarden ook bij geen verandering.", + "basis": "Gebaseerd op {before} dagen voor en {after} dagen na de start ({from} tot {to}).", + "excludedAdherence": "{count} dagen na de start zijn weggelaten, omdat in de {lookback} dagen ervoor minder dan {floor}% van de doses is ingenomen.", + "excludedNotTaking": "{count} dagen zijn weggelaten, omdat het medicijn gepauzeerd was of tussen twee kuren lag.", + "notTracked": "De inname van dit medicijn wordt niet bijgehouden, dus er zijn geen dagen weggelaten om gemiste doses.", + "endDoseChange": "De periode eindigt vóór de eerste dosiswijziging en beschrijft dus de startdosis.", + "confoundersTitle": "Andere medicijnwijzigingen in deze periode, die deze weergave niet van dit medicijn kan scheiden:", + "confounderStarted": "{name} gestart op {date}", + "confounderStopped": "{name} gestopt op {date}", + "confounderDoseChange": "{name}: dosis gewijzigd op {date}", + "confounderPaused": "{name} gepauzeerd op {date}", + "confounderResumed": "{name} hervat op {date}", + "method": "Zo wordt het berekend: door de dagwaarden vóór de start wordt een lijn gelegd en een tweede door die erna. De sprong tussen beide bij de start is de niveauverandering, het verschil in hun helling de trendverandering. De bereiken houden er rekening mee dat waarden van de ene dag doorwerken in de volgende.", + "needStart": "Stel een startdatum in om de verandering bij de start te zien.", + "needBefore": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen vóór de start.", + "needAfter": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen sinds de start en op dagen waarop de doses zijn ingenomen.", + "fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond." + } }, "import": { "resultSuccess": "{imported} innames geïmporteerd", diff --git a/messages/pl.json b/messages/pl.json index c1812715f..2467c66a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2359,7 +2359,30 @@ }, "scaleAbsolute": "Bezwzględne", "scalePercentSinceStart": "% od początku", - "scaleToggleLabel": "Skala wykresu" + "scaleToggleLabel": "Skala wykresu", + "segmented": { + "title": "Zmiana na początku", + "level": "Zaraz na początku poziom przesunął się o {value} {unit} (zakres 95%: od {low} do {high}).", + "slope": "Po rozpoczęciu trend tygodniowy zmienił się o {value} {unit} na tydzień (zakres 95%: od {low} do {high}).", + "preSlope": "Przed rozpoczęciem trend wynosił {value} {unit} na tydzień.", + "includesZero": "Gdy zakres obejmuje zero, te pomiary są zgodne także z brakiem zmiany.", + "basis": "Na podstawie {before} dni przed i {after} dni po rozpoczęciu (od {from} do {to}).", + "excludedAdherence": "Pominięto {count} dni po rozpoczęciu, bo w ciągu {lookback} dni przed nimi przyjęto mniej niż {floor}% dawek.", + "excludedNotTaking": "Pominięto {count} dni, bo lek był wstrzymany albo przypadały między kuracjami.", + "notTracked": "Przyjmowanie tego leku nie jest rejestrowane, więc nie pominięto żadnych dni z powodu opuszczonych dawek.", + "endDoseChange": "Okres kończy się przed pierwszą zmianą dawki, więc opisuje dawkę początkową.", + "confoundersTitle": "Inne zmiany leków w tym okresie, których ten widok nie potrafi oddzielić od tego leku:", + "confounderStarted": "{name} rozpoczęty {date}", + "confounderStopped": "{name} zakończony {date}", + "confounderDoseChange": "{name}: zmiana dawki {date}", + "confounderPaused": "{name} wstrzymany {date}", + "confounderResumed": "{name} wznowiony {date}", + "method": "Jak to jest liczone: przez dzienne wartości sprzed rozpoczęcia prowadzi się jedną prostą, a przez te późniejsze drugą. Skok między nimi w dniu rozpoczęcia to zmiana poziomu, różnica ich nachyleń to zmiana trendu. Zakresy uwzględniają, że wartości z jednego dnia przenoszą się na następny.", + "needStart": "Ustaw datę rozpoczęcia, aby zobaczyć zmianę na początku.", + "needBefore": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni przed rozpoczęciem.", + "needAfter": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni od rozpoczęcia, w dni z przyjętymi dawkami.", + "fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana." + } }, "import": { "resultSuccess": "Zaimportowano przyjęcia: {imported}", diff --git a/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx new file mode 100644 index 000000000..b9fa7eb63 --- /dev/null +++ b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import enMessages from "../../../../../../messages/en.json"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; +import { ChangeAtStartSummary } from "../change-at-start-summary"; + +function est(estimate: number, se: number) { + const ciLow = estimate - 2 * se; + const ciHigh = estimate + 2 * se; + return { + estimate, + ciLow, + ciHigh, + standardError: se, + includesZero: ciLow <= 0 && ciHigh >= 0, + }; +} + +const present: EfficacySegmented = { + present: true, + level: 0.95, + window: { from: "2026-01-01", to: "2026-05-30", endReason: "dose_change" }, + days: { + before: 80, + after: 70, + excludedLowAdherence: 6, + excludedNotTaking: 0, + }, + adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 }, + levelChange: est(-7.34, 0.6), + slopeChangePerWeek: est(-0.26, 0.16), + preSlopePerWeek: est(0.05, 0.1), + fit: { + n: 150, + df: 146, + rSquared: 0.6, + residualAutocorrelation: 0.3, + hacLag: 4, + }, + confounders: [ + { medicationName: "Example B", event: "started", date: "2026-02-10" }, + ], +}; + +function render(segmented: EfficacySegmented) { + return renderToStaticMarkup( + + + , + ); +} + +describe("ChangeAtStartSummary", () => { + it("states the level and trend change with their ranges in plain words", () => { + const html = render(present); + expect(html).toContain("the level moved by -7.3 mmHg"); + expect(html).toContain("95 % range -8.5 to -6.1"); + expect(html).toContain("changed by -0.3 mmHg per week"); + // The slope interval spans zero, so the no-change reading is said aloud. + expect(html).toContain("also consistent with no change"); + expect(html).toContain("Based on 80 days before and 70 days after"); + expect(html).toContain("6 days after the start were left out"); + expect(html).toContain("describes the starting dose"); + expect(html).toContain("Example B started on"); + expect(html).not.toMatch(/work(s|ing)|effective|successful/i); + }); + + it("names the missing floor instead of a number", () => { + const html = render({ + ...present, + present: false, + reason: "insufficient_before", + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + }); + expect(html).toContain("at least 14 days spread over 21 days before"); + expect(html).not.toContain("level moved"); + }); +}); diff --git a/src/components/medications/detail/efficacy/change-at-start-summary.tsx b/src/components/medications/detail/efficacy/change-at-start-summary.tsx new file mode 100644 index 000000000..8f05249c0 --- /dev/null +++ b/src/components/medications/detail/efficacy/change-at-start-summary.tsx @@ -0,0 +1,155 @@ +"use client"; + +/** + * The segmented-regression read-out on a Wirkung target card: the level + * change and the change in weekly trend at the start, each with its 95 % + * range, in plain sentences, followed by what the fit rests on (days used, + * days left out and why) and the other medication changes it cannot separate + * from this one. Renders the server DTO; recomputes nothing. + */ +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; +import { formatEstimate } from "@/lib/medications/efficacy/segmented-format"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +const CONFOUNDER_KEYS = { + started: "medications.efficacy.segmented.confounderStarted", + stopped: "medications.efficacy.segmented.confounderStopped", + dose_change: "medications.efficacy.segmented.confounderDoseChange", + paused: "medications.efficacy.segmented.confounderPaused", + resumed: "medications.efficacy.segmented.confounderResumed", +} as const; + +const NEED_KEYS = { + no_start: "medications.efficacy.segmented.needStart", + insufficient_before: "medications.efficacy.segmented.needBefore", + insufficient_after: "medications.efficacy.segmented.needAfter", + fit_failed: "medications.efficacy.segmented.fitFailed", +} as const; + +export function ChangeAtStartSummary({ + segmented, + unit, + minDays, + minSpanDays, +}: { + segmented: EfficacySegmented; + unit: string | null; + minDays: number; + minSpanDays: number; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const num = (v: number, d: number) => fmt.number(v, d); + const u = unit ?? ""; + + if ( + !segmented.present || + !segmented.levelChange || + !segmented.slopeChangePerWeek || + !segmented.preSlopePerWeek || + !segmented.window + ) { + return ( +
+

+ {t(NEED_KEYS[segmented.reason ?? "fit_failed"], { + days: minDays, + span: minSpanDays, + })} +

+
+ ); + } + + const level = formatEstimate(segmented.levelChange, num); + const slope = formatEstimate(segmented.slopeChangePerWeek, num); + const pre = formatEstimate(segmented.preSlopePerWeek, num); + const anyZero = + segmented.levelChange.includesZero || + segmented.slopeChangePerWeek.includesZero; + + return ( +
+

+ {t("medications.efficacy.segmented.title")} +

+

+ {t("medications.efficacy.segmented.level", { ...level, unit: u })} +

+

+ {t("medications.efficacy.segmented.slope", { ...slope, unit: u })} +

+

+ {t("medications.efficacy.segmented.preSlope", { + value: pre.value, + unit: u, + })} +

+ {anyZero ? ( +

+ {t("medications.efficacy.segmented.includesZero")} +

+ ) : null} +

+ {t("medications.efficacy.segmented.basis", { + before: segmented.days.before, + after: segmented.days.after, + from: fmt.dateShortSmartCalendar(segmented.window.from), + to: fmt.dateShortSmartCalendar(segmented.window.to), + })} +

+ {segmented.days.excludedLowAdherence > 0 ? ( +

+ {t("medications.efficacy.segmented.excludedAdherence", { + count: segmented.days.excludedLowAdherence, + floor: segmented.adherence.floorPct, + lookback: segmented.adherence.lookbackDays, + })} +

+ ) : null} + {segmented.days.excludedNotTaking > 0 ? ( +

+ {t("medications.efficacy.segmented.excludedNotTaking", { + count: segmented.days.excludedNotTaking, + })} +

+ ) : null} + {segmented.adherence.status === "not_tracked" ? ( +

+ {t("medications.efficacy.segmented.notTracked")} +

+ ) : null} + {segmented.window.endReason === "dose_change" ? ( +

+ {t("medications.efficacy.segmented.endDoseChange")} +

+ ) : null} + {segmented.confounders.length > 0 ? ( +
+

+ {t("medications.efficacy.segmented.confoundersTitle")} +

+
    + {segmented.confounders.map((c) => ( +
  • + {t(CONFOUNDER_KEYS[c.event], { + name: c.medicationName, + date: fmt.dateShortSmartCalendar(c.date), + })} +
  • + ))} +
+
+ ) : null} +

+ {t("medications.efficacy.segmented.method")} +

+
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index a4d1cdde4..fde9cbe77 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -43,6 +43,11 @@ import type { EfficacyTargetView, } from "@/lib/medications/efficacy/build-efficacy"; import { EmptyState } from "@/components/ui/empty-state"; +import { + SEGMENTED_MIN_DAYS_PER_SIDE, + SEGMENTED_MIN_SPAN_DAYS, +} from "@/lib/medications/efficacy/segmented-effect"; +import { ChangeAtStartSummary } from "./change-at-start-summary"; // v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own // `` as a bare 220px `ResponsiveContainer` with no chrome of its own. @@ -310,6 +315,15 @@ function TargetCard({ + {target.segmented ? ( + + ) : null} + {target.levelShift?.present && target.levelShift.nearStart && target.levelShift.at ? ( diff --git a/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts new file mode 100644 index 000000000..6f099d6bc --- /dev/null +++ b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts @@ -0,0 +1,208 @@ +/** + * The medication-effect assembly around the segmented regression: the window, + * the adherence and not-taking exclusions, the dose-change cut, the confounder + * list and the minimum-data floor. The regression itself is pinned to + * reference values in `src/lib/stats/__tests__/segmented-regression.test.ts`. + */ +import { describe, expect, it } from "vitest"; + +import { + buildSegmentedEffect, + notTakingPredicate, + type SegmentedEffectInput, +} from "@/lib/medications/efficacy/segmented-effect"; + +const DAY = 24 * 60 * 60 * 1000; +const PIVOT = "2026-03-01"; + +function dayAt(offset: number): string { + return new Date(Date.parse(`${PIVOT}T00:00:00Z`) + offset * DAY) + .toISOString() + .slice(0, 10); +} + +/** 140 before, a clean −10 shift and −0.7/week (−0.1/day) slope after. */ +function series(fromOffset: number, toOffset: number) { + const out: { day: string; value: number }[] = []; + for (let t = fromOffset; t <= toOffset; t++) { + out.push({ + day: dayAt(t), + value: t < 0 ? 140 : 130 - 0.1 * t, + }); + } + return out; +} + +function input(over: Partial = {}): SegmentedEffectInput { + return { + dayValues: series(-60, 59), + pivotDay: PIVOT, + lastDay: dayAt(59), + adherenceDays: null, + notTakingOn: () => false, + ownDoseChangeDays: [], + otherMedicationEvents: [], + ...over, + }; +} + +describe("buildSegmentedEffect", () => { + it("recovers the known level and weekly slope change", () => { + const r = buildSegmentedEffect(input()); + expect(r.present).toBe(true); + expect(r.levelChange!.estimate).toBeCloseTo(-10, 9); + expect(r.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 9); + expect(r.preSlopePerWeek!.estimate).toBeCloseTo(0, 9); + expect(r.days).toEqual({ + before: 60, + after: 60, + excludedLowAdherence: 0, + excludedNotTaking: 0, + }); + expect(r.window).toEqual({ + from: dayAt(-90), + to: dayAt(59), + endReason: "today", + }); + expect(r.adherence.status).toBe("not_tracked"); + expect(r.level).toBe(0.95); + }); + + it("caps the post window at 180 days", () => { + const r = buildSegmentedEffect( + input({ dayValues: series(-30, 300), lastDay: dayAt(300) }), + ); + expect(r.window).toEqual({ + from: dayAt(-90), + to: dayAt(179), + endReason: "window", + }); + expect(r.days.after).toBe(180); + }); + + it("drops values older than the 90-day pre window", () => { + const r = buildSegmentedEffect(input({ dayValues: series(-200, 59) })); + expect(r.days.before).toBe(90); + }); + + it("ends the window the day before the first own dose change", () => { + const r = buildSegmentedEffect( + input({ ownDoseChangeDays: [PIVOT, dayAt(40)] }), + ); + expect(r.window?.to).toBe(dayAt(39)); + expect(r.window?.endReason).toBe("dose_change"); + expect(r.days.after).toBe(40); + }); + + it("leaves out post-start days whose trailing 14-day adherence is below 80 %", () => { + // One dose a day; every dose from day 20 to day 29 missed. + const adherenceDays = Array.from({ length: 60 }, (_, t) => ({ + day: dayAt(t), + taken: t >= 20 && t < 30 ? 0 : 1, + missed: t >= 20 && t < 30 ? 1 : 0, + })); + const r = buildSegmentedEffect(input({ adherenceDays })); + expect(r.adherence.status).toBe("tracked"); + // Day d is low when misses in [d-13, d] exceed 2 (3/14 = 78.6 %): from + // day 22 (3 misses) through day 29+13-2 = day 40 (3 misses left). + expect(r.days.excludedLowAdherence).toBe(19); + expect(r.days.after).toBe(60 - 19); + // The fit on the remaining days still recovers the clean effect. + expect(r.levelChange!.estimate).toBeCloseTo(-10, 9); + }); + + it("treats a lookback without any expected dose as unknown, not low", () => { + const r = buildSegmentedEffect(input({ adherenceDays: [] })); + expect(r.days.excludedLowAdherence).toBe(0); + expect(r.present).toBe(true); + }); + + it("leaves out days the medication was not being taken", () => { + const r = buildSegmentedEffect( + input({ notTakingOn: (d) => d >= dayAt(10) && d < dayAt(20) }), + ); + expect(r.days.excludedNotTaking).toBe(10); + }); + + it("needs 14 days on each side", () => { + const before = buildSegmentedEffect(input({ dayValues: series(-13, 59) })); + expect(before.present).toBe(false); + expect(before.reason).toBe("insufficient_before"); + expect(before.levelChange).toBeNull(); + const after = buildSegmentedEffect( + input({ dayValues: series(-60, 12), lastDay: dayAt(12) }), + ); + expect(after.reason).toBe("insufficient_after"); + }); + + it("needs each side to span 21 days, not just hold 14 values", () => { + // 14 consecutive days right before the start: enough values, too short. + const r = buildSegmentedEffect(input({ dayValues: series(-14, 59) })); + expect(r.days.before).toBe(14); + expect(r.reason).toBe("insufficient_before"); + }); + + it("answers no_start without a start date", () => { + const r = buildSegmentedEffect(input({ pivotDay: null })); + expect(r).toMatchObject({ present: false, reason: "no_start" }); + expect(r.window).toBeNull(); + }); + + it("lists only the other medications' changes inside the window, oldest first", () => { + const r = buildSegmentedEffect( + input({ + otherMedicationEvents: [ + { medicationName: "B", event: "dose_change", date: dayAt(30) }, + { medicationName: "A", event: "started", date: dayAt(-20) }, + { medicationName: "C", event: "stopped", date: dayAt(-120) }, + ], + }), + ); + expect(r.confounders).toEqual([ + { medicationName: "A", event: "started", date: dayAt(-20) }, + { medicationName: "B", event: "dose_change", date: dayAt(30) }, + ]); + }); + + it("flags an interval that spans zero", () => { + const flat = series(-60, 59).map((p, i) => ({ + day: p.day, + value: 120 + (i % 2 === 0 ? 3 : -3), + })); + const r = buildSegmentedEffect(input({ dayValues: flat })); + expect(r.present).toBe(true); + expect(r.levelChange!.includesZero).toBe(true); + }); +}); + +describe("notTakingPredicate", () => { + it("covers a pause up to the resume day and the gaps between courses", () => { + const pred = notTakingPredicate( + { + pauseEras: [ + { + pausedAt: new Date("2026-03-10T08:00:00Z"), + resumedAt: new Date("2026-03-15T08:00:00Z"), + }, + ], + courses: [ + { + startsOn: new Date("2026-03-01T00:00:00Z"), + endsOn: new Date("2026-03-31T00:00:00Z"), + }, + { + startsOn: new Date("2026-05-01T00:00:00Z"), + endsOn: null, + }, + ], + } as never, + "UTC", + ); + expect(pred("2026-03-09")).toBe(false); + expect(pred("2026-03-10")).toBe(true); + expect(pred("2026-03-14")).toBe(true); + expect(pred("2026-03-15")).toBe(false); + expect(pred("2026-04-10")).toBe(true); + expect(pred("2026-05-02")).toBe(false); + }); +}); diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts index 2841c24ad..a1dde1418 100644 --- a/src/lib/medications/efficacy/build-efficacy.ts +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -24,7 +24,6 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { buildComplianceMedicationContext, - COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, @@ -40,6 +39,11 @@ import { type MedTargetTier, } from "@/lib/medications/med-target-map"; import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { + EFFECT_MEDICATION_INCLUDE, + loadSegmentedEffects, +} from "@/lib/medications/efficacy/load-segmented-effects"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -93,6 +97,12 @@ export interface EfficacyTargetView { beforeAfter: EfficacyBeforeAfter; /** Metric targets only; null for lab targets (changepoint reads metrics). */ levelShift: EfficacyLevelShift | null; + /** + * The segmented-regression effect (level + slope change with 95 % + * intervals, adherence-adjusted). Metric targets only; null for a lab, + * whose readings are too sparse for a daily time series. + */ + segmented: EfficacySegmented | null; } export interface EfficacyMarkers { @@ -294,6 +304,7 @@ async function buildMetricTarget( series, beforeAfter: beforeAfterFromSeries(straddle, pivotMs), levelShift, + segmented: null, }; } @@ -343,6 +354,7 @@ async function buildLabTarget( series, beforeAfter: beforeAfterFromSeries(straddle, pivotMs), levelShift: null, + segmented: null, }; } @@ -351,7 +363,7 @@ async function buildLabTarget( * override wins (tier "override"); otherwise the derived ATC/name resolution. * Returns the tier + class provenance for the DTO alongside the target list. */ -async function resolveEffectiveTargets(med: { +export async function resolveEffectiveTargets(med: { id: string; name: string; treatmentClass: string; @@ -417,14 +429,8 @@ export async function buildMedicationEfficacy( ): Promise { const med = await prisma.medication.findFirst({ where: { id: medicationId, userId }, - include: { - schedules: true, - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - // v1.40 (#1024) — the courses, so a gap between two expects nothing. - courses: COURSES_COMPLIANCE_SELECT, - doseChanges: { orderBy: { effectiveFrom: "asc" } }, - }, + // The courses ride along, so a gap between two expects nothing (#1024). + include: EFFECT_MEDICATION_INCLUDE, }); if (!med) return null; @@ -570,6 +576,23 @@ export async function buildMedicationEfficacy( } } + // The segmented effect, per metric target, around `startsOn` only: the + // first-reading fallback has no "before" by definition. + const segmented = await loadSegmentedEffects({ + userId, + med, + targets: targets + .filter((t) => t.kind === "metric") + .map((t) => t.key as MeasurementType), + timezone, + now, + }); + for (const view of targets) { + if (view.kind === "metric") { + view.segmented = segmented.get(view.key as MeasurementType) ?? null; + } + } + // Adherence lane — the cadence-aware per-day rate from the SAME ledger the // compliance surfaces read; never recomputed here. const fetchFrom = new Date( diff --git a/src/lib/medications/efficacy/load-segmented-effects.ts b/src/lib/medications/efficacy/load-segmented-effects.ts new file mode 100644 index 000000000..91923c93a --- /dev/null +++ b/src/lib/medications/efficacy/load-segmented-effects.ts @@ -0,0 +1,247 @@ +/** + * The database half of the segmented medication effect: one intake-event + * read, one confounder read and one per-day SQL fold per target, handed to the + * pure assembly in `./segmented-effect.ts`. + */ +import type { MeasurementType, Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + buildMedicationComplianceBundle, + COURSES_COMPLIANCE_SELECT, + dailyComplianceRatesFromLedger, + lastNonSkippedTakenAt, +} from "@/lib/analytics/compliance"; +import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { readDayAggregates } from "@/lib/measurements/day-aggregates"; +import { plausibleMetricRange } from "@/lib/measurements/value-domain"; +import { userDayKey } from "@/lib/tz/format"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + buildSegmentedEffect, + notTakingPredicate, + SEGMENTED_POST_WINDOW_DAYS, + SEGMENTED_PRE_WINDOW_DAYS, + type EfficacyConfounder, + type EfficacySegmented, + type SegmentedEffectInput, +} from "./segmented-effect"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The medication shape the effect needs: the efficacy builder's include. */ +export const EFFECT_MEDICATION_INCLUDE = { + schedules: true, + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + courses: COURSES_COMPLIANCE_SELECT, + doseChanges: { orderBy: { effectiveFrom: "asc" } }, +} satisfies Prisma.MedicationInclude; + +export type EffectMedication = Prisma.MedicationGetPayload<{ + include: typeof EFFECT_MEDICATION_INCLUDE; +}>; + +/** Other medications' starts, stops, dose changes and pauses inside a window. */ +async function loadConfounders( + userId: string, + medicationId: string, + windowFrom: Date, + windowTo: Date, + timezone: string, +): Promise { + const inWindow = { gte: windowFrom, lte: windowTo }; + const others = await prisma.medication.findMany({ + where: { + userId, + id: { not: medicationId }, + OR: [ + { startsOn: inWindow }, + { endsOn: inWindow }, + { doseChanges: { some: { effectiveFrom: inWindow } } }, + { pauseEras: { some: { pausedAt: inWindow } } }, + { pauseEras: { some: { resumedAt: inWindow } } }, + ], + }, + select: { + name: true, + startsOn: true, + endsOn: true, + doseChanges: { + where: { effectiveFrom: inWindow }, + select: { effectiveFrom: true }, + }, + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + take: 50, + }); + const out: EfficacyConfounder[] = []; + const within = (d: Date | null): d is Date => + d !== null && + d.getTime() >= windowFrom.getTime() && + d.getTime() <= windowTo.getTime(); + for (const m of others) { + if (within(m.startsOn)) { + out.push({ + medicationName: m.name, + event: "started", + date: dayKeyOfDate(m.startsOn), + }); + } + if (within(m.endsOn)) { + out.push({ + medicationName: m.name, + event: "stopped", + date: dayKeyOfDate(m.endsOn), + }); + } + for (const dc of m.doseChanges) { + out.push({ + medicationName: m.name, + event: "dose_change", + date: userDayKey(dc.effectiveFrom, timezone), + }); + } + for (const p of m.pauseEras) { + if (within(p.pausedAt)) { + out.push({ + medicationName: m.name, + event: "paused", + date: userDayKey(p.pausedAt, timezone), + }); + } + if (within(p.resumedAt)) { + out.push({ + medicationName: m.name, + event: "resumed", + date: userDayKey(p.resumedAt, timezone), + }); + } + } + } + return out; +} + +/** + * Load and fit the segmented effect for each metric target of one medication. + * `now` bounds every read: a report pinned in the past passes its own end so + * nothing after it is used. Reads: one intake-event query, one confounder + * query, and one per-day SQL fold per target. + */ +export async function loadSegmentedEffects(params: { + userId: string; + med: EffectMedication; + targets: readonly MeasurementType[]; + timezone: string; + now: Date; +}): Promise> { + const { userId, med, targets, timezone, now } = params; + const out = new Map(); + if (targets.length === 0) return out; + + const pivotDay = med.startsOn ? dayKeyOfDate(med.startsOn) : null; + const lastDay = userDayKey(now, timezone); + if (pivotDay === null || pivotDay > lastDay) { + const empty = buildSegmentedEffect({ + dayValues: [], + pivotDay: null, + lastDay, + adherenceDays: null, + notTakingOn: () => false, + ownDoseChangeDays: [], + otherMedicationEvents: [], + }); + for (const t of targets) out.set(t, empty); + return out; + } + + // A day of margin either side: the SQL folds on local days, the window is + // cut on day keys afterwards. + const pivotMs = dayKeyAsUtcMidnight(pivotDay).getTime(); + const since = new Date(pivotMs - (SEGMENTED_PRE_WINDOW_DAYS + 1) * DAY_MS); + const until = new Date( + Math.min( + now.getTime(), + pivotMs + (SEGMENTED_POST_WINDOW_DAYS + 1) * DAY_MS, + ), + ); + + let adherenceDays: SegmentedEffectInput["adherenceDays"] = null; + if (med.trackIntake) { + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + medicationId: med.id, + userId, + deletedAt: null, + scheduledFor: { gte: since, lte: until }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + takenAt: true, + skipped: true, + scheduledFor: true, + autoMissed: true, + attributionSource: true, + }, + }); + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(events), + timezone, + ); + const bundle = buildMedicationComplianceBundle( + events, + dueSchedules(med), + ctx, + until, + ); + adherenceDays = dailyComplianceRatesFromLedger( + bundle.ledgerRows, + timezone, + ).map((d) => ({ + day: userDayKey(d.date, timezone), + taken: d.taken, + missed: d.missed, + })); + } + + const confounders = await loadConfounders( + userId, + med.id, + since, + until, + timezone, + ); + const notTakingOn = notTakingPredicate(med, timezone); + const ownDoseChangeDays = med.doseChanges.map((dc) => + userDayKey(dc.effectiveFrom, timezone), + ); + + for (const target of targets) { + const rows = await readDayAggregates({ + userId, + type: target, + since, + until, + timeZone: timezone, + valueRange: plausibleMetricRange(target), + }); + out.set( + target, + buildSegmentedEffect({ + dayValues: rows.map((r) => ({ + day: r.day, + value: r.dayMean ?? r.sum / r.n, + })), + pivotDay, + lastDay, + adherenceDays, + notTakingOn, + ownDoseChangeDays, + otherMedicationEvents: confounders, + }), + ); + } + return out; +} diff --git a/src/lib/medications/efficacy/segmented-effect.ts b/src/lib/medications/efficacy/segmented-effect.ts new file mode 100644 index 000000000..028f4a55b --- /dev/null +++ b/src/lib/medications/efficacy/segmented-effect.ts @@ -0,0 +1,345 @@ +/** + * The medication effect as an interrupted time series: the target metric's + * daily values over a window around the start, fitted with a segmented + * regression (level change + slope change, Newey–West intervals). + * + * Adherence-adjusted by exclusion. After the start, a day is left out when the + * doses expected over the trailing fourteen days were taken less than 80 % of + * the time, and when the medication was paused or between courses that day. + * The adherence comes from the cadence-aware dose ledger the compliance + * surfaces read (`dailyComplianceRatesFromLedger`), never a recount. With + * intake tracking off nothing is excluded and the DTO says so. + * + * The window is ninety days before the start and up to 180 after it, cut short + * at the medication's own first dose change (the estimate then describes the + * starting dose, not a blend of two). Other medications that started, stopped, + * changed dose or paused inside the window are listed as caveats: the model + * cannot tell their effect from this one. + * + * Descriptive and association-only, like the rest of the efficacy DTO: there + * is no verdict field. The renderer phrases the numbers and the caveats; it + * never decides whether a medication "works". + * + * Pure: the database reads live in `./load-segmented-effects.ts`, so the + * client renderer can import the constants and types from here. Computed on + * request, without AI. + */ +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + fitSegmentedRegression, + type SegmentedEstimate, +} from "@/lib/stats/segmented-regression"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Days of target data the window reaches back over before the start. */ +export const SEGMENTED_PRE_WINDOW_DAYS = 90; +/** Days after the start the window reaches forward over, at most. */ +export const SEGMENTED_POST_WINDOW_DAYS = 180; +/** Days with a value required on each side of the start. */ +export const SEGMENTED_MIN_DAYS_PER_SIDE = 14; +/** Calendar span (first to last value) required on each side. */ +export const SEGMENTED_MIN_SPAN_DAYS = 21; +/** Trailing window the per-day adherence is judged over. */ +export const ADHERENCE_LOOKBACK_DAYS = 14; +/** Adherence below this share leaves a post-start day out. */ +export const ADHERENCE_FLOOR = 0.8; +/** Confidence level of every interval. */ +export const SEGMENTED_LEVEL = 0.95; + +/** An estimate in display terms: the unit per day for a level, per week for a slope. */ +export interface EfficacyEstimate { + estimate: number; + ciLow: number; + ciHigh: number; + standardError: number; + /** True when the interval spans zero (the data is also consistent with no change). */ + includesZero: boolean; +} + +export type EfficacyConfounderEvent = + "started" | "stopped" | "dose_change" | "paused" | "resumed"; + +export interface EfficacyConfounder { + medicationName: string; + event: EfficacyConfounderEvent; + /** YYYY-MM-DD. */ + date: string; +} + +export interface EfficacySegmented { + present: boolean; + reason?: + "no_start" | "insufficient_before" | "insufficient_after" | "fit_failed"; + /** Confidence level of the intervals (0.95). */ + level: number; + /** The window actually fitted (YYYY-MM-DD, inclusive). */ + window: { + from: string; + to: string; + endReason: "today" | "window" | "dose_change"; + } | null; + /** Days with a value used on each side, and the days left out after the start. */ + days: { + before: number; + after: number; + excludedLowAdherence: number; + excludedNotTaking: number; + }; + adherence: { + /** "not_tracked" when the medication logs no intake, so nothing was excluded. */ + status: "tracked" | "not_tracked"; + floorPct: number; + lookbackDays: number; + }; + /** Immediate shift at the start, in the target's unit. */ + levelChange: EfficacyEstimate | null; + /** Change in the weekly trend after the start, unit per week. */ + slopeChangePerWeek: EfficacyEstimate | null; + /** The weekly trend before the start, unit per week. */ + preSlopePerWeek: EfficacyEstimate | null; + fit: { + n: number; + df: number; + rSquared: number; + residualAutocorrelation: number; + hacLag: number; + } | null; + /** Other medication changes inside the window, oldest first. */ + confounders: EfficacyConfounder[]; +} + +function dayIndex(day: string): number { + return Math.round(dayKeyAsUtcMidnight(day).getTime() / DAY_MS); +} + +function dayKeyOfIndex(index: number): string { + return dayKeyOfDate(new Date(index * DAY_MS)); +} + +function scaled(e: SegmentedEstimate, factor: number): EfficacyEstimate { + const ciLow = e.ciLow * factor; + const ciHigh = e.ciHigh * factor; + return { + estimate: e.estimate * factor, + ciLow, + ciHigh, + standardError: e.standardError * factor, + includesZero: ciLow <= 0 && ciHigh >= 0, + }; +} + +/** The input to the pure assembly — every day already a local day key. */ +export interface SegmentedEffectInput { + /** One value per local day (any order). */ + dayValues: ReadonlyArray<{ day: string; value: number }>; + /** The start day, or null when the medication carries no start date. */ + pivotDay: string | null; + /** The last day the window may reach (today, or a report's last day). */ + lastDay: string; + /** Per-day dose tallies from the ledger; null when intake is not tracked. */ + adherenceDays: ReadonlyArray<{ + day: string; + taken: number; + missed: number; + }> | null; + /** Whether the medication was not being taken on a day (paused, between courses). */ + notTakingOn: (day: string) => boolean; + /** The medication's own dose changes (YYYY-MM-DD). */ + ownDoseChangeDays: readonly string[]; + /** Other medications' changes (YYYY-MM-DD), any order. */ + otherMedicationEvents: readonly EfficacyConfounder[]; +} + +const EMPTY_DAYS = { + before: 0, + after: 0, + excludedLowAdherence: 0, + excludedNotTaking: 0, +}; + +/** + * Assemble the segmented effect from already-resolved day data. Pure, so the + * window, exclusion and minimum-data rules are testable without a database. + */ +export function buildSegmentedEffect( + input: SegmentedEffectInput, +): EfficacySegmented { + const adherence = { + status: input.adherenceDays === null ? "not_tracked" : "tracked", + floorPct: Math.round(ADHERENCE_FLOOR * 100), + lookbackDays: ADHERENCE_LOOKBACK_DAYS, + } as const; + const absent = ( + reason: NonNullable, + window: EfficacySegmented["window"] = null, + days = EMPTY_DAYS, + confounders: EfficacyConfounder[] = [], + ): EfficacySegmented => ({ + present: false, + reason, + level: SEGMENTED_LEVEL, + window, + days, + adherence, + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + confounders, + }); + + if (input.pivotDay === null) return absent("no_start"); + + const pivot = dayIndex(input.pivotDay); + const from = pivot - SEGMENTED_PRE_WINDOW_DAYS; + let to = Math.min( + dayIndex(input.lastDay), + pivot + SEGMENTED_POST_WINDOW_DAYS - 1, + ); + let endReason: "today" | "window" | "dose_change" = + to === dayIndex(input.lastDay) ? "today" : "window"; + // The first own dose change after the start ends the window the day + // before it: one estimate per dose. A dose recorded on the start day is + // the starting dose, not a change. + const firstChange = input.ownDoseChangeDays + .map(dayIndex) + .filter((d) => d > pivot) + .sort((a, b) => a - b)[0]; + if (firstChange !== undefined && firstChange - 1 < to) { + to = firstChange - 1; + endReason = "dose_change"; + } + const window = { + from: dayKeyOfIndex(from), + to: dayKeyOfIndex(to), + endReason, + }; + + const confounders = input.otherMedicationEvents + .filter((c) => { + const d = dayIndex(c.date); + return d >= from && d <= to; + }) + .slice() + .sort((a, b) => a.date.localeCompare(b.date)); + + // Per-day adherence, judged over the trailing lookback. + const adherenceByDay = new Map(); + for (const a of input.adherenceDays ?? []) { + adherenceByDay.set(dayIndex(a.day), { taken: a.taken, missed: a.missed }); + } + const lowAdherenceOn = (d: number): boolean => { + if (input.adherenceDays === null) return false; + let taken = 0; + let missed = 0; + for (let k = d - ADHERENCE_LOOKBACK_DAYS + 1; k <= d; k++) { + const a = adherenceByDay.get(k); + if (!a) continue; + taken += a.taken; + missed += a.missed; + } + // No dose expected in the lookback: adherence is unknown, not low. + if (taken + missed === 0) return false; + return taken / (taken + missed) < ADHERENCE_FLOOR; + }; + + const points: { t: number; y: number }[] = []; + const days = { ...EMPTY_DAYS }; + const sorted = input.dayValues + .map((v) => ({ d: dayIndex(v.day), day: v.day, value: v.value })) + .filter((v) => v.d >= from && v.d <= to && Number.isFinite(v.value)) + .sort((a, b) => a.d - b.d); + let beforeFirst: number | null = null; + let beforeLast: number | null = null; + let afterFirst: number | null = null; + let afterLast: number | null = null; + for (const v of sorted) { + const t = v.d - pivot; + if (t >= 0) { + if (input.notTakingOn(v.day)) { + days.excludedNotTaking += 1; + continue; + } + if (lowAdherenceOn(v.d)) { + days.excludedLowAdherence += 1; + continue; + } + days.after += 1; + afterFirst ??= t; + afterLast = t; + } else { + days.before += 1; + beforeFirst ??= t; + beforeLast = t; + } + points.push({ t, y: v.value }); + } + + const span = (a: number | null, b: number | null) => + a === null || b === null ? 0 : b - a + 1; + if ( + days.before < SEGMENTED_MIN_DAYS_PER_SIDE || + span(beforeFirst, beforeLast) < SEGMENTED_MIN_SPAN_DAYS + ) { + return absent("insufficient_before", window, days, confounders); + } + if ( + days.after < SEGMENTED_MIN_DAYS_PER_SIDE || + span(afterFirst, afterLast) < SEGMENTED_MIN_SPAN_DAYS + ) { + return absent("insufficient_after", window, days, confounders); + } + + const fit = fitSegmentedRegression(points, { level: SEGMENTED_LEVEL }); + if (!fit) return absent("fit_failed", window, days, confounders); + + return { + present: true, + level: SEGMENTED_LEVEL, + window, + days, + adherence, + levelChange: scaled(fit.levelChange, 1), + slopeChangePerWeek: scaled(fit.slopeChange, 7), + preSlopePerWeek: scaled(fit.preSlope, 7), + fit: { + n: fit.n, + df: fit.df, + rSquared: fit.rSquared, + residualAutocorrelation: fit.residualAutocorrelation, + hacLag: fit.hacLag, + }, + confounders, + }; +} + +/** Day keys on which a medication was not being taken (paused, between courses). */ +export function notTakingPredicate( + med: { + pauseEras: ReadonlyArray<{ pausedAt: Date; resumedAt: Date | null }>; + courses: ReadonlyArray<{ startsOn: Date; endsOn: Date | null }>; + }, + timezone: string, +): (day: string) => boolean { + const pauses = med.pauseEras.map((p) => ({ + from: userDayKey(p.pausedAt, timezone), + to: p.resumedAt ? userDayKey(p.resumedAt, timezone) : null, + })); + const courses = med.courses.map((c) => ({ + from: dayKeyOfDate(c.startsOn), + to: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + })); + return (day: string) => { + // Paused from the pause day up to (not including) the resume day. + if (pauses.some((p) => day >= p.from && (p.to === null || day < p.to))) { + return true; + } + if (courses.length === 0) return false; + return !courses.some( + (c) => day >= c.from && (c.to === null || day <= c.to), + ); + }; +} diff --git a/src/lib/medications/efficacy/segmented-format.ts b/src/lib/medications/efficacy/segmented-format.ts new file mode 100644 index 000000000..8b144fcd3 --- /dev/null +++ b/src/lib/medications/efficacy/segmented-format.ts @@ -0,0 +1,38 @@ +/** + * How a segmented-effect estimate is printed, shared by the efficacy tab, the + * PDF report and the clinician view so all three show the same figure. + * + * The precision follows the uncertainty: one significant digit of the standard + * error decides the decimals (an SE of 0.6 mmHg prints one decimal, 0.02 kg + * prints two). More digits than that would claim a precision the data does not + * have; fewer would hide a real interval. Capped at three decimals. + */ +import type { EfficacyEstimate } from "./segmented-effect"; + +export function estimateDecimals(e: Pick) { + const se = Math.abs(e.standardError); + if (!Number.isFinite(se) || se === 0) return 1; + return Math.min(3, Math.max(0, -Math.floor(Math.log10(se)))); +} + +/** + * The three numbers of an estimate as display strings, the point estimate + * signed. `num` is the caller's locale formatter. + */ +export function formatEstimate( + e: EfficacyEstimate, + num: (value: number, decimals: number) => string, +): { value: string; low: string; high: string } { + const decimals = estimateDecimals(e); + // A value that rounds to zero prints unsigned, never "-0.0". + const signed = (v: number) => { + const text = num(v, decimals); + const rounded = Number(v.toFixed(decimals)); + return rounded > 0 ? `+${text}` : rounded === 0 ? num(0, decimals) : text; + }; + return { + value: signed(e.estimate), + low: num(e.ciLow, decimals), + high: num(e.ciHigh, decimals), + }; +} diff --git a/src/lib/validations/medication-efficacy.ts b/src/lib/validations/medication-efficacy.ts index 86826ef7b..743e3b42a 100644 --- a/src/lib/validations/medication-efficacy.ts +++ b/src/lib/validations/medication-efficacy.ts @@ -81,6 +81,81 @@ const levelShiftSchema = z }) .nullable(); +const estimateSchema = z + .object({ + estimate: z.number(), + ciLow: z.number(), + ciHigh: z.number(), + standardError: z.number(), + includesZero: z.boolean(), + }) + .nullable(); + +/** + * The segmented-regression (interrupted time series) effect: the immediate + * level change and the change in weekly trend at the start, each with a 95 % + * interval (Newey–West), fitted over up to 90 days before and 180 after with + * low-adherence and not-taking days after the start left out. Descriptive + * only; `present: false` carries the reason the data floor was not met. + */ +const segmentedSchema = z + .object({ + present: z.boolean(), + reason: z + .enum([ + "no_start", + "insufficient_before", + "insufficient_after", + "fit_failed", + ]) + .optional(), + level: z.number(), + window: z + .object({ + from: z.string(), + to: z.string(), + endReason: z.enum(["today", "window", "dose_change"]), + }) + .nullable(), + days: z.object({ + before: z.number().int(), + after: z.number().int(), + excludedLowAdherence: z.number().int(), + excludedNotTaking: z.number().int(), + }), + adherence: z.object({ + status: z.enum(["tracked", "not_tracked"]), + floorPct: z.number().int(), + lookbackDays: z.number().int(), + }), + levelChange: estimateSchema, + slopeChangePerWeek: estimateSchema, + preSlopePerWeek: estimateSchema, + fit: z + .object({ + n: z.number().int(), + df: z.number().int(), + rSquared: z.number(), + residualAutocorrelation: z.number(), + hacLag: z.number().int(), + }) + .nullable(), + confounders: z.array( + z.object({ + medicationName: z.string(), + event: z.enum([ + "started", + "stopped", + "dose_change", + "paused", + "resumed", + ]), + date: z.string(), + }), + ), + }) + .nullable(); + const targetViewSchema = z.object({ kind: z.enum(["metric", "lab"]), key: z.string(), @@ -91,6 +166,7 @@ const targetViewSchema = z.object({ series: z.array(seriesPointSchema), beforeAfter: beforeAfterSchema, levelShift: levelShiftSchema, + segmented: segmentedSchema, }); export const medicationEfficacyResponseSchema = z.object({ From 2ba6214a651efb15b80c3c8d29875062611b98e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:51:36 +0200 Subject: [PATCH 42/98] feat(labs): show each marker's slope and where the line would leave the range The biomarker detail read now carries a trend: the least-squares line through every numeric reading of the marker (all stored in its unit), with the slope's 95 % interval and p-value. It needs three readings over at least a year. The line is placed against the marker's band, and an exit date is given only when the interval excludes zero, the line is inside the band today and reaches a bound within three years. The detail page words it as a straight-line projection, not a forecast. --- docs/api/openapi.yaml | 173 +++++++++++++++- messages/de.json | 19 ++ messages/en.json | 19 ++ messages/es.json | 19 ++ messages/fr.json | 19 ++ messages/it.json | 19 ++ messages/ko.json | 19 ++ messages/nl.json | 19 ++ messages/pl.json | 19 ++ .../analysis-depth-no-ai-guard.test.ts | 1 + .../biomarkers/[id]/__tests__/route.test.ts | 54 ++++- src/app/api/biomarkers/[id]/route.ts | 40 +++- .../labs/__tests__/lab-trend-card.test.tsx | 74 +++++++ src/components/labs/lab-biomarker-detail.tsx | 5 + src/components/labs/lab-trend-card.tsx | 81 ++++++++ src/components/labs/types.ts | 32 +++ src/lib/insights/correlations.ts | 21 +- src/lib/labs/__tests__/lab-trend.test.ts | 131 +++++++++++++ src/lib/labs/lab-trend.ts | 184 ++++++++++++++++++ src/lib/openapi/routes/biomarkers.ts | 56 +++++- 20 files changed, 993 insertions(+), 11 deletions(-) create mode 100644 src/components/labs/__tests__/lab-trend-card.test.tsx create mode 100644 src/components/labs/lab-trend-card.tsx create mode 100644 src/lib/labs/__tests__/lab-trend.test.ts create mode 100644 src/lib/labs/lab-trend.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 339c7b7c5..c9b3511d3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15869,8 +15869,9 @@ paths: tags: - Labs summary: Fetch a single biomarker - description: Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows - surface as 404. A marker merged into another answers 404 with `meta.mergedInto`. + description: Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its + `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with + `meta.mergedInto`. parameters: - in: path name: id @@ -72267,7 +72268,7 @@ components: type: object properties: data: - $ref: "#/components/schemas/Biomarker" + $ref: "#/components/schemas/BiomarkerDetail" error: type: "null" meta: @@ -72280,6 +72281,172 @@ components: - data - error additionalProperties: false + BiomarkerDetail: + type: object + properties: + id: + type: string + name: + type: string + unit: + type: string + lowerBound: + type: + - number + - "null" + upperBound: + type: + - number + - "null" + panel: + type: + - string + - "null" + analyteKey: + type: + - string + - "null" + hasContext: + type: boolean + context: + type: + - string + - "null" + hidden: + type: boolean + aliases: + description: v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on + the marker), sorted, without the marker's own name. Absent on older servers. + type: array + items: + type: string + createdAt: + type: string + updatedAt: + type: string + trend: + description: v1.44 — absent on older servers. + $ref: "#/components/schemas/BiomarkerTrend" + required: + - id + - name + - unit + - lowerBound + - upperBound + - panel + - analyteKey + - hasContext + - context + - hidden + - createdAt + - updatedAt + additionalProperties: false + BiomarkerTrend: + anyOf: + - type: object + properties: + status: + type: string + const: ok + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + firstAt: + type: string + description: YYYY-MM-DD of the first reading. + lastAt: + type: string + description: YYYY-MM-DD of the newest reading. + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + slopePerYear: + type: number + description: Least-squares change per year, in the marker's unit. + slopeCi95: + type: array + prefixItems: + - type: number + - type: number + items: false + minItems: 2 + maxItems: 2 + description: 95 % interval of the slope (Student t at n − 2 df). + pValue: + type: number + fittedNow: + type: number + description: The line's value on `asOf`. + asOf: + type: string + description: YYYY-MM-DD the line was evaluated on. + projection: + type: string + enum: + - exits + - beyond_horizon + - no_exit + - already_outside + - no_clear_trend + - no_band + exitDate: + description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." + type: + - string + - "null" + exitBound: + anyOf: + - type: string + enum: + - low + - high + - type: "null" + required: + - status + - n + - firstAt + - lastAt + - spanDays + - slopePerYear + - slopeCi95 + - pValue + - fittedNow + - asOf + - projection + - exitDate + - exitBound + additionalProperties: false + - type: object + properties: + status: + type: string + const: insufficient + reason: + type: string + enum: + - too_few_values + - span_too_short + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - reason + - n + - spanDays + additionalProperties: false + description: "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's + unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the + line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the + line is inside the band today and reaches a bound within three years. Server-computed; clients render it as + given." UpdateBiomarkerResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index dd2a6c8e4..fc61d1d46 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10115,6 +10115,25 @@ "doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben", "doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben", "error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut." + }, + "trend": { + "title": "Verlauf", + "slope": { + "rising": "Steigt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.", + "falling": "Sinkt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.", + "flat": "Keine Veränderung über {n} Werte seit {since}." + }, + "projection": { + "exits": { + "high": "Liefe diese Gerade so weiter, erreichte sie um den {date} das obere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage.", + "low": "Liefe diese Gerade so weiter, erreichte sie um den {date} das untere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage." + }, + "beyond_horizon": "In diesem Tempo bleibt die Gerade noch mindestens drei Jahre in deinem Referenzbereich.", + "no_exit": "Die Gerade läuft von den Grenzen deines Referenzbereichs weg.", + "already_outside": "Die Gerade liegt bereits außerhalb deines Referenzbereichs.", + "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben." + }, + "interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}." } }, "modules": { diff --git a/messages/en.json b/messages/en.json index d9a637e04..be2db6114 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Merged into {name}, {count} readings moved", "doneToastOther": "Merged into {name}, {count} readings moved", "error": "The biomarkers could not be merged. Try again." + }, + "trend": { + "title": "Trend", + "slope": { + "rising": "Rising by about {amount} {unit} a year across {n} readings since {since}.", + "falling": "Falling by about {amount} {unit} a year across {n} readings since {since}.", + "flat": "No change across {n} readings since {since}." + }, + "projection": { + "exits": { + "high": "If this straight line continued, it would reach the upper end of your reference range around {date}. That is a projection, not a forecast.", + "low": "If this straight line continued, it would reach the lower end of your reference range around {date}. That is a projection, not a forecast." + }, + "beyond_horizon": "At this pace the line stays inside your reference range for at least three more years.", + "no_exit": "The line heads away from the edges of your reference range.", + "already_outside": "The line already sits outside your reference range.", + "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn." + }, + "interval": "95 % interval of the yearly change: {lo} to {hi} {unit}." } }, "modules": { diff --git a/messages/es.json b/messages/es.json index cf161392a..287b36a61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Combinado en {name}, {count} mediciones movidas", "doneToastOther": "Combinado en {name}, {count} mediciones movidas", "error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo." + }, + "trend": { + "title": "Tendencia", + "slope": { + "rising": "Sube unos {amount} {unit} al año en {n} valores desde {since}.", + "falling": "Baja unos {amount} {unit} al año en {n} valores desde {since}.", + "flat": "Sin cambios en {n} valores desde {since}." + }, + "projection": { + "exits": { + "high": "Si esta línea recta continuara, llegaría al límite superior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico.", + "low": "Si esta línea recta continuara, llegaría al límite inferior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico." + }, + "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia al menos tres años más.", + "no_exit": "La línea se aleja de los límites de tu rango de referencia.", + "already_outside": "La línea ya está fuera de tu rango de referencia.", + "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada." + }, + "interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}." } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index 1edb3089b..d1c564694 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Fusionné dans {name}, {count} mesures déplacées", "doneToastOther": "Fusionné dans {name}, {count} mesures déplacées", "error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie." + }, + "trend": { + "title": "Tendance", + "slope": { + "rising": "Augmente d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.", + "falling": "Diminue d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.", + "flat": "Aucune variation sur {n} valeurs depuis le {since}." + }, + "projection": { + "exits": { + "high": "Si cette droite se prolongeait, elle atteindrait la limite haute de ta plage de référence vers le {date}. C'est une projection, pas une prévision.", + "low": "Si cette droite se prolongeait, elle atteindrait la limite basse de ta plage de référence vers le {date}. C'est une projection, pas une prévision." + }, + "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence pendant au moins trois ans encore.", + "no_exit": "La droite s'éloigne des limites de ta plage de référence.", + "already_outside": "La droite est déjà hors de ta plage de référence.", + "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée." + }, + "interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}." } }, "modules": { diff --git a/messages/it.json b/messages/it.json index 52ef2d137..dd0c39841 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Unito in {name}, {count} misurazioni spostate", "doneToastOther": "Unito in {name}, {count} misurazioni spostate", "error": "Non è stato possibile unire i biomarcatori. Riprova." + }, + "trend": { + "title": "Andamento", + "slope": { + "rising": "Sale di circa {amount} {unit} all'anno su {n} valori dal {since}.", + "falling": "Scende di circa {amount} {unit} all'anno su {n} valori dal {since}.", + "flat": "Nessuna variazione su {n} valori dal {since}." + }, + "projection": { + "exits": { + "high": "Se questa retta proseguisse, raggiungerebbe il limite superiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione.", + "low": "Se questa retta proseguisse, raggiungerebbe il limite inferiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione." + }, + "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per almeno altri tre anni.", + "no_exit": "La retta si allontana dai limiti del tuo intervallo di riferimento.", + "already_outside": "La retta è già fuori dal tuo intervallo di riferimento.", + "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla." + }, + "interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}." } }, "modules": { diff --git a/messages/ko.json b/messages/ko.json index 26c14f484..f8ac7397c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10115,6 +10115,25 @@ "doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", "doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", "error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요." + }, + "trend": { + "title": "추세", + "slope": { + "rising": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 오르고 있습니다.", + "falling": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 내려가고 있습니다.", + "flat": "{since} 이후 {n}개 값에서 변화가 없습니다." + }, + "projection": { + "exits": { + "high": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 상한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다.", + "low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다." + }, + "beyond_horizon": "이 속도라면 적어도 3년 동안은 참고 범위 안에 머뭅니다.", + "no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.", + "already_outside": "직선이 이미 참고 범위 밖에 있습니다.", + "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다." + }, + "interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}." } }, "modules": { diff --git a/messages/nl.json b/messages/nl.json index 8fd2353e6..bc81ba2ae 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Samengevoegd in {name}, {count} metingen verplaatst", "doneToastOther": "Samengevoegd in {name}, {count} metingen verplaatst", "error": "De biomarkers konden niet worden samengevoegd. Probeer het opnieuw." + }, + "trend": { + "title": "Verloop", + "slope": { + "rising": "Stijgt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.", + "falling": "Daalt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.", + "flat": "Geen verandering over {n} waarden sinds {since}." + }, + "projection": { + "exits": { + "high": "Als deze rechte lijn zo doorliep, zou ze rond {date} de bovengrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling.", + "low": "Als deze rechte lijn zo doorliep, zou ze rond {date} de ondergrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling." + }, + "beyond_horizon": "In dit tempo blijft de lijn nog minstens drie jaar binnen je referentiebereik.", + "no_exit": "De lijn loopt weg van de grenzen van je referentiebereik.", + "already_outside": "De lijn ligt al buiten je referentiebereik.", + "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken." + }, + "interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}." } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index 304d35ac3..3939cf2eb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary", "doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów", "error": "Nie udało się scalić biomarkerów. Spróbuj ponownie." + }, + "trend": { + "title": "Trend", + "slope": { + "rising": "Rośnie o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.", + "falling": "Spada o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.", + "flat": "Brak zmian na podstawie {n} wyników od {since}." + }, + "projection": { + "exits": { + "high": "Gdyby ta prosta biegła dalej, osiągnęłaby górną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza.", + "low": "Gdyby ta prosta biegła dalej, osiągnęłaby dolną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza." + }, + "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym jeszcze co najmniej trzy lata.", + "no_exit": "Prosta oddala się od granic twojego zakresu referencyjnego.", + "already_outside": "Prosta jest już poza twoim zakresem referencyjnym.", + "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja." + }, + "interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}." } }, "modules": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts index 0eb5df59f..f5aa84195 100644 --- a/src/__tests__/analysis-depth-no-ai-guard.test.ts +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -29,6 +29,7 @@ const ANALYSIS_MODULES = [ "lib/insights/correlation-channel-series.ts", "lib/insights/discovery-matrix.ts", "lib/insights/derived/changepoint.ts", + "lib/labs/lab-trend.ts", ] as const; /** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts index 9cf66d9dd..46a840f1f 100644 --- a/src/app/api/biomarkers/[id]/__tests__/route.test.ts +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -16,7 +16,7 @@ vi.mock("@/lib/db", () => { createMany: vi.fn(), }, biomarkerMerge: { findFirst: vi.fn(async () => null) }, - labResult: { deleteMany: vi.fn() }, + labResult: { deleteMany: vi.fn(), findMany: vi.fn(async () => []) }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; // `$transaction` is set in `beforeEach`: the batch form for the delete, the @@ -50,7 +50,7 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT, DELETE } from "../route"; +import { GET, PUT, DELETE } from "../route"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -105,6 +105,56 @@ beforeEach(() => { vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never); }); +describe("GET /api/biomarkers/[id] — trend (v1.44)", () => { + const YEAR_MS = 365.25 * 86_400_000; + const getReq = () => + new NextRequest("http://localhost/api/biomarkers/bm-1", { method: "GET" }); + + it("carries the marker's slope over every stored reading", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + const t0 = Date.now() - 2 * YEAR_MS; + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + { takenAt: new Date(t0), value: 60 }, + { takenAt: new Date(t0 + YEAR_MS), value: 70 }, + { takenAt: new Date(t0 + 2 * YEAR_MS), value: 80 }, + ] as never); + const res = await GET(getReq(), params("bm-1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.name).toBe("LDL"); + expect(body.data.trend).toMatchObject({ + status: "ok", + n: 3, + slopePerYear: 10, + projection: "exits", + exitBound: "high", + }); + expect(prisma.labResult.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + biomarkerId: "bm-1", + deletedAt: null, + }), + }), + ); + }); + + it("answers insufficient below three readings", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + const body = await (await GET(getReq(), params("bm-1"))).json(); + expect(body.data.trend).toEqual({ + status: "insufficient", + reason: "too_few_values", + n: 0, + spanDays: 0, + }); + }); +}); + describe("DELETE /api/biomarkers/[id]", () => { it("drops the marker and its readings in one userId-narrowed transaction", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 0cc1601b9..e47454e51 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -26,6 +26,10 @@ import { mergedInto } from "@/lib/labs/biomarker-merge"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; +import { computeLabTrend } from "@/lib/labs/lab-trend"; + +/** Upper bound on readings the trend line reads. */ +const LAB_TREND_READ_CAP = 2000; import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; @@ -58,14 +62,44 @@ export const GET = apiHandler( }); } - const aliases = await aliasNamesByMarker(prisma, user.id, [row]); + const [aliases, readings] = await Promise.all([ + aliasNamesByMarker(prisma, user.id, [row]), + // v1.44 — the marker's numeric readings for the trend line. Every + // reading is stored in the marker's unit, so the series needs no + // conversion; the cap is far above any real panel history. + prisma.labResult.findMany({ + where: { + userId: user.id, + biomarkerId: id, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "asc" }, + take: LAB_TREND_READ_CAP, + select: { takenAt: true, value: true }, + }), + ]); + const trend = computeLabTrend( + readings.flatMap((r) => + r.value === null ? [] : [{ takenAt: r.takenAt, value: r.value }], + ), + { low: row.lowerBound, high: row.upperBound }, + new Date(), + ); annotate({ action: { name: "labs.biomarker.get" }, - meta: { biomarkerId: id }, + meta: { + biomarkerId: id, + trend: trend.status, + ...(trend.status === "ok" ? { projection: trend.projection } : {}), + }, }); - return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? [])); + return apiSuccess({ + ...serialiseBiomarker(row, aliases.get(row.id) ?? []), + trend, + }); }, ); diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx new file mode 100644 index 000000000..d5c3ae554 --- /dev/null +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -0,0 +1,74 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +import { LabTrendCard } from "../lab-trend-card"; +import type { BiomarkerTrendDto } from "../types"; + +primeLocales("de"); + +const OK: BiomarkerTrendDto = { + status: "ok", + n: 4, + firstAt: "2023-01-10", + lastAt: "2026-01-10", + spanDays: 1096, + slopePerYear: 12.5, + slopeCi95: [4.25, 20.75], + pValue: 0.02, + fittedNow: 130, + asOf: "2026-10-21", + projection: "exits", + exitDate: "2027-11-02", + exitBound: "high", +}; + +function render( + trend: BiomarkerTrendDto | undefined, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("words the server's slope and labels the exit as a projection", () => { + const html = render(OK); + expect(html).toContain("Rising by about 13 mg/dL a year across 4 readings"); + expect(html).toContain('data-projection="exits"'); + expect(html).toContain("upper end of your reference range"); + expect(html).toContain("projection, not a forecast"); + expect(html).toContain("4.25 to 20.75 mg/dL"); + }); + + it("speaks German with du", () => { + const html = render(OK, "de"); + expect(html).toContain("deines Referenzbereichs"); + expect(html).toContain("Fortschreibung, keine Vorhersage"); + }); + + it("draws no projection sentence without a clear trend, and nothing when insufficient", () => { + const html = render({ + ...OK, + projection: "no_clear_trend", + exitDate: null, + exitBound: null, + }); + expect(html).toContain('data-projection="no_clear_trend"'); + expect(html).not.toContain("forecast"); + expect( + render({ + status: "insufficient", + reason: "too_few_values", + n: 2, + spanDays: 40, + }), + ).toBe(""); + expect(render(undefined)).toBe(""); + }); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index f83352055..67978895b 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -55,6 +55,7 @@ import { importWithRetry } from "@/lib/retry-import"; import { BiomarkerAliases } from "./biomarker-aliases"; import { BiomarkerForm } from "./biomarker-form"; import { LabForm } from "./lab-form"; +import { LabTrendCard } from "./lab-trend-card"; import type { BiomarkerDto, LabResultDto, @@ -505,6 +506,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { /> + )} diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx new file mode 100644 index 000000000..94b81f164 --- /dev/null +++ b/src/components/labs/lab-trend-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { BiomarkerTrendDto } from "./types"; + +/** + * v1.44 — the marker's slope over all its readings, and where that straight + * line would leave the reference band. + * + * Server-authoritative: every number is the route's (`computeLabTrend`); this + * only words it. Renders nothing below three readings over a year. The exit + * sentence names itself a projection, never a forecast. + */ +export function LabTrendCard({ + trend, + unit, +}: { + trend: BiomarkerTrendDto | undefined; + unit: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!trend || trend.status !== "ok") return null; + + const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2; + const per = (v: number) => fmt.number(Math.abs(v), digits); + const direction = + trend.slopePerYear > 0 + ? "rising" + : trend.slopePerYear < 0 + ? "falling" + : "flat"; + const slopeLine = t(`labs.trend.slope.${direction}`, { + amount: per(trend.slopePerYear), + unit, + n: trend.n, + since: fmt.dateShortSmartCalendar(trend.firstAt), + }); + const projectionLine = + trend.projection === "exits" && trend.exitDate + ? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, { + date: fmt.dateShortSmartCalendar(trend.exitDate), + }) + : trend.projection === "no_band" + ? null + : t(`labs.trend.projection.${trend.projection}`); + + return ( + + + + + +

+ {slopeLine} +

+ {projectionLine ? ( +

+ {projectionLine} +

+ ) : null} +

+ {t("labs.trend.interval", { + lo: fmt.number(trend.slopeCi95[0], 2), + hi: fmt.number(trend.slopeCi95[1], 2), + unit, + })} +

+
+
+ ); +} diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index d7a0e2816..f4c976a74 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -108,8 +108,40 @@ export interface BiomarkerDto { hidden: boolean; createdAt: string; updatedAt: string; + /** v1.44 — the detail read's trend line; absent on the list and older servers. */ + trend?: BiomarkerTrendDto; } +/** v1.44 — `GET /api/biomarkers/{id}` `trend` (`computeLabTrend`). */ +export type BiomarkerTrendDto = + | { + status: "ok"; + n: number; + firstAt: string; + lastAt: string; + spanDays: number; + slopePerYear: number; + slopeCi95: [number, number]; + pValue: number; + fittedNow: number; + asOf: string; + projection: + | "exits" + | "beyond_horizon" + | "no_exit" + | "already_outside" + | "no_clear_trend" + | "no_band"; + exitDate: string | null; + exitBound: "low" | "high" | null; + } + | { + status: "insufficient"; + reason: "too_few_values" | "span_too_short"; + n: number; + spanDays: number; + }; + export interface BiomarkerListResponse { biomarkers: BiomarkerDto[]; } diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index 86ba3e7a9..892658646 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -388,7 +388,7 @@ function roundTo(value: number, digits: number): number { * gate is no longer load-bearing for correctness — only for sample * adequacy. */ -function twoSidedPFromT(absT: number, df: number): number { +export function twoSidedPFromT(absT: number, df: number): number { if (!Number.isFinite(absT)) return 0; if (df <= 0) return 1; if (absT === 0) return 1; @@ -396,6 +396,25 @@ function twoSidedPFromT(absT: number, df: number): number { return regularizedIncompleteBeta(x, df / 2, 0.5); } +/** + * The two-sided critical t at `alpha` with `df` degrees of freedom: the t + * whose two-sided p equals `alpha` (12.706 at df 1, 4.303 at df 2, 1.960 as + * df grows, for alpha 0.05). Bisection over {@link twoSidedPFromT}, which is + * monotone in t; 200 halvings of [0, 1e6] reach double precision. Null for a + * df below 1, where no interval exists. + */ +export function criticalTTwoSided(df: number, alpha = 0.05): number | null { + if (!(df >= 1) || !(alpha > 0 && alpha < 1)) return null; + let lo = 0; + let hi = 1e6; + for (let i = 0; i < 200; i += 1) { + const mid = (lo + hi) / 2; + if (twoSidedPFromT(mid, df) > alpha) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} + // ── Welch two-sample t-test (unequal variances) ──────────────────── export interface WelchOk { diff --git a/src/lib/labs/__tests__/lab-trend.test.ts b/src/lib/labs/__tests__/lab-trend.test.ts new file mode 100644 index 000000000..b4aeb3dfa --- /dev/null +++ b/src/lib/labs/__tests__/lab-trend.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { criticalTTwoSided } from "@/lib/insights/correlations"; +import { computeLabTrend } from "../lab-trend"; + +const YEAR_MS = 365.25 * 86_400_000; +const T0 = Date.UTC(2023, 0, 1); +const atYears = (y: number) => new Date(T0 + y * YEAR_MS); +const readings = (values: Array<[number, number]>) => + values.map(([y, value]) => ({ takenAt: atYears(y), value })); + +describe("criticalTTwoSided", () => { + it("matches the tabled Student t at alpha 0.05", () => { + // df 1: tan(0.95·π/2) exactly. + expect(criticalTTwoSided(1)).toBeCloseTo(Math.tan(0.475 * Math.PI), 6); + expect(criticalTTwoSided(2)).toBeCloseTo(4.302653, 5); + expect(criticalTTwoSided(10)).toBeCloseTo(2.228139, 5); + expect(criticalTTwoSided(1000)).toBeCloseTo(1.962339, 4); + expect(criticalTTwoSided(0)).toBeNull(); + }); +}); + +describe("computeLabTrend", () => { + it("needs three readings over at least a year", () => { + expect( + computeLabTrend( + readings([ + [0, 1], + [2, 2], + ]), + { low: 0, high: 5 }, + atYears(2), + ), + ).toEqual({ + status: "insufficient", + reason: "too_few_values", + n: 2, + spanDays: 731, + }); + expect( + computeLabTrend( + readings([ + [0, 1], + [0.3, 2], + [0.6, 3], + ]), + { low: 0, high: 5 }, + atYears(1), + ), + ).toMatchObject({ status: "insufficient", reason: "span_too_short", n: 3 }); + }); + + it("projects the exit date of an exact line", () => { + // 100, 110, 120 a year apart: 10 per year, no residual, band top 140. + const trend = computeLabTrend( + readings([ + [0, 100], + [1, 110], + [2, 120], + ]), + { low: 70, high: 140 }, + atYears(2), + ); + expect(trend).toMatchObject({ + status: "ok", + n: 3, + slopePerYear: 10, + slopeCi95: [10, 10], + pValue: 0, + fittedNow: 120, + projection: "exits", + exitBound: "high", + // The line meets 140 four years after the first reading. + exitDate: new Date(T0 + 4 * YEAR_MS).toISOString().slice(0, 10), + }); + }); + + it("draws no projection when the slope's interval includes zero", () => { + // 100, 112, 118: slope 9, SSR 6, df 1, SE √3, t = 9/√3. + const trend = computeLabTrend( + readings([ + [0, 100], + [1, 112], + [2, 118], + ]), + { low: 70, high: 140 }, + atYears(2), + ); + if (trend.status !== "ok") throw new Error("expected ok"); + const half = Math.tan(0.475 * Math.PI) * Math.sqrt(3); + expect(trend.slopePerYear).toBe(9); + expect(trend.slopeCi95[0]).toBeCloseTo(9 - half, 3); + expect(trend.slopeCi95[1]).toBeCloseTo(9 + half, 3); + // df 1 is the Cauchy: p = (2/π)·atan(√3 / 9). + expect(trend.pValue).toBeCloseTo( + (2 / Math.PI) * Math.atan(Math.sqrt(3) / 9), + 5, + ); + expect(trend.projection).toBe("no_clear_trend"); + expect(trend.exitDate).toBeNull(); + }); + + it("says when the line is already outside, has no band, or exits too late", () => { + const line = readings([ + [0, 100], + [1, 110], + [2, 120], + ]); + expect( + computeLabTrend(line, { low: 70, high: 115 }, atYears(2)), + ).toMatchObject({ projection: "already_outside" }); + expect( + computeLabTrend(line, { low: null, high: null }, atYears(2)), + ).toMatchObject({ projection: "no_band" }); + expect( + computeLabTrend(line, { low: 70, high: 200 }, atYears(2)), + ).toMatchObject({ projection: "beyond_horizon", exitDate: null }); + // Falling toward a band with no lower bound. + const falling = readings([ + [0, 120], + [1, 110], + [2, 100], + ]); + expect( + computeLabTrend(falling, { low: null, high: 140 }, atYears(2)), + ).toMatchObject({ projection: "no_exit" }); + expect( + computeLabTrend(falling, { low: 90, high: 140 }, atYears(2)), + ).toMatchObject({ projection: "exits", exitBound: "low" }); + }); +}); diff --git a/src/lib/labs/lab-trend.ts b/src/lib/labs/lab-trend.ts new file mode 100644 index 000000000..f577c1f3d --- /dev/null +++ b/src/lib/labs/lab-trend.ts @@ -0,0 +1,184 @@ +/** + * v1.44 — the slope of one lab marker over all its readings, and where that + * line would leave the reference band. + * + * A single panel says where a value is; the trend says where it is going. + * The statistic is an ordinary least-squares line through every numeric + * reading of the marker (stored in the marker's unit since v1.40, so the + * series is unit-normalised by construction), x in years. It is reported + * only with at least {@link LAB_TREND_MIN_VALUES} readings spanning at least + * {@link LAB_TREND_MIN_SPAN_DAYS} days: three draws in one month are a + * measurement-noise question, not a trend. + * + * The slope carries its 95 % confidence interval (Student t at n − 2 degrees + * of freedom). A projection is offered only when that interval excludes zero, + * the line is inside the band today and heads toward a bound it reaches + * within {@link LAB_PROJECTION_HORIZON_YEARS} years. It is a straight-line + * extrapolation, labelled as one wherever it is shown, never a forecast of + * the body and never a risk statement: lab values plateau, respond to + * treatment and carry assay noise a line does not know about. With three + * readings the interval is wide (t ≈ 12.7 at one degree of freedom), so the + * projection is rare exactly where the evidence is thin. + * + * Pure. The read lives in the biomarker route. + */ +import { criticalTTwoSided, twoSidedPFromT } from "@/lib/insights/correlations"; +import { linearFit } from "@/lib/stats/linear-fit"; +import { dateOnlyKey } from "@/lib/tz/date-only"; + +export const LAB_TREND_MIN_VALUES = 3; +export const LAB_TREND_MIN_SPAN_DAYS = 365; +export const LAB_PROJECTION_HORIZON_YEARS = 3; + +const DAY_MS = 86_400_000; +const YEAR_DAYS = 365.25; + +export type LabTrendProjection = + /** The line meets a bound within the horizon: `exitDate` is set. */ + | "exits" + /** The line heads to a bound but meets it after the horizon. */ + | "beyond_horizon" + /** The line heads away from the band edges, or to a side with no bound. */ + | "no_exit" + /** The line already sits outside the band today. */ + | "already_outside" + /** The slope's interval includes zero: no projection is drawn. */ + | "no_clear_trend" + /** The marker has no reference band to leave. */ + | "no_band"; + +export interface LabTrendOk { + status: "ok"; + /** Readings in the fit. */ + n: number; + firstAt: string; + lastAt: string; + spanDays: number; + /** Change per year in the marker's unit. */ + slopePerYear: number; + /** 95 % interval of the slope per year. */ + slopeCi95: [number, number]; + /** Two-sided p of the slope against zero (t at n − 2 df). */ + pValue: number; + /** The line's value on `asOf`. */ + fittedNow: number; + asOf: string; + projection: LabTrendProjection; + /** Set only for `exits`: the day the line meets `exitBound`. */ + exitDate: string | null; + exitBound: "low" | "high" | null; +} + +export interface LabTrendInsufficient { + status: "insufficient"; + reason: "too_few_values" | "span_too_short"; + n: number; + spanDays: number; +} + +export type LabTrend = LabTrendOk | LabTrendInsufficient; + +const round = (x: number, digits: number) => { + const f = 10 ** digits; + return Math.round(x * f) / f; +}; + +/** + * The calendar label of a draw instant. Lab readings carry the day of the + * draw as their meaning (`reading-day.ts`); the exit day is a point on the + * same axis, so both read through the same label. + */ +const dayOf = (ms: number) => dateOnlyKey(new Date(ms)); + +/** + * Fit the marker's readings and place the line against its band. + * `now` anchors "today" for the fitted value and the horizon. + */ +export function computeLabTrend( + readings: ReadonlyArray<{ takenAt: Date; value: number }>, + band: { low: number | null; high: number | null }, + now: Date, +): LabTrend { + const points = readings + .filter((r) => Number.isFinite(r.value)) + .map((r) => ({ t: r.takenAt.getTime(), v: r.value })) + .sort((a, b) => a.t - b.t); + const n = points.length; + const spanDays = + n > 0 ? Math.round((points[n - 1].t - points[0].t) / DAY_MS) : 0; + if (n < LAB_TREND_MIN_VALUES) { + return { status: "insufficient", reason: "too_few_values", n, spanDays }; + } + if (spanDays < LAB_TREND_MIN_SPAN_DAYS) { + return { status: "insufficient", reason: "span_too_short", n, spanDays }; + } + + const t0 = points[0].t; + const xs = points.map((p) => (p.t - t0) / DAY_MS / YEAR_DAYS); + const ys = points.map((p) => p.v); + const fit = linearFit(xs, ys); + if (!fit) { + return { status: "insufficient", reason: "span_too_short", n, spanDays }; + } + + const df = n - 2; + // Residual variance from the centred sums: SSR = Syy − Sxy² / Sxx. + const ssr = Math.max(0, fit.syy - (fit.sxy * fit.sxy) / fit.sxx); + const se = df > 0 ? Math.sqrt(ssr / df / fit.sxx) : Infinity; + const tCrit = criticalTTwoSided(df) ?? Infinity; + const half = tCrit * se; + const lo = fit.slope - half; + const hi = fit.slope + half; + const pValue = + se === 0 + ? fit.slope === 0 + ? 1 + : 0 + : twoSidedPFromT(Math.abs(fit.slope / se), df); + + const xNow = (now.getTime() - t0) / DAY_MS / YEAR_DAYS; + const fittedNow = fit.intercept + fit.slope * xNow; + + let projection: LabTrendProjection; + let exitDate: string | null = null; + let exitBound: "low" | "high" | null = null; + const hasBand = band.low != null || band.high != null; + if (!hasBand) projection = "no_band"; + else if (!(lo > 0 || hi < 0)) projection = "no_clear_trend"; + else if ( + (band.low != null && fittedNow < band.low) || + (band.high != null && fittedNow > band.high) + ) { + projection = "already_outside"; + } else { + const bound = fit.slope > 0 ? band.high : band.low; + if (bound == null) projection = "no_exit"; + else { + const xCross = (bound - fit.intercept) / fit.slope; + const yearsAhead = xCross - xNow; + if (yearsAhead > LAB_PROJECTION_HORIZON_YEARS) { + projection = "beyond_horizon"; + } else { + projection = "exits"; + exitBound = fit.slope > 0 ? "high" : "low"; + exitDate = dayOf(t0 + xCross * YEAR_DAYS * DAY_MS); + } + } + } + + return { + status: "ok", + n, + firstAt: dayOf(points[0].t), + lastAt: dayOf(points[n - 1].t), + spanDays, + slopePerYear: round(fit.slope, 4), + slopeCi95: [round(lo, 4), round(hi, 4)], + pValue: round(pValue, 6), + fittedNow: round(fittedNow, 4), + asOf: dayOf(now.getTime()), + projection, + exitDate, + exitBound, + }; +} diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index b467b9ba3..8de2be86d 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -77,6 +77,58 @@ const biomarkerRow = z "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); +const labTrend = z + .union([ + z.object({ + status: z.literal("ok"), + n: z.number().int(), + firstAt: z.string().describe("YYYY-MM-DD of the first reading."), + lastAt: z.string().describe("YYYY-MM-DD of the newest reading."), + spanDays: z.number().int(), + slopePerYear: z + .number() + .describe("Least-squares change per year, in the marker's unit."), + slopeCi95: z + .tuple([z.number(), z.number()]) + .describe("95 % interval of the slope (Student t at n − 2 df)."), + pValue: z.number(), + fittedNow: z.number().describe("The line's value on `asOf`."), + asOf: z.string().describe("YYYY-MM-DD the line was evaluated on."), + projection: z.enum([ + "exits", + "beyond_horizon", + "no_exit", + "already_outside", + "no_clear_trend", + "no_band", + ]), + exitDate: z + .string() + .nullable() + .describe( + "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast.", + ), + exitBound: z.enum(["low", "high"]).nullable(), + }), + z.object({ + status: z.literal("insufficient"), + reason: z.enum(["too_few_values", "span_too_short"]), + n: z.number().int(), + spanDays: z.number().int(), + }), + ]) + .meta({ + id: "BiomarkerTrend", + description: + "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within three years. Server-computed; clients render it as given.", + }); + +const biomarkerDetail = biomarkerRow + .extend({ + trend: labTrend.optional().describe("v1.44 — absent on older servers."), + }) + .meta({ id: "BiomarkerDetail" }); + const listResponse = z .object({ biomarkers: z.array(biomarkerRow) }) .meta({ id: "ListBiomarkersResponse" }); @@ -191,7 +243,7 @@ export const biomarkerPaths: NonNullable = { tags: ["Labs"], summary: "Fetch a single biomarker", description: - "Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.", + "Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.", requestParams: { path: z.object({ id: z.string() }) }, responses: { ...recordRefusal(), @@ -199,7 +251,7 @@ export const biomarkerPaths: NonNullable = { description: "Biomarker detail.", content: { "application/json": { - schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"), + schema: dataEnvelope(biomarkerDetail, "GetBiomarkerResponse"), }, }, }, From 709d28523c65d110ec3f2a72eed384fc0279bb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:52:22 +0200 Subject: [PATCH 43/98] fix(settings): forward moved anchors on hash changes too, prove targets exist A same-page link to a moved anchor changes only the hash, so the arrival check never forwarded it; the shell now also listens for hashchange. The legacy-routes guard walks the component tree of every forward target's page and fails when the anchor it forwards to is not rendered there. --- src/__tests__/settings-legacy-routes.test.ts | 91 +++++++++++++++++++ .../__tests__/settings-anchor-forward.test.ts | 37 ++++++++ .../settings/settings-anchor-forward.tsx | 27 +++++- 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 src/components/settings/__tests__/settings-anchor-forward.test.ts diff --git a/src/__tests__/settings-legacy-routes.test.ts b/src/__tests__/settings-legacy-routes.test.ts index ffba71623..db699d3bc 100644 --- a/src/__tests__/settings-legacy-routes.test.ts +++ b/src/__tests__/settings-legacy-routes.test.ts @@ -13,6 +13,9 @@ * A route removed without a redirect, a redirect pointing at a page that no * longer exists, or a forward whose target 404s fails here by name. */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + import { describe, expect, it } from "vitest"; import nextConfig from "../../next.config"; @@ -160,6 +163,74 @@ describe("historical Settings addresses", () => { }); }); +const SRC = join(process.cwd(), "src"); + +/** The component file a Settings page renders from. */ +function entryFilesFor(page: string): string[] { + const parts = page.split("/").filter(Boolean); + if (parts.length === 2) { + const loader = readFileSync( + join(SRC, "components/settings/settings-section-loader.tsx"), + "utf8", + ); + const match = new RegExp( + `(?:^|\\s)"?${parts[1]}"?:\\s*\\(\\)\\s*=>\\s*import\\("@/([^"]+)"\\)`, + "m", + ).exec(loader); + return match ? [resolveModule(match[1]!)].filter(isString) : []; + } + if (parts[1] === "modules") { + return [join(SRC, "components/settings/module-settings-pages.tsx")]; + } + if (parts[1] === "layout") { + return [join(SRC, "components/settings/layout-section.tsx")]; + } + return []; +} + +function isString(value: string | null): value is string { + return value !== null; +} + +function resolveModule(spec: string, from?: string): string | null { + const base = spec.startsWith(".") + ? join(dirname(from!), spec) + : join(SRC, spec.replace(/^@\//, "")); + for (const candidate of [ + base, + `${base}.tsx`, + `${base}.ts`, + join(base, "index.tsx"), + join(base, "index.ts"), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +/** + * Does any component reachable from `entries` render `anchor`, as a card + * header's `anchor` prop or an element id? Walks the component imports only + * (`@/components/...` and relative), which is where a card's anchor lives. + */ +function anchorIsRendered(entries: string[], anchor: string): boolean { + const pattern = new RegExp(`(?:anchor|id)=["{]+"?${anchor}"`); + const seen = new Set(); + const queue = [...entries]; + while (queue.length > 0 && seen.size < 600) { + const file = queue.shift()!; + if (seen.has(file)) continue; + seen.add(file); + const text = readFileSync(file, "utf8"); + if (pattern.test(text)) return true; + for (const m of text.matchAll(/from\s+"((?:@\/components\/|\.)[^"]+)"/g)) { + const next = resolveModule(m[1]!, file); + if (next && !seen.has(next)) queue.push(next); + } + } + return false; +} + describe("anchors forwarded after a card moved", () => { const forwards = Object.entries(SETTINGS_ANCHOR_FORWARDS).flatMap( ([page, anchors]) => @@ -183,6 +254,26 @@ describe("anchors forwarded after a card moved", () => { }, ); + it.each(forwards)( + "$target carries the anchor it is forwarded to", + ({ target }) => { + const [page, anchor] = target.split("#"); + if (!anchor) return; + const entries = entryFilesFor(page!); + expect(entries.length, page).toBeGreaterThan(0); + expect( + anchorIsRendered(entries, anchor), + `${anchor} not found on ${page}`, + ).toBe(true); + }, + ); + + it("the anchor search finds a known anchor and misses a made-up one", () => { + const entries = entryFilesFor("/settings/security"); + expect(anchorIsRendered(entries, "sessions")).toBe(true); + expect(anchorIsRendered(entries, "no-such-anchor-anywhere")).toBe(false); + }); + it("leaves an anchor that did not move alone", () => { expect(forwardedAnchorTarget("/settings/privacy", "#retention")).toBeNull(); expect(forwardedAnchorTarget("/settings/security", "#sessions")).toBeNull(); diff --git a/src/components/settings/__tests__/settings-anchor-forward.test.ts b/src/components/settings/__tests__/settings-anchor-forward.test.ts new file mode 100644 index 000000000..e8f9b6707 --- /dev/null +++ b/src/components/settings/__tests__/settings-anchor-forward.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/settings/privacy", + useRouter: () => ({ replace: vi.fn() }), +})); + +import { watchAnchorForwards } from "../settings-anchor-forward"; + +function fakeWindow(hash: string) { + const target = new EventTarget(); + return Object.assign(target, { location: { hash } }); +} + +describe("watchAnchorForwards", () => { + it("forwards a moved anchor on arrival", () => { + const replace = vi.fn(); + watchAnchorForwards("/settings/privacy", replace, fakeWindow("#sessions")); + expect(replace).toHaveBeenCalledWith("/settings/security#sessions"); + }); + + it("forwards when a same-page link changes only the hash", () => { + const replace = vi.fn(); + const win = fakeWindow("#retention"); + const stop = watchAnchorForwards("/settings/privacy", replace, win); + expect(replace).not.toHaveBeenCalled(); + + win.location.hash = "#trusted-devices"; + win.dispatchEvent(new Event("hashchange")); + expect(replace).toHaveBeenCalledWith("/settings/security#trusted-devices"); + + stop(); + win.location.hash = "#sessions"; + win.dispatchEvent(new Event("hashchange")); + expect(replace).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/settings/settings-anchor-forward.tsx b/src/components/settings/settings-anchor-forward.tsx index 247c4213b..a2ebbb68d 100644 --- a/src/components/settings/settings-anchor-forward.tsx +++ b/src/components/settings/settings-anchor-forward.tsx @@ -7,7 +7,8 @@ * moved while its page stayed cannot be: the `#anchor` never reaches the * server. Bookmarks, cross-links in older notifications and the docs point at * `/settings/privacy#sessions` and the like, so the shell forwards a known - * anchor on a known page to the card's new home, once, on arrival. + * anchor on a known page to the card's new home, on arrival and when a + * same-page link changes only the hash. * * `SETTINGS_ANCHOR_FORWARDS` is also read by the legacy-routes guard, which * proves every target lands on a page that exists. @@ -56,14 +57,34 @@ export function forwardedAnchorTarget( return SETTINGS_ANCHOR_FORWARDS[pathname]?.[anchor] ?? null; } +/** + * Forward now, and again whenever only the hash changes: a same-page link to + * a moved anchor is no navigation, so an arrival check alone never sees it. + * Returns the unsubscribe. + */ +export function watchAnchorForwards( + pathname: string, + replace: (target: string) => void, + win: Pick & { + location: { hash: string }; + } = window, +): () => void { + const forward = () => { + const target = forwardedAnchorTarget(pathname, win.location.hash); + if (target) replace(target); + }; + forward(); + win.addEventListener("hashchange", forward); + return () => win.removeEventListener("hashchange", forward); +} + export function SettingsAnchorForward() { const pathname = usePathname(); const router = useRouter(); useEffect(() => { if (!pathname) return; - const target = forwardedAnchorTarget(pathname, window.location.hash); - if (target) router.replace(target); + return watchAnchorForwards(pathname, (target) => router.replace(target)); }, [pathname, router]); return null; From 9e2a0681b2bf6e036b647e123d6c442f31207a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:55:57 +0200 Subject: [PATCH 44/98] feat(doctor-report): add sleep, HRV, steps, workouts and the medication effect The standard report template now carries sleep duration, heart-rate variability as two labelled rows (SDNN and RMSSD), daily steps, a per-sport workout summary and the change around each medication start. Each still rides its module, and the template is still only applied by the button that names it; saved selections and share links are untouched. Two structured leaves join the catalogue: WORKOUTS (activity group, workouts module) and MEDICATION_EFFECT (medications group). The workout block reads narrow rows through the canonical-workout picker and sums them per sport. The effect block reuses the efficacy loader with the report's end as its horizon, carries a target only when that target's own measurement leaf is admitted, and is capped at eight medications. In the PDF the wearable series chart at half width, two to a row, in the units the table prints; the workout and effect blocks are compact tables with short notes. The clinician view renders both blocks from the same payload. RMSSD joins the dense day-bucket path so long windows stay aggregated. --- docs/api/openapi.yaml | 6 +- messages/de.json | 38 ++- messages/en.json | 38 ++- messages/es.json | 38 ++- messages/fr.json | 38 ++- messages/it.json | 38 ++- messages/ko.json | 38 ++- messages/nl.json | 38 ++- messages/pl.json | 38 ++- ...doctor-report-control-gating-guard.test.ts | 4 +- src/__tests__/i18n-english-leak-guard.test.ts | 2 + .../share-view-leaf-render-guard.test.ts | 67 +++- .../unit-preference-display-guard.test.ts | 10 + .../workout-canonical-identity-guard.test.ts | 3 + .../health-record/__tests__/route.test.ts | 2 + .../clinician/activity-effect-sections.tsx | 192 +++++++++++ src/components/clinician/clinician-view.tsx | 18 + src/components/clinician/report-sections.tsx | 4 +- .../__tests__/report-selection-panel.test.tsx | 4 +- src/lib/doctor-report-pdf-core.ts | 2 + .../activity-effect-sections.test.ts | 312 ++++++++++++++++++ .../activity-effect-sections.ts | 287 ++++++++++++++++ src/lib/doctor-report-pdf/clinical-summary.ts | 18 +- .../measurements-charts-section.ts | 43 ++- .../medication-mood-wellness-section.ts | 4 + src/lib/doctor-report-types.ts | 51 +++ .../__tests__/leaf-gating-sweep.test.ts | 11 + .../__tests__/medication-effects.test.ts | 127 +++++++ src/lib/doctor-report/collect.ts | 30 ++ src/lib/doctor-report/medication-effects.ts | 74 +++++ src/lib/doctor-report/sport-label.ts | 19 ++ src/lib/doctor-report/workouts.ts | 125 +++++++ .../__tests__/catalogue-guard.test.ts | 23 +- src/lib/report-selection/catalogue.ts | 39 ++- src/lib/report-selection/template.ts | 26 +- 35 files changed, 1760 insertions(+), 47 deletions(-) create mode 100644 src/components/clinician/activity-effect-sections.tsx create mode 100644 src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts create mode 100644 src/lib/doctor-report-pdf/activity-effect-sections.ts create mode 100644 src/lib/doctor-report/__tests__/medication-effects.test.ts create mode 100644 src/lib/doctor-report/medication-effects.ts create mode 100644 src/lib/doctor-report/sport-label.ts create mode 100644 src/lib/doctor-report/workouts.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a5bea3f73..356e42699 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26155,7 +26155,7 @@ components: type: number const: 2 leaves: - maxItems: 104 + maxItems: 106 type: array items: type: string @@ -26215,7 +26215,7 @@ components: type: number const: 2 leaves: - maxItems: 104 + maxItems: 106 type: array items: type: string @@ -29248,7 +29248,7 @@ components: type: number const: 2 leaves: - maxItems: 104 + maxItems: 106 type: array items: type: string diff --git a/messages/de.json b/messages/de.json index 365d2274c..eac8cfe2e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours} Stunden, Selbstauskunft", "lifeWork": "Arbeit", "lifeNightWork": "Nachtarbeit", - "lifeExposures": "Belastungen: {list}" + "lifeExposures": "Belastungen: {list}", + "workouts": { + "title": "Trainings", + "summary": "{sessions} Einheiten, insgesamt {hours} h; im Schnitt {perWeek} Einheiten und {minutesPerWeek} Minuten pro Woche.", + "colSport": "Sportart", + "colSessions": "Einheiten", + "colTime": "Dauer", + "colDistance": "Strecke", + "colEnergy": "Energie", + "colHeartRate": "Ø Herzfrequenz", + "moreSports": "{count} weitere Sportarten sind in den Summen enthalten, aber nicht einzeln aufgeführt." + }, + "medicationEffect": { + "title": "Veränderung rund um den Start der Medikamente", + "colMedication": "Medikament / Messwert", + "colStart": "Start", + "colLevel": "Niveauänderung (95-%-KI)", + "colSlope": "Trendänderung (95-%-KI)", + "colDays": "Tage vor / nach", + "to": "bis", + "week": "Woche", + "reasonNoStart": "Kein Startdatum erfasst", + "reasonBefore": "Zu wenige Werte vor dem Start", + "reasonAfter": "Zu wenige Werte seit dem Start", + "reasonFit": "Nicht berechenbar", + "excludedAdherence": "{count} Tage nach dem Start ausgelassen (unter {floor} % der Dosen eingenommen)", + "excludedNotTaking": "{count} Tage ausgelassen (pausiert oder zwischen Einnahmezeiträumen)", + "notTracked": "Einnahme nicht erfasst, daher keine Tage wegen ausgelassener Dosen entfernt", + "endDoseChange": "Zeitraum endet am {date}, vor der ersten Dosisänderung", + "days": "{before} Tage vor, {after} Tage nach dem Start, {excluded} Tage ausgelassen", + "method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments." + } }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -12314,7 +12345,10 @@ "leafVisits": "Arztbesuche", "leafSurgicalHistory": "Operationen und Eingriffe", "leafImmunizations": "Impfungen", - "leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)" + "leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)", + "leafMedicationEffect": "Veränderung rund um den Start jedes Medikaments", + "leafWorkouts": "Trainings (Übersicht je Sportart)", + "leafHrvSdnn": "HRV (SDNN)" }, "recordSharing": { "banner": { diff --git a/messages/en.json b/messages/en.json index 4881ff789..f20f1394d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours} hours, self-reported", "lifeWork": "Work", "lifeNightWork": "night work", - "lifeExposures": "exposures: {list}" + "lifeExposures": "exposures: {list}", + "workouts": { + "title": "Workouts", + "summary": "{sessions} sessions, {hours} h in total; on average {perWeek} sessions and {minutesPerWeek} minutes per week.", + "colSport": "Sport", + "colSessions": "Sessions", + "colTime": "Time", + "colDistance": "Distance", + "colEnergy": "Energy", + "colHeartRate": "Avg heart rate", + "moreSports": "{count} more sports are counted in the totals but not listed." + }, + "medicationEffect": { + "title": "Change around each medication start", + "colMedication": "Medication / measure", + "colStart": "Start", + "colLevel": "Level change (95 % CI)", + "colSlope": "Trend change (95 % CI)", + "colDays": "Days before / after", + "to": "to", + "week": "week", + "reasonNoStart": "No start date recorded", + "reasonBefore": "Too few readings before the start", + "reasonAfter": "Too few readings since the start", + "reasonFit": "Could not be computed", + "excludedAdherence": "{count} days after the start left out (under {floor} % of doses taken)", + "excludedNotTaking": "{count} days left out (paused or between courses)", + "notTracked": "intake not tracked, so no days left out for missed doses", + "endDoseChange": "window ends {date}, before the first dose change", + "days": "{before} days before, {after} days after the start, {excluded} days left out", + "method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication." + } }, "errorBoundary": { "title": "Something went wrong", @@ -12314,7 +12345,10 @@ "leafVisits": "Visits", "leafSurgicalHistory": "Surgical history", "leafImmunizations": "Immunizations", - "leafEmergency": "Emergency information (blood type, contacts, directives)" + "leafEmergency": "Emergency information (blood type, contacts, directives)", + "leafMedicationEffect": "Change around each medication start", + "leafWorkouts": "Workouts (summary per sport)", + "leafHrvSdnn": "HRV (SDNN)" }, "recordSharing": { "banner": { diff --git a/messages/es.json b/messages/es.json index 449ea8372..bd5c8f2e9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours} horas, declarado", "lifeWork": "Trabajo", "lifeNightWork": "trabajo nocturno", - "lifeExposures": "exposiciones: {list}" + "lifeExposures": "exposiciones: {list}", + "workouts": { + "title": "Entrenamientos", + "summary": "{sessions} sesiones, {hours} h en total; de media {perWeek} sesiones y {minutesPerWeek} minutos por semana.", + "colSport": "Deporte", + "colSessions": "Sesiones", + "colTime": "Tiempo", + "colDistance": "Distancia", + "colEnergy": "Energía", + "colHeartRate": "FC media", + "moreSports": "{count} deportes más se cuentan en los totales, pero no se enumeran." + }, + "medicationEffect": { + "title": "Cambio en torno al inicio de cada medicamento", + "colMedication": "Medicamento / medida", + "colStart": "Inicio", + "colLevel": "Cambio de nivel (IC 95 %)", + "colSlope": "Cambio de tendencia (IC 95 %)", + "colDays": "Días antes / después", + "to": "a", + "week": "semana", + "reasonNoStart": "Sin fecha de inicio registrada", + "reasonBefore": "Muy pocos valores antes del inicio", + "reasonAfter": "Muy pocos valores desde el inicio", + "reasonFit": "No se pudo calcular", + "excludedAdherence": "{count} días después del inicio omitidos (menos del {floor} % de las dosis tomadas)", + "excludedNotTaking": "{count} días omitidos (en pausa o entre tandas)", + "notTracked": "toma no registrada, así que no se omitió ningún día por dosis olvidadas", + "endDoseChange": "el periodo termina el {date}, antes del primer cambio de dosis", + "days": "{before} días antes, {after} días después del inicio, {excluded} días omitidos", + "method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento." + } }, "errorBoundary": { "title": "Algo ha salido mal", @@ -12314,7 +12345,10 @@ "leafVisits": "Visitas médicas", "leafSurgicalHistory": "Antecedentes quirúrgicos", "leafImmunizations": "Vacunas", - "leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)" + "leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)", + "leafMedicationEffect": "Cambio en torno al inicio de cada medicamento", + "leafWorkouts": "Entrenamientos (resumen por deporte)", + "leafHrvSdnn": "VFC (SDNN)" }, "recordSharing": { "banner": { diff --git a/messages/fr.json b/messages/fr.json index d9c64674a..5ec7d0e56 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours} heures, déclaré", "lifeWork": "Travail", "lifeNightWork": "travail de nuit", - "lifeExposures": "expositions : {list}" + "lifeExposures": "expositions : {list}", + "workouts": { + "title": "Entraînements", + "summary": "{sessions} séances, {hours} h au total ; en moyenne {perWeek} séances et {minutesPerWeek} minutes par semaine.", + "colSport": "Sport", + "colSessions": "Séances", + "colTime": "Durée", + "colDistance": "Distance", + "colEnergy": "Énergie", + "colHeartRate": "FC moyenne", + "moreSports": "{count} autres sports sont inclus dans les totaux, sans être listés." + }, + "medicationEffect": { + "title": "Changement autour du démarrage des médicaments", + "colMedication": "Médicament / mesure", + "colStart": "Début", + "colLevel": "Changement de niveau (IC 95 %)", + "colSlope": "Changement de tendance (IC 95 %)", + "colDays": "Jours avant / après", + "to": "à", + "week": "semaine", + "reasonNoStart": "Aucune date de début enregistrée", + "reasonBefore": "Trop peu de valeurs avant le démarrage", + "reasonAfter": "Trop peu de valeurs depuis le démarrage", + "reasonFit": "Calcul impossible", + "excludedAdherence": "{count} jours après le démarrage écartés (moins de {floor} % des doses prises)", + "excludedNotTaking": "{count} jours écartés (en pause ou entre deux périodes de prise)", + "notTracked": "prises non suivies, aucun jour écarté pour des doses manquées", + "endDoseChange": "la période s'arrête le {date}, avant le premier changement de dose", + "days": "{before} jours avant, {after} jours après le démarrage, {excluded} jours écartés", + "method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament." + } }, "errorBoundary": { "title": "Une erreur est survenue", @@ -12314,7 +12345,10 @@ "leafVisits": "Consultations", "leafSurgicalHistory": "Antécédents chirurgicaux", "leafImmunizations": "Vaccinations", - "leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)" + "leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)", + "leafMedicationEffect": "Changement autour du démarrage de chaque médicament", + "leafWorkouts": "Entraînements (résumé par sport)", + "leafHrvSdnn": "VFC (SDNN)" }, "recordSharing": { "banner": { diff --git a/messages/it.json b/messages/it.json index 15be9a0fa..d9d5d124d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours} ore, dichiarate", "lifeWork": "Lavoro", "lifeNightWork": "lavoro notturno", - "lifeExposures": "esposizioni: {list}" + "lifeExposures": "esposizioni: {list}", + "workouts": { + "title": "Allenamenti", + "summary": "{sessions} sessioni, {hours} h in totale; in media {perWeek} sessioni e {minutesPerWeek} minuti a settimana.", + "colSport": "Sport", + "colSessions": "Sessioni", + "colTime": "Durata", + "colDistance": "Distanza", + "colEnergy": "Energia", + "colHeartRate": "FC media", + "moreSports": "Altri {count} sport sono inclusi nei totali ma non elencati." + }, + "medicationEffect": { + "title": "Variazione intorno all'inizio dei farmaci", + "colMedication": "Farmaco / misura", + "colStart": "Inizio", + "colLevel": "Variazione di livello (IC 95 %)", + "colSlope": "Variazione di tendenza (IC 95 %)", + "colDays": "Giorni prima / dopo", + "to": "a", + "week": "settimana", + "reasonNoStart": "Nessuna data di inizio registrata", + "reasonBefore": "Troppo pochi valori prima dell'inizio", + "reasonAfter": "Troppo pochi valori dall'inizio", + "reasonFit": "Non calcolabile", + "excludedAdherence": "{count} giorni dopo l'inizio esclusi (meno del {floor} % delle dosi assunte)", + "excludedNotTaking": "{count} giorni esclusi (in pausa o tra due cicli)", + "notTracked": "assunzione non registrata, quindi nessun giorno escluso per dosi saltate", + "endDoseChange": "il periodo termina il {date}, prima della prima variazione di dose", + "days": "{before} giorni prima, {after} giorni dopo l'inizio, {excluded} giorni esclusi", + "method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco." + } }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -12314,7 +12345,10 @@ "leafVisits": "Visite mediche", "leafSurgicalHistory": "Anamnesi chirurgica", "leafImmunizations": "Vaccinazioni", - "leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)" + "leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)", + "leafMedicationEffect": "Variazione intorno all'inizio di ogni farmaco", + "leafWorkouts": "Allenamenti (riepilogo per sport)", + "leafHrvSdnn": "HRV (SDNN)" }, "recordSharing": { "banner": { diff --git a/messages/ko.json b/messages/ko.json index d943eb2f3..c4460503e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours}시간, 본인 응답", "lifeWork": "일", "lifeNightWork": "야간 근무", - "lifeExposures": "노출: {list}" + "lifeExposures": "노출: {list}", + "workouts": { + "title": "운동", + "summary": "총 {sessions}회, {hours}시간; 주당 평균 {perWeek}회, {minutesPerWeek}분.", + "colSport": "종목", + "colSessions": "횟수", + "colTime": "시간", + "colDistance": "거리", + "colEnergy": "에너지", + "colHeartRate": "평균 심박수", + "moreSports": "{count}개 종목이 합계에는 포함되지만 목록에는 없습니다." + }, + "medicationEffect": { + "title": "약 시작 전후의 변화", + "colMedication": "약 / 측정값", + "colStart": "시작", + "colLevel": "수준 변화(95% CI)", + "colSlope": "추세 변화(95% CI)", + "colDays": "전 / 후 일수", + "to": "~", + "week": "주", + "reasonNoStart": "기록된 시작일 없음", + "reasonBefore": "시작 전 측정값이 너무 적음", + "reasonAfter": "시작 후 측정값이 너무 적음", + "reasonFit": "계산할 수 없음", + "excludedAdherence": "시작 후 {count}일 제외(복용률 {floor}% 미만)", + "excludedNotTaking": "{count}일 제외(일시 중지 또는 복용 기간 사이)", + "notTracked": "복용 미기록으로 놓친 용량에 따른 제외 없음", + "endDoseChange": "기간은 첫 용량 변경 전인 {date}에 끝남", + "days": "시작 전 {before}일, 시작 후 {after}일, 제외 {excluded}일", + "method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다." + } }, "errorBoundary": { "title": "문제가 생겼어요", @@ -12314,7 +12345,10 @@ "leafVisits": "진료", "leafSurgicalHistory": "수술 이력", "leafImmunizations": "예방접종", - "leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)" + "leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)", + "leafMedicationEffect": "각 약 시작 전후의 변화", + "leafWorkouts": "운동(종목별 요약)", + "leafHrvSdnn": "심박변이도 (SDNN)" }, "recordSharing": { "banner": { diff --git a/messages/nl.json b/messages/nl.json index 2bd95f72c..804cc5662 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours} uur, zelf opgegeven", "lifeWork": "Werk", "lifeNightWork": "nachtwerk", - "lifeExposures": "blootstellingen: {list}" + "lifeExposures": "blootstellingen: {list}", + "workouts": { + "title": "Trainingen", + "summary": "{sessions} trainingen, {hours} u in totaal; gemiddeld {perWeek} trainingen en {minutesPerWeek} minuten per week.", + "colSport": "Sport", + "colSessions": "Trainingen", + "colTime": "Duur", + "colDistance": "Afstand", + "colEnergy": "Energie", + "colHeartRate": "Gem. hartslag", + "moreSports": "{count} andere sporten tellen mee in de totalen, maar staan niet in de lijst." + }, + "medicationEffect": { + "title": "Verandering rond de start van medicijnen", + "colMedication": "Medicijn / meetwaarde", + "colStart": "Start", + "colLevel": "Niveauverandering (95%-BI)", + "colSlope": "Trendverandering (95%-BI)", + "colDays": "Dagen voor / na", + "to": "tot", + "week": "week", + "reasonNoStart": "Geen startdatum vastgelegd", + "reasonBefore": "Te weinig waarden vóór de start", + "reasonAfter": "Te weinig waarden sinds de start", + "reasonFit": "Niet te berekenen", + "excludedAdherence": "{count} dagen na de start weggelaten (minder dan {floor}% van de doses ingenomen)", + "excludedNotTaking": "{count} dagen weggelaten (gepauzeerd of tussen kuren)", + "notTracked": "inname niet bijgehouden, dus geen dagen weggelaten om gemiste doses", + "endDoseChange": "periode eindigt op {date}, vóór de eerste dosiswijziging", + "days": "{before} dagen voor, {after} dagen na de start, {excluded} dagen weggelaten", + "method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn." + } }, "errorBoundary": { "title": "Er is iets misgegaan", @@ -12314,7 +12345,10 @@ "leafVisits": "Bezoeken", "leafSurgicalHistory": "Operatiegeschiedenis", "leafImmunizations": "Vaccinaties", - "leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)" + "leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)", + "leafMedicationEffect": "Verandering rond de start van elk medicijn", + "leafWorkouts": "Trainingen (overzicht per sport)", + "leafHrvSdnn": "HRV (SDNN)" }, "recordSharing": { "banner": { diff --git a/messages/pl.json b/messages/pl.json index 2467c66a8..e585a2ff6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -401,7 +401,38 @@ "lifeSittingValue": "{hours} godz., według deklaracji", "lifeWork": "Praca", "lifeNightWork": "praca w nocy", - "lifeExposures": "narażenia: {list}" + "lifeExposures": "narażenia: {list}", + "workouts": { + "title": "Treningi", + "summary": "{sessions} treningów, łącznie {hours} h; średnio {perWeek} treningu i {minutesPerWeek} minut tygodniowo.", + "colSport": "Dyscyplina", + "colSessions": "Treningi", + "colTime": "Czas", + "colDistance": "Dystans", + "colEnergy": "Energia", + "colHeartRate": "Średnie tętno", + "moreSports": "Kolejne dyscypliny ({count}) są wliczone do sum, ale nie wymienione." + }, + "medicationEffect": { + "title": "Zmiana wokół rozpoczęcia leków", + "colMedication": "Lek / pomiar", + "colStart": "Początek", + "colLevel": "Zmiana poziomu (95% CI)", + "colSlope": "Zmiana trendu (95% CI)", + "colDays": "Dni przed / po", + "to": "do", + "week": "tydzień", + "reasonNoStart": "Brak zapisanej daty rozpoczęcia", + "reasonBefore": "Za mało pomiarów przed rozpoczęciem", + "reasonAfter": "Za mało pomiarów od rozpoczęcia", + "reasonFit": "Nie można obliczyć", + "excludedAdherence": "Pominięto {count} dni po rozpoczęciu (przyjęto mniej niż {floor}% dawek)", + "excludedNotTaking": "Pominięto {count} dni (lek wstrzymany lub przerwa między kuracjami)", + "notTracked": "przyjmowanie nie jest rejestrowane, więc nie pominięto dni z powodu opuszczonych dawek", + "endDoseChange": "okres kończy się {date}, przed pierwszą zmianą dawki", + "days": "{before} dni przed, {after} dni po rozpoczęciu, pominięto {excluded} dni", + "method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku." + } }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -12314,7 +12345,10 @@ "leafVisits": "Wizyty lekarskie", "leafSurgicalHistory": "Historia operacji", "leafImmunizations": "Szczepienia", - "leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)" + "leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)", + "leafMedicationEffect": "Zmiana wokół rozpoczęcia każdego leku", + "leafWorkouts": "Treningi (podsumowanie według dyscypliny)", + "leafHrvSdnn": "HRV (SDNN)" }, "recordSharing": { "banner": { diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index b0763a13f..1c2d51c0c 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(104); + expect(ALL_LEAF_IDS.length).toBe(106); expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86); - expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20); }); it("places every leaf in exactly one group", () => { diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index b091256da..9bda44703 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -98,6 +98,8 @@ const LEGIT_IDENTICAL = new Set([ "insights.derived.composite.READINESS.component.hrv", "labs.catalog.hs-crp", "measurements.typeHrvRmssd", + // The report's SDNN row, named the way the RMSSD row above is. + "reportSelection.leafHrvSdnn", "illness.vital.HRV_RMSSD", "settings.sections.sources.metrics.hrvRmssd", "measurements.typeVo2Max", diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index 841f63ffa..bd50f93f0 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -13,7 +13,7 @@ * is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real * `` over a real fixture. It proves four things per leaf. * - * 1. `DISPOSITIONS` is `Record`, so a nineteenth + * 1. `DISPOSITIONS` is `Record`, so a new * structured leaf cannot be added to the catalogue without deciding here * whether it renders — and saying so — or `pnpm typecheck` fails. That is * the lock, and it is the compiler's, not this suite's. @@ -291,6 +291,69 @@ const DISPOSITIONS: Record = { }, marker: "Semaglutide", }, + MEDICATION_EFFECT: { + kind: "rendered", + data: { + medicationEffects: [ + { + medicationName: "Effect-marker drug", + startsOn: "2026-01-02", + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + present: false, + reason: "insufficient_after", + level: 0.95, + window: null, + days: { + before: 40, + after: 3, + excludedLowAdherence: 0, + excludedNotTaking: 0, + }, + adherence: { + status: "tracked", + floorPct: 80, + lookbackDays: 14, + }, + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + confounders: [], + }, + }, + ], + }, + ], + }, + marker: "Effect-marker drug", + }, + WORKOUTS: { + kind: "rendered", + data: { + workouts: { + sessions: 3, + totalDurationSec: 7200, + sessionsPerWeek: 0.7, + minutesPerWeek: 28, + sportCount: 1, + bySport: [ + { + sportType: "rowing", + sessions: 3, + durationSec: 7200, + distanceM: 15000, + distanceSessions: 3, + energyKcal: null, + avgHeartRate: null, + }, + ], + }, + }, + marker: "15.0 km", + }, ALLERGIES: { kind: "rendered", data: { @@ -444,7 +507,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", ( it("reads a plausible catalogue", () => { // Sanity floor: a degraded import would satisfy every loop below // vacuously, which is the failure mode a pair guard dies of. - expect(STRUCTURED_LEAF_IDS).toHaveLength(18); + expect(STRUCTURED_LEAF_IDS).toHaveLength(20); expect(Object.keys(DISPOSITIONS).sort()).toEqual( [...STRUCTURED_LEAF_IDS].sort(), ); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index b5febe503..df2d41130 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -361,6 +361,16 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)", }, + "lib/doctor-report-pdf/activity-effect-sections.ts": { + lines: 2, + reason: + "SI by policy: the report's workout distance is in km, like every clinician artefact", + }, + "components/clinician/activity-effect-sections.tsx": { + lines: 1, + reason: + "SI by policy: the clinician page's workout distance is in km, like the PDF", + }, "components/insights/workout-detail/splits.tsx": { lines: 1, reason: "kilometre splits are cut server-side; their pace is per km", diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 416faa7e5..84d221c5b 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record = { "lib/jobs/workout-insight-generate.ts", "lib/workouts/list-read.ts": "lib/workouts/list-read.ts", "lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts", + // The doctor report's per-sport block (v1.44): sums sessions, so a session + // two sources reported must count once, as it does on the list. + "lib/doctor-report/workouts.ts": "lib/doctor-report/workouts.ts", // The only indirection in the set. The Coach block is handed rows it does not // read itself; the snapshot builder fetches them in parallel with every other // block's data. Recording the owner here rather than assuming the call site diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 0a768cb86..861553985 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -38,6 +38,8 @@ vi.mock("@/lib/db", () => ({ healthProfileFactRevision: { findMany: vi.fn() }, // v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry. mentalHealthAssessment: { findMany: vi.fn(async () => []) }, + // v1.44 — the per-sport workout block. + workout: { findMany: vi.fn(async () => []) }, user: { findUnique: vi.fn(), update: vi.fn() }, // The whole-record gate reads the session's own stamps: a sign-in a // moment ago is the proof, on an account without a second factor. diff --git a/src/components/clinician/activity-effect-sections.tsx b/src/components/clinician/activity-effect-sections.tsx new file mode 100644 index 000000000..3f62c9878 --- /dev/null +++ b/src/components/clinician/activity-effect-sections.tsx @@ -0,0 +1,192 @@ +/** + * The two leaves the v1.44 report added, on the clinician page: the per-sport + * workout summary and the medication effect around each start. Content and + * order follow the PDF blocks in `doctor-report-pdf/activity-effect-sections`, + * and both read the same aggregated payload. + * + * A pure server component: no client hooks, no markdown — every value renders + * as escaped React text. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { reportStatUnit } from "@/lib/doctor-report/stat-display"; +import { resolveGlucoseUnit } from "@/lib/glucose"; +import { formatEstimate } from "@/lib/medications/efficacy/segmented-format"; +import { reportMeasurementLabelKey } from "@/lib/report-selection/catalogue"; +import { reportSportLabel } from "@/lib/doctor-report/sport-label"; +import { + LeafSection, + StatRow, + type LeafScope, + type Translate, +} from "./report-sections"; + +const REASON_KEYS = { + no_start: "doctorReport.medicationEffect.reasonNoStart", + insufficient_before: "doctorReport.medicationEffect.reasonBefore", + insufficient_after: "doctorReport.medicationEffect.reasonAfter", + fit_failed: "doctorReport.medicationEffect.reasonFit", +} as const; + +const CONFOUNDER_KEYS = { + started: "medications.efficacy.segmented.confounderStarted", + stopped: "medications.efficacy.segmented.confounderStopped", + dose_change: "medications.efficacy.segmented.confounderDoseChange", + paused: "medications.efficacy.segmented.confounderPaused", + resumed: "medications.efficacy.segmented.confounderResumed", +} as const; + +export function WorkoutsSection({ + t, + report, + scope, + fmtDigits, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDigits: (n: number, digits: number) => string; +}) { + const w = report.workouts ?? null; + return ( + + {w ? ( + <> +

+ {t("doctorReport.workouts.summary", { + sessions: w.sessions, + hours: fmtDigits(w.totalDurationSec / 3600, 1), + perWeek: fmtDigits(w.sessionsPerWeek, 1), + minutesPerWeek: fmtDigits(w.minutesPerWeek, 0), + })} +

+ {w.bySport.map((s) => ( + + ))} + {w.sportCount > w.bySport.length ? ( +

+ {t("doctorReport.workouts.moreSports", { + count: w.sportCount - w.bySport.length, + })} +

+ ) : null} + + ) : null} +
+ ); +} + +export function MedicationEffectSection({ + t, + report, + scope, + fmtDigits, + fmtDay, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDigits: (n: number, digits: number) => string; + /** A stated calendar day (YYYY-MM-DD) in the owner's date order. */ + fmtDay: (day: string) => string; +}) { + const effects = report.medicationEffects ?? []; + const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); + return ( + + {effects.flatMap((med) => + med.targets.map((target) => { + const e = target.effect; + const label = `${med.medicationName}: ${t( + reportMeasurementLabelKey(target.type as MeasurementType), + )}`; + const unit = reportStatUnit(target.type, glucoseUnit, t); + if (!e.present || !e.levelChange || !e.slopeChangePerWeek) { + return ( + + ); + } + const level = formatEstimate(e.levelChange, fmtDigits); + const slope = formatEstimate(e.slopeChangePerWeek, fmtDigits); + return ( +
+

{label}

+

+ {t("medications.efficacy.segmented.level", { + ...level, + unit, + })} +

+

+ {t("medications.efficacy.segmented.slope", { + ...slope, + unit, + })} +

+

+ {t("doctorReport.medicationEffect.days", { + before: e.days.before, + after: e.days.after, + excluded: + e.days.excludedLowAdherence + e.days.excludedNotTaking, + })} +

+ {e.confounders.map((c) => ( +

+ {t(CONFOUNDER_KEYS[c.event], { + name: c.medicationName, + date: fmtDay(c.date), + })} +

+ ))} +
+ ); + }), + )} + {effects.length > 0 ? ( +

+ {t("doctorReport.medicationEffect.method")} +

+ ) : null} +
+ ); +} diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index d7ab42b1f..2be46e88b 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -52,6 +52,10 @@ import { VisitsSection, } from "./history-sections"; import { DoseLogSection, Glp1Section } from "./therapy-sections"; +import { + MedicationEffectSection, + WorkoutsSection, +} from "./activity-effect-sections"; import { CycleSection, FamilyHistorySection, @@ -219,6 +223,13 @@ export function ClinicianView({
) : null} + + {/* ── glucose and labs ─────────────────────────────────── */} + fmt.dateShortSmartCalendar(day)} + /> — the first run", () => { expect(groupChip(html, "vitals")).toBe("0/7"); expect(groupChip(html, "body")).toBe("0/12"); expect(groupChip(html, "labs")).toBe("0/1"); - expect(groupChip(html, "medications")).toBe("0/4"); + expect(groupChip(html, "medications")).toBe("0/5"); // ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS. expect(groupChip(html, "history")).toBe("0/5"); expect(groupChip(html, "cardio")).toBe("0/15"); - expect(groupChip(html, "activity")).toBe("0/11"); + expect(groupChip(html, "activity")).toBe("0/12"); expect(groupChip(html, "sleepRecovery")).toBe("0/16"); expect(groupChip(html, "mobility")).toBe("0/11"); expect(groupChip(html, "environment")).toBe("0/7"); diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 447ec726a..bdf82ce9d 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -36,6 +36,7 @@ import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-fi import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section"; import { buildMeasurementsChartsSection } from "./doctor-report-pdf/measurements-charts-section"; import { buildMedicationMoodWellnessSection } from "./doctor-report-pdf/medication-mood-wellness-section"; +import { buildWorkoutsSection } from "./doctor-report-pdf/activity-effect-sections"; import { buildClinicalRecordsNotesSection } from "./doctor-report-pdf/clinical-records-notes-section"; import { buildReportFooter } from "./doctor-report-pdf/footer-section"; import { @@ -294,6 +295,7 @@ export function buildDoctorReportPdfDocument( } cursor = buildHeaderProfileSection(context, cursor); cursor = buildMeasurementsChartsSection(context, cursor); + cursor = buildWorkoutsSection(context, cursor); cursor = buildMedicationMoodWellnessSection(context, cursor); cursor = buildClinicalRecordsNotesSection(context, cursor); buildReportFooter(context, cursor); diff --git a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts new file mode 100644 index 000000000..a2c0ac544 --- /dev/null +++ b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts @@ -0,0 +1,312 @@ +/** + * The v1.44 report blocks in the rendered PDF: sleep, both heart-rate + * variability statistics under their own labels, steps, the per-sport workout + * block and the medication effect, plus the per-sport fold behind the workout + * block. Text is read back out of the real PDF bytes. + */ +import { describe, expect, it } from "vitest"; +import { PDFParse } from "pdf-parse"; + +import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { summariseReportWorkouts } from "@/lib/doctor-report/workouts"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; + +const NOW = new Date("2026-05-03T12:00:00.000Z"); + +async function textOf(bytes: Uint8Array): Promise { + const parser = new PDFParse({ data: bytes }); + try { + return (await parser.getText()).text; + } finally { + await parser.destroy(); + } +} + +function series(values: number[]) { + return values.map((value, i) => ({ + value, + measuredAt: new Date( + Date.parse("2026-03-01T06:00:00.000Z") + i * 86_400_000, + ).toISOString(), + })); +} + +function stat(values: number[]) { + return { + avg: values.reduce((a, b) => a + b, 0) / values.length, + min: Math.min(...values), + max: Math.max(...values), + count: values.length, + latest: values[values.length - 1], + }; +} + +const EFFECT: EfficacySegmented = { + present: true, + level: 0.95, + window: { from: "2025-12-02", to: "2026-04-10", endReason: "dose_change" }, + days: { + before: 84, + after: 61, + excludedLowAdherence: 5, + excludedNotTaking: 0, + }, + adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 }, + levelChange: { + estimate: -7.34, + ciLow: -8.52, + ciHigh: -6.15, + standardError: 0.6, + includesZero: false, + }, + slopeChangePerWeek: { + estimate: -0.26, + ciLow: -0.58, + ciHigh: 0.07, + standardError: 0.16, + includesZero: true, + }, + preSlopePerWeek: { + estimate: 0.1, + ciLow: -0.1, + ciHigh: 0.3, + standardError: 0.1, + includesZero: true, + }, + fit: { + n: 145, + df: 141, + rSquared: 0.55, + residualAutocorrelation: 0.31, + hacLag: 4, + }, + confounders: [ + { medicationName: "Other drug", event: "started", date: "2026-02-14" }, + ], +}; + +function data(overrides: Partial = {}): DoctorReportData { + const sleep = [400, 430, 455, 470, 410]; + const sdnn = [42.5, 45.25, 40.125, 47]; + const rmssd = [31, 35.5, 29, 33]; + const steps = [8000, 9500, 7200, 10100]; + return { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { + username: null, + dateOfBirth: null, + gender: null, + heightCm: null, + }, + practiceName: null, + measurements: { + SLEEP_DURATION: series(sleep), + HEART_RATE_VARIABILITY: series(sdnn), + HRV_RMSSD: series(rmssd), + ACTIVITY_STEPS: series(steps), + }, + stats: { + SLEEP_DURATION: stat(sleep), + HEART_RATE_VARIABILITY: stat(sdnn), + HRV_RMSSD: stat(rmssd), + ACTIVITY_STEPS: stat(steps), + }, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + workouts: { + sessions: 9, + totalDurationSec: 9 * 3600, + sessionsPerWeek: 0.7, + minutesPerWeek: 42, + sportCount: 2, + bySport: [ + { + sportType: "running", + sessions: 6, + durationSec: 6 * 3600, + distanceM: 54_300, + distanceSessions: 6, + energyKcal: 3900, + avgHeartRate: 148.5, + }, + { + sportType: "yoga", + sessions: 3, + durationSec: 3 * 3600, + distanceM: null, + distanceSessions: 0, + energyKcal: null, + avgHeartRate: null, + }, + ], + }, + medicationEffects: [ + { + medicationName: "Example ACE inhibitor", + startsOn: "2026-03-02", + targets: [{ type: "BLOOD_PRESSURE_SYS", effect: EFFECT }], + }, + ], + ...overrides, + } as DoctorReportData; +} + +function render(d: DoctorReportData) { + return renderDoctorReportPdfBytes(d, { + timeFormat: "AUTO", + dateFormat: "AUTO", + t: getServerTranslator("en").t, + locale: "en", + now: NOW, + }); +} + +describe("doctor report v1.44 sections", () => { + it("labels SDNN and RMSSD as separate rows and keeps recorded precision", async () => { + const text = await textOf(render(data())); + expect(text).toContain("HRV (SDNN)"); + expect(text).toContain("HRV (RMSSD)"); + // #1228: the row keeps the digits the readings carry, up to three + // significant ones (40.125 ms reads 40.1, never 40). + expect(text).toContain("40.1"); + expect(text).not.toMatch(/\b40 ms/); + expect(text).toContain("Sleep"); + expect(text).toContain("Steps"); + }); + + it("charts the wearable series at half width in the report's units", async () => { + const text = await textOf(render(data())); + // The sleep chart's max label is in hours (470 min = 7.8 h), never minutes. + expect(text).toContain("7.8 h"); + expect(text).not.toContain("470.0 h"); + }); + + it("prints the workout block per sport with weekly figures", async () => { + const text = await textOf(render(data())); + expect(text).toContain("Workouts"); + expect(text).toContain("9 sessions, 9.0 h in total"); + expect(text).toContain("0.7 sessions and 42 minutes per week"); + expect(text).toContain("Running"); + expect(text).toContain("54.3 km"); + expect(text).toContain("149 bpm"); + expect(text).toContain("Yoga"); + }); + + it("prints the medication effect with intervals, exclusions and confounders", async () => { + // Notes wrap at the page edge; read them as one run of words. + const text = (await textOf(render(data()))).replace(/\s+/g, " "); + expect(text).toContain("Change around each medication start"); + expect(text).toContain("Example ACE inhibitor"); + expect(text).toContain("-7.3 mmHg"); + expect(text).toContain("(-8.5 to -6.2)"); + expect(text).toContain("-0.3 mmHg/week"); + expect(text).toContain("84 / 61"); + expect(text).toContain("5 days after the start left out"); + expect(text).toContain("Other drug started on"); + expect(text).toContain("not a proven effect"); + }); + + it("names the missing floor instead of printing numbers", async () => { + const text = await textOf( + render( + data({ + medicationEffects: [ + { + medicationName: "Example ACE inhibitor", + startsOn: null, + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + ...EFFECT, + present: false, + reason: "no_start", + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + }, + }, + ], + }, + ], + }), + ), + ); + expect(text).toContain("No start date recorded"); + expect(text).not.toContain("-7.3 mmHg"); + }); + + it("omits both blocks when the aggregator handed none over", async () => { + const text = await textOf( + render(data({ workouts: null, medicationEffects: null })), + ); + expect(text).not.toContain("Workouts"); + expect(text).not.toContain("Change around each medication start"); + }); +}); + +describe("summariseReportWorkouts", () => { + it("sums per sport, longest total first, and averages over the window's weeks", () => { + const w = summariseReportWorkouts( + [ + { + sportType: "running", + durationSec: 1800, + totalDistanceM: 5000, + totalEnergyKcal: 300, + avgHeartRate: 150, + }, + { + sportType: "running", + durationSec: 2400, + totalDistanceM: null, + totalEnergyKcal: 400, + avgHeartRate: 140, + }, + { + sportType: "cycling", + durationSec: 7200, + totalDistanceM: 40_000, + totalEnergyKcal: null, + avgHeartRate: null, + }, + ], + 14, + )!; + expect(w.sessions).toBe(3); + expect(w.totalDurationSec).toBe(11_400); + expect(w.sessionsPerWeek).toBe(1.5); + expect(w.minutesPerWeek).toBe(95); + expect(w.bySport.map((s) => s.sportType)).toEqual(["cycling", "running"]); + const running = w.bySport[1]; + expect(running).toEqual({ + sportType: "running", + sessions: 2, + durationSec: 4200, + distanceM: 5000, + distanceSessions: 1, + energyKcal: 700, + avgHeartRate: 145, + }); + expect(w.bySport[0].avgHeartRate).toBeNull(); + }); + + it("returns null without sessions", () => { + expect(summariseReportWorkouts([], 30)).toBeNull(); + }); +}); diff --git a/src/lib/doctor-report-pdf/activity-effect-sections.ts b/src/lib/doctor-report-pdf/activity-effect-sections.ts new file mode 100644 index 000000000..cbf7c0d36 --- /dev/null +++ b/src/lib/doctor-report-pdf/activity-effect-sections.ts @@ -0,0 +1,287 @@ +/** + * Two compact PDF blocks added with the v1.44 report: the per-sport workout + * summary and the medication effect around each start. + * + * Both render only what the aggregator handed over (the selection and the + * modules are applied there), and both are tables of a few rows: a workout + * block is one row per sport, never a session log, and the effect block is + * one row per medication and target with its caveats as short notes under it. + */ +import type { jsPDF } from "jspdf"; +import autoTable from "jspdf-autotable"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { formatEstimate } from "@/lib/medications/efficacy/segmented-format"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; +import { reportMeasurementLabelKey } from "../report-selection/catalogue"; +import { reportSportLabel } from "../doctor-report/sport-label"; +import { + pdfCursorState, + type DoctorReportPdfCursorState, + type DoctorReportPdfRenderContext, +} from "./render-context"; + +const TABLE_STYLES = { + theme: "grid" as const, + styles: { + fontSize: 8.5, + cellPadding: 2.2, + textColor: [30, 30, 30] as [number, number, number], + lineColor: [200, 200, 200] as [number, number, number], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245] as [number, number, number], + textColor: [30, 30, 30] as [number, number, number], + fontStyle: "bold" as const, + }, + alternateRowStyles: { + fillColor: [252, 252, 252] as [number, number, number], + }, +}; + +function lastTableY(doc: jsPDF): number { + return (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY; +} + +function heading( + context: DoctorReportPdfRenderContext, + y: number, + text: string, + keepWith: number, +): number { + const { doc, margin, ensureSpace } = context; + const top = ensureSpace(y, 6 + keepWith); + doc.setFontSize(12); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(text, margin, top); + return top + 5; +} + +function note( + context: DoctorReportPdfRenderContext, + y: number, + text: string, +): number { + const { doc, margin, pageWidth, ensureSpace } = context; + doc.setFontSize(8); + doc.setFont("helvetica", "normal"); + doc.setTextColor(90, 90, 90); + let cursor = y; + for (const line of doc.splitTextToSize(text, pageWidth - 2 * margin)) { + cursor = ensureSpace(cursor, 4); + doc.text(line, margin, cursor); + cursor += 3.8; + } + doc.setTextColor(30, 30, 30); + return cursor; +} + +/** The per-sport workout summary. */ +export function buildWorkoutsSection( + context: DoctorReportPdfRenderContext, + state: DoctorReportPdfCursorState, +): DoctorReportPdfCursorState { + const { doc, data, t, num, margin, tableBottomMargin } = context; + const w = data.workouts; + if (!w || w.sessions === 0) return state; + + let y = heading(context, state.y, t("doctorReport.workouts.title"), 24); + y = note( + context, + y, + t("doctorReport.workouts.summary", { + sessions: w.sessions, + hours: num(w.totalDurationSec / 3600, 1), + perWeek: num(w.sessionsPerWeek, 1), + minutesPerWeek: num(w.minutesPerWeek, 0), + }), + ); + y += 1; + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.workouts.colSport"), + t("doctorReport.workouts.colSessions"), + t("doctorReport.workouts.colTime"), + t("doctorReport.workouts.colDistance"), + t("doctorReport.workouts.colEnergy"), + t("doctorReport.workouts.colHeartRate"), + ], + ], + body: w.bySport.map((s) => [ + reportSportLabel(t, s.sportType), + String(s.sessions), + `${num(s.durationSec / 3600, 1)} h`, + s.distanceM !== null + ? s.distanceSessions < s.sessions + ? `${num(s.distanceM / 1000, 1)} km (${s.distanceSessions}/${s.sessions})` + : `${num(s.distanceM / 1000, 1)} km` + : "", + s.energyKcal !== null ? `${num(s.energyKcal, 0)} kcal` : "", + s.avgHeartRate !== null ? `${num(s.avgHeartRate, 0)} bpm` : "", + ]), + ...TABLE_STYLES, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = lastTableY(doc) + 3; + if (w.sportCount > w.bySport.length) { + y = note( + context, + y, + t("doctorReport.workouts.moreSports", { + count: w.sportCount - w.bySport.length, + }), + ); + } + return pdfCursorState(doc, y + 5); +} + +const EFFECT_REASON_KEYS: Record< + NonNullable, + string +> = { + no_start: "doctorReport.medicationEffect.reasonNoStart", + insufficient_before: "doctorReport.medicationEffect.reasonBefore", + insufficient_after: "doctorReport.medicationEffect.reasonAfter", + fit_failed: "doctorReport.medicationEffect.reasonFit", +}; + +const CONFOUNDER_KEYS = { + started: "medications.efficacy.segmented.confounderStarted", + stopped: "medications.efficacy.segmented.confounderStopped", + dose_change: "medications.efficacy.segmented.confounderDoseChange", + paused: "medications.efficacy.segmented.confounderPaused", + resumed: "medications.efficacy.segmented.confounderResumed", +} as const; + +/** A stated calendar day printed in the report's date order. */ +function dayLabel( + fmtDate: DoctorReportPdfRenderContext["fmtDate"], + day: string, +): string { + return fmtDate(dateOnlyAtNoonUtc(day).toISOString()); +} + +/** The segmented-regression effect, one row per medication and target. */ +export function buildMedicationEffectSection( + context: DoctorReportPdfRenderContext, + state: DoctorReportPdfCursorState, +): DoctorReportPdfCursorState { + const { doc, data, t, num, fmtDate, margin, tableBottomMargin, unitFor } = + context; + const effects = data.medicationEffects; + if (!effects || effects.length === 0) return state; + + const numAt = (v: number, d: number) => num(v, d); + const rows: string[][] = []; + const notes: string[] = []; + for (const med of effects) { + for (const target of med.targets) { + const e = target.effect; + const label = t( + reportMeasurementLabelKey(target.type as MeasurementType), + ); + const unit = unitFor(target.type); + const name = `${med.medicationName}\n${label}`; + const start = med.startsOn ? dayLabel(fmtDate, med.startsOn) : ""; + if (!e.present || !e.levelChange || !e.slopeChangePerWeek || !e.window) { + rows.push([ + name, + start, + t(EFFECT_REASON_KEYS[e.reason ?? "fit_failed"]), + "", + "", + ]); + continue; + } + const level = formatEstimate(e.levelChange, numAt); + const slope = formatEstimate(e.slopeChangePerWeek, numAt); + rows.push([ + name, + start, + `${level.value} ${unit}\n(${level.low} ${t("doctorReport.medicationEffect.to")} ${level.high})`, + `${slope.value} ${unit}/${t("doctorReport.medicationEffect.week")}\n(${slope.low} ${t("doctorReport.medicationEffect.to")} ${slope.high})`, + `${e.days.before} / ${e.days.after}`, + ]); + const extras: string[] = []; + if (e.days.excludedLowAdherence > 0) { + extras.push( + t("doctorReport.medicationEffect.excludedAdherence", { + count: e.days.excludedLowAdherence, + floor: e.adherence.floorPct, + }), + ); + } + if (e.days.excludedNotTaking > 0) { + extras.push( + t("doctorReport.medicationEffect.excludedNotTaking", { + count: e.days.excludedNotTaking, + }), + ); + } + if (e.adherence.status === "not_tracked") { + extras.push(t("doctorReport.medicationEffect.notTracked")); + } + if (e.window.endReason === "dose_change") { + extras.push( + t("doctorReport.medicationEffect.endDoseChange", { + date: dayLabel(fmtDate, e.window.to), + }), + ); + } + for (const c of e.confounders) { + extras.push( + t(CONFOUNDER_KEYS[c.event], { + name: c.medicationName, + date: dayLabel(fmtDate, c.date), + }), + ); + } + if (extras.length > 0) { + notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`); + } + } + } + if (rows.length === 0) return state; + + let y = heading( + context, + state.y, + t("doctorReport.medicationEffect.title"), + 24, + ); + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.medicationEffect.colMedication"), + t("doctorReport.medicationEffect.colStart"), + t("doctorReport.medicationEffect.colLevel"), + t("doctorReport.medicationEffect.colSlope"), + t("doctorReport.medicationEffect.colDays"), + ], + ], + body: rows, + ...TABLE_STYLES, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = lastTableY(doc) + 3; + for (const line of notes) y = note(context, y, line); + y = note(context, y + 1, t("doctorReport.medicationEffect.method")); + return pdfCursorState(doc, y + 5); +} diff --git a/src/lib/doctor-report-pdf/clinical-summary.ts b/src/lib/doctor-report-pdf/clinical-summary.ts index 037a765d5..535f9060b 100644 --- a/src/lib/doctor-report-pdf/clinical-summary.ts +++ b/src/lib/doctor-report-pdf/clinical-summary.ts @@ -3,7 +3,7 @@ import { type DoctorReportData, } from "../doctor-report-data"; import type { MeasurementType } from "@/generated/prisma/client"; -import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys"; +import { reportMeasurementLabelKey } from "../report-selection/catalogue"; import type { DoctorReportNumberFormatter, DoctorReportTranslator, @@ -35,6 +35,20 @@ export const SPARKLINE_TYPES = [ "PULSE", ] as const; +/** + * The wearable series charted at half width, two to a row, below the + * full-width vitals. Each still needs its leaf selected and two points in the + * window. The points are the report's own series: one per night for sleep, + * day buckets for heart-rate variability once the window or the row count + * passes the raw limit, and the daily totals steps arrive as. + */ +export const COMPACT_SPARKLINE_TYPES = [ + "SLEEP_DURATION", + "HEART_RATE_VARIABILITY", + "HRV_RMSSD", + "ACTIVITY_STEPS", +] as const satisfies readonly MeasurementType[]; + /** First-half vs second-half mean → "↑" / "↓" / "→". */ export function trendArrow(values: number[]): "↑" | "↓" | "→" { if (values.length < 2) return "→"; @@ -95,7 +109,7 @@ export function buildClinicalSummaryLines( ); lines.push( t("doctorReport.summaryTrend", { - label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""), + label: t(reportMeasurementLabelKey(type as MeasurementType)), latest: num(stat.latest, 1), arrow, }), diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 672857029..91e19f5a6 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -11,10 +11,11 @@ import { reportStatValue, } from "../doctor-report/stat-display"; import type { MeasurementType } from "@/generated/prisma/client"; -import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys"; +import { reportMeasurementLabelKey } from "../report-selection/catalogue"; import { getBmiClassificationKey, getBpClassificationKey, + COMPACT_SPARKLINE_TYPES, SPARKLINE_TYPES, } from "./clinical-summary"; import { @@ -252,7 +253,7 @@ export function buildMeasurementsChartsSection( const show = (v: number) => num(reportStatValue(type, v, glucoseUnit), digits); groupRows.push([ - t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""), + t(reportMeasurementLabelKey(type as MeasurementType)), `${show(s.latest)} ${unit}`.trim(), `${show(s.avg)} ${unit}`.trim(), show(s.min), @@ -433,7 +434,13 @@ export function buildMeasurementsChartsSection( const chartTypes = SPARKLINE_TYPES.filter( (type) => (data.measurements[type]?.length ?? 0) >= 2, ); - if (chartTypes.length > 0) { + // v1.44 — the wearable series (sleep, both HRV statistics, steps) draw at + // half width, two to a row: a clinician reads their direction, and four + // full-width charts would push the medication blocks a page further. + const compactTypes = COMPACT_SPARKLINE_TYPES.filter( + (type) => (data.measurements[type]?.length ?? 0) >= 2, + ); + if (chartTypes.length > 0 || compactTypes.length > 0) { // Keep the heading with at least the first chart. y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4); doc.setFontSize(12); @@ -446,9 +453,7 @@ export function buildMeasurementsChartsSection( // A chart never tears across a page boundary — break before drawing // it whole. y = ensureSpace(y, SPARKLINE_HEIGHT + 4); - const label = t( - MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? "", - ); + const label = t(reportMeasurementLabelKey(type as MeasurementType)); y = drawSparkline(doc, { x: margin, y, @@ -461,6 +466,32 @@ export function buildMeasurementsChartsSection( }); y += 4; } + const columnGap = 6; + const halfWidth = (pageWidth - 2 * margin - columnGap) / 2; + for (let i = 0; i < compactTypes.length; i += 2) { + y = ensureSpace(y, SPARKLINE_HEIGHT + 4); + let rowBottom = y; + for (const [column, type] of compactTypes.slice(i, i + 2).entries()) { + // Plotted in the unit the table prints (sleep in hours, not the + // stored minutes), so the min/max labels match the row above. + const points = (data.measurements[type] ?? []).map((p) => ({ + measuredAt: p.measuredAt, + value: reportStatValue(type, p.value, glucoseUnit), + })); + const bottom = drawSparkline(doc, { + x: margin + column * (halfWidth + columnGap), + y, + width: halfWidth, + label: t(reportMeasurementLabelKey(type)), + points, + num, + unit: unitFor(type), + dateShort: (iso) => dateShort(iso), + }); + rowBottom = Math.max(rowBottom, bottom); + } + y = rowBottom + 4; + } y += 2; } } diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index e29c1b040..55a42ae4a 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -9,6 +9,7 @@ import { type DoctorReportPdfRenderContext, } from "./render-context"; import { GLP1_SIDE_EFFECT_TAG_LABEL_KEYS } from "../medications/glp1-side-effect-tags"; +import { buildMedicationEffectSection } from "./activity-effect-sections"; const MOOD_LABEL_KEYS: Record = { 1: "doctorReport.moodAwful", @@ -140,6 +141,9 @@ export function buildMedicationMoodWellnessSection( .finalY + 8; } + // v1.44 — the effect around each medication's start, beside its adherence. + y = buildMedicationEffectSection(context, pdfCursorState(doc, y)).y; + // v1.4.25 — GLP-1 therapy section. Only renders when the data // aggregator emitted a non-null `glp1` block (user has at least one // active GLP-1 medication and the compliance toggle is on). Lists diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 6c7ae66b6..3a18f18fb 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -15,6 +15,7 @@ import type { GlucoseUnit } from "@/lib/glucose"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import type { MeasurementSource } from "@/generated/prisma/client"; import type { ModuleKey } from "@/lib/modules/gate"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; import type { ComplianceSchedule, @@ -497,6 +498,56 @@ export interface DoctorReportData { note: string | null; noteUnreadable: boolean; } | null; + /** + * Workouts over the window, summed per sport (v1.44). Null when the + * WORKOUTS leaf was not selected, the `workouts` module is off, or no + * session fell inside the window. Optional so older fixtures typecheck. + */ + workouts?: DoctorReportWorkouts | null; + /** + * The segmented-regression effect of each listed medication on its target + * metric(s), around the medication's start (v1.44). Computed without AI from + * the same day values and dose ledger the efficacy tab reads. A target rides + * only when its own measurement leaf is admitted too, so the block never + * carries a figure derived from a series the owner withheld. Null when the + * MEDICATION_EFFECT leaf was not selected or nothing qualified. + */ + medicationEffects?: DoctorReportMedicationEffect[] | null; +} + +/** The report's workout block: totals plus one row per sport. */ +export interface DoctorReportWorkouts { + sessions: number; + totalDurationSec: number; + /** Sessions and minutes divided by the window's weeks (days / 7). */ + sessionsPerWeek: number; + minutesPerWeek: number; + /** Distinct sports in the window; `bySport` lists at most twelve. */ + sportCount: number; + bySport: Array<{ + /** The canonical sport key; the renderer names it in the reader's language. */ + sportType: string; + sessions: number; + durationSec: number; + /** Summed over the sessions that reported a distance (`distanceSessions`). */ + distanceM: number | null; + distanceSessions: number; + energyKcal: number | null; + /** Mean of the per-session average heart rates that were reported. */ + avgHeartRate: number | null; + }>; +} + +/** One medication's effect block in the report. */ +export interface DoctorReportMedicationEffect { + medicationName: string; + /** YYYY-MM-DD, or null when the medication carries no start date. */ + startsOn: string | null; + targets: Array<{ + /** The target MeasurementType; the renderer labels it and picks the unit. */ + type: string; + effect: EfficacySegmented; + }>; } /** The three persisted score types surfaced in the wellness summary. */ diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 9ce9fc22e..fce815725 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -257,6 +257,17 @@ function seed() { practitioner: { name: "Sample practice" }, }, ]; + rows.workout = [ + { + sportType: "running", + startedAt: new Date("2026-02-03T07:00:00.000Z"), + durationSec: 1800, + source: "MANUAL", + totalDistanceM: 5000, + totalEnergyKcal: 300, + avgHeartRate: 140, + }, + ]; rows.familyHistoryEntry = [ { relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 }, ]; diff --git a/src/lib/doctor-report/__tests__/medication-effects.test.ts b/src/lib/doctor-report/__tests__/medication-effects.test.ts new file mode 100644 index 000000000..23f8c5250 --- /dev/null +++ b/src/lib/doctor-report/__tests__/medication-effects.test.ts @@ -0,0 +1,127 @@ +/** + * The report's medication-effect block reads only what its gate admits: a + * target whose own measurement leaf is withheld never comes back as an + * effect, the reads stop at the report's end, and the block is bounded. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findMany, resolveTargets, loadEffects } = vi.hoisted(() => ({ + findMany: vi.fn(), + resolveTargets: vi.fn(), + loadEffects: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany } } })); +vi.mock("@/lib/medications/efficacy/build-efficacy", () => ({ + resolveEffectiveTargets: resolveTargets, +})); +vi.mock("@/lib/medications/efficacy/load-segmented-effects", () => ({ + EFFECT_MEDICATION_INCLUDE: {}, + loadSegmentedEffects: loadEffects, +})); + +import { + loadReportMedicationEffects, + REPORT_EFFECT_MEDICATION_CAP, +} from "../medication-effects"; + +const END = new Date("2026-05-01T00:00:00.000Z"); + +function med(id: string) { + return { + id, + name: `Drug ${id}`, + treatmentClass: "ANTIHYPERTENSIVE", + atcCode: null, + startsOn: new Date("2026-02-01T00:00:00.000Z"), + }; +} + +beforeEach(() => { + findMany.mockReset(); + resolveTargets.mockReset(); + loadEffects.mockReset(); + resolveTargets.mockResolvedValue({ + tier: "atc", + cls: "x", + targets: [ + { kind: "metric", measurementType: "BLOOD_PRESSURE_SYS" }, + { kind: "metric", measurementType: "BLOOD_PRESSURE_DIA" }, + { kind: "lab", analyte: "Potassium", label: "Potassium" }, + ], + }); + loadEffects.mockImplementation( + async ({ targets }: { targets: string[] }) => + new Map(targets.map((t) => [t, { present: false, reason: "no_start" }])), + ); +}); + +describe("loadReportMedicationEffects", () => { + it("keeps only metric targets whose own leaf is admitted, and stops at the report end", async () => { + findMany.mockResolvedValue([med("a")]); + const out = await loadReportMedicationEffects({ + userId: "u1", + medicationIds: ["a"], + admitsMeasurement: (type) => type === "BLOOD_PRESSURE_SYS", + timezone: "Europe/Berlin", + end: END, + }); + expect(out).toHaveLength(1); + expect(out![0].targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]); + expect(out![0].startsOn).toBe("2026-02-01"); + expect(loadEffects).toHaveBeenCalledWith( + expect.objectContaining({ + targets: ["BLOOD_PRESSURE_SYS"], + now: END, + timezone: "Europe/Berlin", + }), + ); + // Owner-scoped, one-shot medications excluded at the query. + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "u1", id: { in: ["a"] }, oneShot: false }, + }), + ); + }); + + it("drops a medication none of whose targets is admitted, and answers null when nothing remains", async () => { + findMany.mockResolvedValue([med("a")]); + const out = await loadReportMedicationEffects({ + userId: "u1", + medicationIds: ["a"], + admitsMeasurement: () => false, + timezone: "UTC", + end: END, + }); + expect(out).toBeNull(); + expect(loadEffects).not.toHaveBeenCalled(); + }); + + it("reads nothing without medications", async () => { + const out = await loadReportMedicationEffects({ + userId: "u1", + medicationIds: [], + admitsMeasurement: () => true, + timezone: "UTC", + end: END, + }); + expect(out).toBeNull(); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("caps the number of medications it computes", async () => { + const ids = Array.from({ length: 12 }, (_, i) => `m${i}`); + findMany.mockResolvedValue(ids.map(med)); + const out = await loadReportMedicationEffects({ + userId: "u1", + medicationIds: ids, + admitsMeasurement: () => true, + timezone: "UTC", + end: END, + }); + expect(out).toHaveLength(REPORT_EFFECT_MEDICATION_CAP); + expect(loadEffects).toHaveBeenCalledTimes(REPORT_EFFECT_MEDICATION_CAP); + // In the report's own list order. + expect(out![0].medicationName).toBe("Drug m0"); + }); +}); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 0f850b658..a38c3313b 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -65,6 +65,8 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets"; import { summariseDenseBuckets } from "./measurement-series"; import { emptyGlucoseClinical } from "./glucose-panel"; import { buildAdministrationLedger, buildGlp1Block } from "./medications"; +import { loadReportWorkouts } from "./workouts"; +import { loadReportMedicationEffects } from "./medication-effects"; import { userDayKey, validTimezoneOr, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { loadAllergies, @@ -92,6 +94,9 @@ const DENSE_REPORT_RAW_WINDOW_DAYS = 90; const DENSE_STAT_TYPES = [ "PULSE", "HEART_RATE_VARIABILITY", + // RMSSD arrives per night from most rings and bands, but some write it per + // five-minute window; the row cap below keeps the sparse case raw. + "HRV_RMSSD", "OXYGEN_SATURATION", ] as const satisfies readonly MeasurementType[]; @@ -114,6 +119,7 @@ const MEDICATION_LEAVES = [ "MEDICATION_LIST", "MEDICATION_ADMINISTRATIONS", "MEDICATION_COMPLIANCE", + "MEDICATION_EFFECT", "GLP1_THERAPY", ] as const; @@ -644,6 +650,8 @@ export async function collectDoctorReportData( // Each remaining structured block: read only when its leaf was chosen. const [ + workouts, + medicationEffects, cycle, labResults, illnessEpisodes, @@ -655,6 +663,26 @@ export async function collectDoctorReportData( anamnesis, emergency, ] = await Promise.all([ + gate.admits("WORKOUTS") + ? loadReportWorkouts({ + userId, + start, + end, + days, + sourcePriorityJson: userProfile?.sourcePriorityJson ?? null, + }) + : Promise.resolve(null), + // The effect around each listed medication's start. Its own leaf AND the + // target's measurement leaf must both admit it; the loader asks the gate. + gate.admits("MEDICATION_EFFECT") + ? loadReportMedicationEffects({ + userId, + medicationIds: medications.map((m) => m.id), + admitsMeasurement: (type) => gate.admits(type), + timezone: reportTz, + end, + }) + : Promise.resolve(null), gate.admits("CYCLE") ? buildCycleExportSummary(userId, userDayKey(end, reportTz)) : Promise.resolve(null), @@ -765,5 +793,7 @@ export async function collectDoctorReportData( familyHistory, anamnesis, emergency, + workouts, + medicationEffects, }; } diff --git a/src/lib/doctor-report/medication-effects.ts b/src/lib/doctor-report/medication-effects.ts new file mode 100644 index 000000000..a90920e2a --- /dev/null +++ b/src/lib/doctor-report/medication-effects.ts @@ -0,0 +1,74 @@ +/** + * The medication-effect block of the doctor report: for each listed + * medication, the segmented-regression effect on its target metric(s) around + * its start, from the same loader the efficacy tab reads. + * + * Bounded: at most {@link REPORT_EFFECT_MEDICATION_CAP} medications, each a + * handful of reads (targets, intake events, confounders, one per-day SQL fold + * per target), whatever the window length. Every read stops at the report's + * end, so a report pinned in the past never uses later data. + * + * A target rides only when the gate admits its own measurement leaf: a + * deselected blood-pressure series must not come back as a blood-pressure + * effect. Lab targets are left out (too sparse for a daily series). + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { resolveEffectiveTargets } from "@/lib/medications/efficacy/build-efficacy"; +import { + EFFECT_MEDICATION_INCLUDE, + loadSegmentedEffects, +} from "@/lib/medications/efficacy/load-segmented-effects"; +import type { DoctorReportMedicationEffect } from "@/lib/doctor-report-types"; + +/** Medications the block computes at most, in the report's list order. */ +export const REPORT_EFFECT_MEDICATION_CAP = 8; + +export async function loadReportMedicationEffects(params: { + userId: string; + medicationIds: readonly string[]; + admitsMeasurement: (type: MeasurementType) => boolean; + timezone: string; + end: Date; +}): Promise { + const { userId, medicationIds, admitsMeasurement, timezone, end } = params; + if (medicationIds.length === 0) return null; + const meds = await prisma.medication.findMany({ + where: { userId, id: { in: [...medicationIds] }, oneShot: false }, + include: EFFECT_MEDICATION_INCLUDE, + }); + const order = new Map(medicationIds.map((id, i) => [id, i])); + meds.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); + + const out: DoctorReportMedicationEffect[] = []; + for (const med of meds) { + if (out.length >= REPORT_EFFECT_MEDICATION_CAP) break; + const resolved = await resolveEffectiveTargets({ + id: med.id, + name: med.name, + treatmentClass: med.treatmentClass, + atcCode: med.atcCode ?? null, + }); + const metricTargets = resolved.targets + .flatMap((t) => (t.kind === "metric" ? [t.measurementType] : [])) + .filter((type) => admitsMeasurement(type)); + if (metricTargets.length === 0) continue; + const effects = await loadSegmentedEffects({ + userId, + med, + targets: metricTargets, + timezone, + now: end, + }); + out.push({ + medicationName: med.name, + startsOn: med.startsOn ? dayKeyOfDate(med.startsOn) : null, + targets: metricTargets.flatMap((type) => { + const effect = effects.get(type); + return effect ? [{ type, effect }] : []; + }), + }); + } + return out.length > 0 ? out : null; +} diff --git a/src/lib/doctor-report/sport-label.ts b/src/lib/doctor-report/sport-label.ts new file mode 100644 index 000000000..95c0e57df --- /dev/null +++ b/src/lib/doctor-report/sport-label.ts @@ -0,0 +1,19 @@ +/** + * A workout sport's name in the report's language, shared by the PDF and the + * clinician view. A sport key the catalogue has no sentence for prints as its + * own words ("stairClimber" → "stair climber"), never as a raw key. + */ +import { translateOrNull } from "@/lib/i18n/translate-code"; + +export function reportSportLabel( + t: (key: string, params?: Record) => string, + sportType: string, +): string { + return ( + translateOrNull(t, `insights.workouts.sport.${sportType}`) ?? + sportType + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .toLowerCase() + ); +} diff --git a/src/lib/doctor-report/workouts.ts b/src/lib/doctor-report/workouts.ts new file mode 100644 index 000000000..dc20b1817 --- /dev/null +++ b/src/lib/doctor-report/workouts.ts @@ -0,0 +1,125 @@ +/** + * The workouts block of the doctor report: sessions over the window, summed + * per sport. + * + * A clinician reads training volume, not a session log, so the block is a + * per-sport summary (sessions, total time, distance, energy, mean of the + * session heart-rate averages) plus the window's weekly figures. Workouts are + * a sparse table — a handful a week even for a daily athlete — so the rows are + * read narrow (no metadata, no samples, no route) and collapsed through the + * canonical-workout picker first, the same rule the workout list applies, so a + * session two sources reported counts once. + */ +import { prisma } from "@/lib/db"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import type { DoctorReportWorkouts } from "@/lib/doctor-report-types"; + +/** + * Sport rows the block lists at most, longest total time first. The totals + * above the table still count every session, and `sportCount` says how many + * sports there were. + */ +export const REPORT_WORKOUT_SPORT_CAP = 12; + +export async function loadReportWorkouts(params: { + userId: string; + start: Date; + end: Date; + days: number; + sourcePriorityJson: unknown; +}): Promise { + const { userId, start, end, days, sourcePriorityJson } = params; + const rows = await prisma.workout.findMany({ + where: { userId, startedAt: { gte: start, lte: end } }, + orderBy: { startedAt: "asc" }, + select: { + sportType: true, + startedAt: true, + durationSec: true, + source: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + }, + }); + const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson); + return summariseReportWorkouts(canonical, days); +} + +/** Pure fold, exported for tests. */ +export function summariseReportWorkouts( + rows: ReadonlyArray<{ + sportType: string; + durationSec: number; + totalDistanceM: number | null; + totalEnergyKcal: number | null; + avgHeartRate: number | null; + }>, + days: number, +): DoctorReportWorkouts | null { + if (rows.length === 0) return null; + const bySport = new Map< + string, + { + sessions: number; + durationSec: number; + distanceM: number; + distanceSessions: number; + energyKcal: number; + energySessions: number; + hrSum: number; + hrSessions: number; + } + >(); + let totalDurationSec = 0; + for (const r of rows) { + totalDurationSec += r.durationSec; + const s = bySport.get(r.sportType) ?? { + sessions: 0, + durationSec: 0, + distanceM: 0, + distanceSessions: 0, + energyKcal: 0, + energySessions: 0, + hrSum: 0, + hrSessions: 0, + }; + s.sessions += 1; + s.durationSec += r.durationSec; + if (r.totalDistanceM !== null && r.totalDistanceM > 0) { + s.distanceM += r.totalDistanceM; + s.distanceSessions += 1; + } + if (r.totalEnergyKcal !== null && r.totalEnergyKcal > 0) { + s.energyKcal += r.totalEnergyKcal; + s.energySessions += 1; + } + if (r.avgHeartRate !== null && r.avgHeartRate > 0) { + s.hrSum += r.avgHeartRate; + s.hrSessions += 1; + } + bySport.set(r.sportType, s); + } + const weeks = Math.max(days, 1) / 7; + return { + sessions: rows.length, + totalDurationSec, + sessionsPerWeek: rows.length / weeks, + minutesPerWeek: totalDurationSec / 60 / weeks, + sportCount: bySport.size, + bySport: [...bySport.entries()] + .sort((a, b) => b[1].durationSec - a[1].durationSec) + .slice(0, REPORT_WORKOUT_SPORT_CAP) + .map(([sportType, s]) => ({ + sportType, + sessions: s.sessions, + durationSec: s.durationSec, + // A sum over the sessions that reported it, and how many did: a + // partial distance never reads as the sport's whole distance. + distanceM: s.distanceSessions > 0 ? s.distanceM : null, + distanceSessions: s.distanceSessions, + energyKcal: s.energySessions > 0 ? s.energyKcal : null, + avgHeartRate: s.hrSessions > 0 ? s.hrSum / s.hrSessions : null, + })), + }; +} diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 593bec93c..5355e3b79 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -32,6 +32,7 @@ import { leafLabelKey, LEAF_MODULE, MEASUREMENT_LEAF_GROUP, + reportMeasurementLabelKey, } from "../catalogue"; import { STANDARD_TEMPLATE_LEAVES, standardTemplateFor } from "../template"; @@ -42,6 +43,15 @@ const EN = JSON.parse( ), ) as Record; +function labelOf(key: string): unknown { + let node: unknown = EN; + for (const part of key.split(".")) { + if (typeof node !== "object" || node === null) return undefined; + node = (node as Record)[part]; + } + return node; +} + function resolves(key: string): boolean { let node: unknown = EN; for (const part of key.split(".")) { @@ -54,8 +64,8 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { expect(MEASUREMENT_LEAF_IDS).toHaveLength(86); - expect(STRUCTURED_LEAF_IDS).toHaveLength(18); - expect(ALL_LEAF_IDS).toHaveLength(104); + expect(STRUCTURED_LEAF_IDS).toHaveLength(20); + expect(ALL_LEAF_IDS).toHaveLength(106); }); it("resolves a label for every leaf", () => { @@ -205,6 +215,15 @@ describe("heart-rate variability in the report", () => { // own cardio leaf beside SDNN, never folded into it, and like SDNN no // module gates it: a report with Recovery off must not lose the account's // only HRV series for the estimator its device reports. + it("names the statistic on both rows, so the two never read as one series", () => { + const sdnn = reportMeasurementLabelKey("HEART_RATE_VARIABILITY"); + const rmssd = reportMeasurementLabelKey("HRV_RMSSD"); + expect(sdnn).not.toBe(rmssd); + expect(leafLabelKey("HEART_RATE_VARIABILITY")).toBe(sdnn); + expect(labelOf(sdnn)).toBe("HRV (SDNN)"); + expect(labelOf(rmssd)).toBe("HRV (RMSSD)"); + }); + it("files SDNN and RMSSD as separate, ungated cardio leaves", () => { for (const type of ["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const) { expect(MEASUREMENT_LEAF_GROUP[type]).toBe("cardio"); diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index de1683fef..dfd7b9bb7 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -5,8 +5,8 @@ * A LEAF is the smallest releasable unit. There are exactly two kinds: every * `MeasurementType` enum member, and the structured sections of the report * payload that are not measurement series (identity, insurance, the glucose - * panel, labs, the three medication blocks, GLP-1, allergies, illness - * episodes, family history, mood, cycle). + * panel, labs, the medication blocks, GLP-1, the medication effect, workouts, + * allergies, illness episodes, family history, mood, cycle). * * One catalogue drives the selection panel, the aggregator, the PDF, the * clinician share view and the guard tests, so a control can never again exist @@ -44,7 +44,9 @@ export type StructuredLeafId = | "MEDICATION_LIST" | "MEDICATION_ADMINISTRATIONS" | "MEDICATION_COMPLIANCE" + | "MEDICATION_EFFECT" | "GLP1_THERAPY" + | "WORKOUTS" | "ALLERGIES" | "ILLNESS_EPISODES" | "VISITS" @@ -213,7 +215,9 @@ export const STRUCTURED_LEAF_GROUP: Record = { MEDICATION_LIST: "medications", MEDICATION_ADMINISTRATIONS: "medications", MEDICATION_COMPLIANCE: "medications", + MEDICATION_EFFECT: "medications", GLP1_THERAPY: "medications", + WORKOUTS: "activity", ALLERGIES: "history", ILLNESS_EPISODES: "history", VISITS: "history", @@ -225,7 +229,7 @@ export const STRUCTURED_LEAF_GROUP: Record = { ANAMNESIS: "sensitive", }; -/** i18n label keys for the 18 structured leaves. */ +/** i18n label keys for the structured leaves. */ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { PATIENT_IDENTITY: "reportSelection.leafPatientIdentity", EMERGENCY: "reportSelection.leafEmergency", @@ -235,7 +239,9 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { MEDICATION_LIST: "reportSelection.leafMedicationList", MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations", MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance", + MEDICATION_EFFECT: "reportSelection.leafMedicationEffect", GLP1_THERAPY: "reportSelection.leafGlp1Therapy", + WORKOUTS: "reportSelection.leafWorkouts", ALLERGIES: "reportSelection.leafAllergies", ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes", VISITS: "reportSelection.leafVisits", @@ -294,6 +300,8 @@ export const LEAF_MODULE: Partial> = { STRAIN_SCORE: "workouts", DAY_STRAIN: "workouts", WORKOUT_STRAIN: "workouts", + // The per-sport workout block. + WORKOUTS: "workouts", // Readiness signals. RECOVERY_SCORE: "recovery", STRESS_SCORE: "recovery", @@ -339,6 +347,7 @@ export const LEAF_MODULE: Partial> = { MEDICATION_LIST: "medications", MEDICATION_ADMINISTRATIONS: "medications", MEDICATION_COMPLIANCE: "medications", + MEDICATION_EFFECT: "medications", GLP1_THERAPY: "medications", // The immunization log is its own opt-out module: the leaf hides when the // `vaccinations` module is off, and the deselected leaf hides regardless of @@ -433,11 +442,33 @@ export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter( : MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID, ); +/** + * Measurement labels the report artefacts print differently from the app. + * Heart-rate variability comes as two statistics that are not + * interchangeable (SDNN from Apple Health and Withings, RMSSD from Oura, + * WHOOP, Polar and Fitbit); the app's generic "Heart-rate variability" label + * on the SDNN series sits beside "HRV (RMSSD)" on a clinician's page, so the + * report names the statistic on both. + */ +const REPORT_MEASUREMENT_LABEL_OVERRIDES: Partial< + Record +> = { + HEART_RATE_VARIABILITY: "reportSelection.leafHrvSdnn", +}; + +/** The i18n label key a report artefact prints a measurement type under. */ +export function reportMeasurementLabelKey(type: MeasurementType): string { + return ( + REPORT_MEASUREMENT_LABEL_OVERRIDES[type] ?? + MEASUREMENT_TYPE_LABEL_KEYS[type] + ); +} + /** The i18n label key for any leaf. */ export function leafLabelKey(leaf: ReportLeafId): string { return isStructuredLeafId(leaf) ? STRUCTURED_LEAF_LABEL_KEYS[leaf] - : MEASUREMENT_TYPE_LABEL_KEYS[leaf]; + : reportMeasurementLabelKey(leaf); } /** The group a leaf belongs to. */ diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts index 01e2a04a3..a93f92bcb 100644 --- a/src/lib/report-selection/template.ts +++ b/src/lib/report-selection/template.ts @@ -16,13 +16,21 @@ import { type ReportLeafId } from "./catalogue"; /** * Identity and insurer (the cover page a practice files the document under), * the three classic vitals, weight and BMI, the glucose series and its panel, - * labs, the drug list and adherence, allergies, past illness episodes, the - * surgical history and the immunization record. + * labs, the drug list, adherence and the effect around each start, sleep, + * both heart-rate-variability statistics, daily steps and the workout summary, + * allergies, past illness episodes, the surgical history and the immunization + * record. * - * Deliberately not in it: everything a wearable produces, gait and mobility, - * environment exposure, the per-dose administration ledger (a clinician asks - * for adherence, not for every logged dose), the GLP-1 block, and the whole - * fenced tier. + * The wearable picks (v1.44) are the four a general visit reads: sleep + * duration, SDNN and RMSSD as two labelled rows, steps and the per-sport + * workout block. Each is one table row or one compact block, and each still + * rides its module: an account with sleep or workouts switched off gets none + * of it. + * + * Deliberately not in it: the rest of what a wearable produces, gait and + * mobility, environment exposure, the per-dose administration ledger (a + * clinician asks for adherence, not for every logged dose), the GLP-1 block, + * and the whole fenced tier. */ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [ "PATIENT_IDENTITY", @@ -38,6 +46,12 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [ "LAB_RESULTS", "MEDICATION_LIST", "MEDICATION_COMPLIANCE", + "MEDICATION_EFFECT", + "SLEEP_DURATION", + "HEART_RATE_VARIABILITY", + "HRV_RMSSD", + "ACTIVITY_STEPS", + "WORKOUTS", "ALLERGIES", "ILLNESS_EPISODES", "SURGICAL_HISTORY", From 77619f8aa53a1c5ea664b90e123c6d8217ae00a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:56:16 +0200 Subject: [PATCH 45/98] feat(fhir): serve Observations as a time series, code RMSSD and workouts GET /api/fhir/Observation keeps its snapshot answer when no date is sent. A date range (FHIR prefixes eq/gt/ge/lt/le, UTC when no offset) switches it to the time series: every reading in the range plus workouts, newest first, paged in the database through one UNION id query and a count, so a page costs what it holds. Series ids are the row's own (m-, w-) and read back through /Observation/{id}. The series replays the same saved report selection as the snapshot, pairs systolic and diastolic recorded at one instant into the blood-pressure panel, leaves sleep stage segments out, and reads an hourly heart-rate bucket as a mean over its hour. A code parameter narrows either answer; a malformed date is a 400 OperationOutcome. A workout is an activity Observation after the Physical Activity IG: LOINC 73985-4 with the sport as its value and duration (55411-3) and energy (41981-2) as components. RMSSD gets its own registry coding, text-only because LOINC has no RMSSD term, so it never borrows the SDNN code. The capability statement lists the two new parameters. --- docs/api/openapi.yaml | 31 +- src/app/api/fhir/Observation/[id]/route.ts | 19 + src/app/api/fhir/Observation/route.ts | 71 +- .../fhir/__tests__/observation-series.test.ts | 228 ++++++ src/app/api/fhir/metadata/route.ts | 21 +- src/lib/fhir/__tests__/series.test.ts | 375 +++++++++ src/lib/fhir/rest.ts | 47 +- src/lib/fhir/series.ts | 765 ++++++++++++++++++ src/lib/openapi/routes/health-record.ts | 8 +- .../fixtures/expected-measurement-loinc.json | 6 + src/lib/signals/registry.ts | 28 + .../fhir-series-and-medication-effect.test.ts | 362 +++++++++ 12 files changed, 1935 insertions(+), 26 deletions(-) create mode 100644 src/app/api/fhir/__tests__/observation-series.test.ts create mode 100644 src/lib/fhir/__tests__/series.test.ts create mode 100644 src/lib/fhir/series.ts create mode 100644 tests/integration/fhir-series-and-medication-effect.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 356e42699..bcf344eb7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1245,8 +1245,9 @@ paths: summary: FHIR R4 CapabilityStatement (v1.11.0) description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` - interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised - interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)." + interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the + `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a + route. Auth: `fhir:read` scope (cookie sessions also pass)." responses: "200": description: CapabilityStatement (application/fhir+json). @@ -1291,7 +1292,16 @@ paths: - FHIR summary: FHIR R4 Observation search (v1.11.0) description: "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: - `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`." + `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the + snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` + (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time + series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-` / + `w-` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection + as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the + same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 + Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a + bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 + OperationOutcome." parameters: - in: query name: _count @@ -1301,6 +1311,14 @@ paths: name: _offset schema: type: number + - in: query + name: date + schema: + type: string + - in: query + name: code + schema: + type: string responses: "200": description: searchset Bundle (application/fhir+json). @@ -1463,9 +1481,10 @@ paths: tags: - FHIR summary: FHIR R4 Observation read - description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is - indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` - scope; `userId` is narrowed from auth." + description: "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id + (`m-` / `w-`, v1.44), the latter read inside the same saved selection the series search applies. An id + belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` + OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id diff --git a/src/app/api/fhir/Observation/[id]/route.ts b/src/app/api/fhir/Observation/[id]/route.ts index 45de4c5e6..1f42faa27 100644 --- a/src/app/api/fhir/Observation/[id]/route.ts +++ b/src/app/api/fhir/Observation/[id]/route.ts @@ -2,6 +2,9 @@ * GET /api/fhir/Observation/{id} — FHIR R4 `read` of one Observation from the * caller's own record. * + * A series id (`m-…` / `w-…`, v1.44) reads that one row inside the same scope + * the series search applies; every other id is a snapshot id. + * * The id space is the export's own (`obs-3`), stable for a given record and * reporting window. A `read` is a filter over the same emitter the search * route pages, so a resource can never differ between the two faces; an id the @@ -16,10 +19,13 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { observationsFromReportData } from "@/lib/fhir/resources"; import { FHIR_READ_SCOPE, + fhirJsonResponse, loadFhirContext, + loadFhirSeriesScope, operationOutcome, readResponse, } from "@/lib/fhir/rest"; +import { readSeriesObservation } from "@/lib/fhir/series"; export const GET = apiHandler( async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { @@ -41,6 +47,19 @@ export const GET = apiHandler( return operationOutcome(429, "throttled", "Rate limit exceeded"); } const { id } = await ctx.params; + if (id.startsWith("m-") || id.startsWith("w-")) { + const { scope, glucoseUnit } = await loadFhirSeriesScope(user.id, null); + annotate({ meta: { resourceId: id, mode: "series" } }); + const observation = await readSeriesObservation({ + userId: user.id, + id, + scope, + glucoseUnit, + }); + return observation + ? fhirJsonResponse(observation) + : operationOutcome(404, "not-found", "No such resource"); + } const context = await loadFhirContext(user.id); annotate({ meta: { resourceId: id } }); return readResponse(observationsFromReportData(context.data), id); diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index ab52fcc54..1988f78c4 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -1,12 +1,19 @@ /** - * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations - * (vitals / activity / lab / survey): one latest reading per type plus the BP - * panel, BMI, glucose, adherence, mood and the wellness composites. + * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations. * - * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in - * `@/lib/fhir/resources` are the single source of the coding, so this face and - * the document export can never describe the same record differently. Offset - * paging via `_count` (clamped ≤200) / `_offset`. + * Two answers, chosen by the request: + * + * - no `date` parameter: the snapshot it has always been — one latest + * reading per type plus the BP panel, BMI, glucose, adherence, mood and the + * wellness composites (`obs-N` ids), offset-paged in memory. + * - with `date` (v1.44): the time series — every reading in the range, plus + * workouts, newest first, paged in the database (`m-…` / `w-…` ids). See + * `@/lib/fhir/series`. + * + * `code` narrows either answer. Read-only. `userId` is narrowed from + * `requireAuth`; both answers read the owner's saved report selection, so the + * series cannot reach a type the snapshot withholds. Paging via `_count` + * (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; @@ -18,10 +25,18 @@ import { observationsFromReportData } from "@/lib/fhir/resources"; import { FHIR_READ_SCOPE, loadFhirContext, + loadFhirSeriesScope, operationOutcome, parsePaging, searchsetResponse, } from "@/lib/fhir/rest"; +import { + emitSeriesPage, + matchesCodeTokens, + parseCodeParams, + parseDateParams, + querySeriesPage, +} from "@/lib/fhir/series"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(FHIR_READ_SCOPE); @@ -41,11 +56,45 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); - const context = await loadFhirContext(user.id); + const params = request.nextUrl.searchParams; + const { count, offset } = parsePaging(params); + const codeValues = params.getAll("code"); + const range = parseDateParams(params.getAll("date")); + if (range && "error" in range) { + return operationOutcome(400, "invalid", range.error); + } - const all = observationsFromReportData(context.data); + if (range) { + const { scope, glucoseUnit } = await loadFhirSeriesScope( + user.id, + parseCodeParams(codeValues), + ); + const { total, refs } = await querySeriesPage({ + userId: user.id, + scope, + range, + count, + offset, + }); + const page = await emitSeriesPage({ + userId: user.id, + refs, + scope, + glucoseUnit, + }); + annotate({ + meta: { mode: "series", total, count, offset, types: scope.types.length }, + }); + return searchsetResponse(request.nextUrl, page, total, count, offset); + } + + const context = await loadFhirContext(user.id); + const snapshot = observationsFromReportData(context.data); + const all = + codeValues.length > 0 + ? snapshot.filter((o) => matchesCodeTokens(o, codeValues)) + : snapshot; const page = all.slice(offset, offset + count); - annotate({ meta: { total: all.length, count, offset } }); + annotate({ meta: { mode: "snapshot", total: all.length, count, offset } }); return searchsetResponse(request.nextUrl, page, all.length, count, offset); }); diff --git a/src/app/api/fhir/__tests__/observation-series.test.ts b/src/app/api/fhir/__tests__/observation-series.test.ts new file mode 100644 index 000000000..25fb76e6f --- /dev/null +++ b/src/app/api/fhir/__tests__/observation-series.test.ts @@ -0,0 +1,228 @@ +/** + * v1.44 — the Observation search's two answers. Without `date` the route + * serves the snapshot exactly as before (the whole-record loader runs, the + * series loader does not); with `date` it serves the paged time series and + * never assembles the whole record. A malformed `date` is a 400, `code` + * narrows the snapshot, and series ids read back through `/{id}`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), + isModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/fhir/rest", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadFhirContext: vi.fn(), + loadFhirSeriesScope: vi.fn(), + }; +}); +vi.mock("@/lib/fhir/resources", () => ({ + observationsFromReportData: vi.fn(), +})); +vi.mock("@/lib/fhir/series", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + querySeriesPage: vi.fn(), + emitSeriesPage: vi.fn(), + readSeriesObservation: vi.fn(), + }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as observationGet } from "../Observation/route"; +import { GET as observationRead } from "../Observation/[id]/route"; +import { + emitSeriesPage, + querySeriesPage, + readSeriesObservation, +} from "@/lib/fhir/series"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { loadFhirContext, loadFhirSeriesScope } from "@/lib/fhir/rest"; +import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; +import { observationsFromReportData } from "@/lib/fhir/resources"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function obs(id: number) { + return { + resourceType: "Observation" as const, + id: `obs-${id}`, + status: "final" as const, + code: { + coding: [ + { system: "http://loinc.org", code: id === 1 ? "29463-7" : "x" }, + ], + }, + subject: { reference: "Patient/patient-1" }, + }; +} + +function req(query = ""): NextRequest { + return new NextRequest(`http://localhost/api/fhir/Observation${query}`); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + count: 1, + resetAt: Date.now(), + } as never); + // The doctorReport module is ON for the contract assertions below; the + // OFF behaviour is pinned in `module-gate.test.ts`. + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(loadFhirContext).mockResolvedValue({ + data: {} as never, + identity: { insuranceNumber: null }, + germanAtc: false, + records: {}, + }); + vi.mocked(loadFhirSeriesScope).mockResolvedValue({ + scope: { + types: ["WEIGHT"], + pairBloodPressure: false, + glucoseContexts: null, + workouts: true, + }, + glucoseUnit: "mg/dL", + }); +}); + +describe("GET /api/fhir/Observation — snapshot stays the default", () => { + it("serves the snapshot without a date and never touches the series", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([ + obs(1), + obs(2), + ] as never); + const res = await observationGet(req()); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { total: number }; + expect(bundle.total).toBe(2); + expect(loadFhirContext).toHaveBeenCalledTimes(1); + expect(loadFhirSeriesScope).not.toHaveBeenCalled(); + expect(querySeriesPage).not.toHaveBeenCalled(); + }); + + it("narrows the snapshot by code", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([ + obs(1), + obs(2), + ] as never); + const res = await observationGet(req("?code=http://loinc.org|29463-7")); + const bundle = (await res.json()) as { + total: number; + entry: Array<{ resource: { id: string } }>; + }; + expect(bundle.total).toBe(1); + expect(bundle.entry[0].resource.id).toBe("obs-1"); + }); +}); + +describe("GET /api/fhir/Observation?date= — time series", () => { + it("pages in the database and never assembles the whole record", async () => { + vi.mocked(querySeriesPage).mockResolvedValue({ + total: 340, + refs: [{ kind: "m", id: "a" }], + } as never); + vi.mocked(emitSeriesPage).mockResolvedValue([ + { ...obs(9), id: "m-a" }, + ] as never); + const res = await observationGet( + req("?date=ge2026-01-01&date=lt2026-02-01&_count=50&_offset=100"), + ); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + total: number; + link: Array<{ relation: string; url: string }>; + entry: Array<{ fullUrl: string }>; + }; + expect(loadFhirContext).not.toHaveBeenCalled(); + expect(querySeriesPage).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + count: 50, + offset: 100, + range: { + from: new Date("2026-01-01T00:00:00.000Z"), + to: new Date("2026-02-01T00:00:00.000Z"), + }, + }), + ); + expect(bundle.total).toBe(340); + expect(bundle.entry[0].fullUrl).toContain("/api/fhir/Observation/m-a"); + const next = bundle.link.find((l) => l.relation === "next"); + expect(next?.url).toContain("_offset=150"); + expect(next?.url).toContain("date=ge2026-01-01"); + }); + + it("hands the code filter to the scope", async () => { + vi.mocked(querySeriesPage).mockResolvedValue({ total: 0, refs: [] }); + vi.mocked(emitSeriesPage).mockResolvedValue([]); + await observationGet(req("?date=2026&code=80404-7")); + const codes = vi.mocked(loadFhirSeriesScope).mock.calls[0][1]; + expect([...(codes?.types ?? [])]).toEqual(["HEART_RATE_VARIABILITY"]); + }); + + it("answers a malformed date with a 400 OperationOutcome", async () => { + const res = await observationGet(req("?date=last-week")); + expect(res.status).toBe(400); + const body = (await res.json()) as { + resourceType: string; + issue: Array<{ code: string }>; + }; + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].code).toBe("invalid"); + expect(querySeriesPage).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/fhir/Observation/{id} — series ids", () => { + const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + + it("reads a series id through the series scope", async () => { + vi.mocked(readSeriesObservation).mockResolvedValue({ + ...obs(1), + id: "w-x", + } as never); + const res = await observationRead( + new NextRequest("http://localhost/api/fhir/Observation/w-x"), + ctx("w-x"), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as { id: string }).id).toBe("w-x"); + expect(loadFhirContext).not.toHaveBeenCalled(); + }); + + it("answers an unknown series id with not-found", async () => { + vi.mocked(readSeriesObservation).mockResolvedValue(null); + const res = await observationRead( + new NextRequest("http://localhost/api/fhir/Observation/m-nope"), + ctx("m-nope"), + ); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/fhir/metadata/route.ts b/src/app/api/fhir/metadata/route.ts index 2bd4587fb..cb8935d9b 100644 --- a/src/app/api/fhir/metadata/route.ts +++ b/src/app/api/fhir/metadata/route.ts @@ -3,7 +3,8 @@ * * Declares the read-only REST face: the resource types served, the `read` + * `search-type` interactions on each, the search parameters honoured - * (`_count`, `_offset`), the `Patient-everything` operation, and the + * (`_count`, `_offset`; `date` and `code` on Observation), the + * `Patient-everything` operation, and the * `application/fhir+json` format. Static — no per-user data — but still gated * behind the `fhir:read` scope so the whole `/api/fhir` tree answers * uniformly. Read-only: no write interactions are advertised. @@ -22,6 +23,7 @@ import { annotate } from "@/lib/logging/context"; import { FHIR_READ_SCOPE, FHIR_REST_RESOURCE_TYPES, + FHIR_OBSERVATION_SEARCH_PARAMS, FHIR_SEARCH_PARAMS, fhirJsonResponse, } from "@/lib/fhir/rest"; @@ -65,10 +67,19 @@ export const GET = apiHandler(async (request: NextRequest) => { resource: FHIR_REST_RESOURCE_TYPES.map((type) => ({ type, interaction: [{ code: "read" }, { code: "search-type" }], - searchParam: FHIR_SEARCH_PARAMS.map((name) => ({ - name, - type: "number", - })), + searchParam: [ + ...FHIR_SEARCH_PARAMS.map((name) => ({ + name, + type: "number", + })), + // v1.44: the Observation search's time-series range and code filter. + ...(type === "Observation" + ? FHIR_OBSERVATION_SEARCH_PARAMS.map((p) => ({ + name: p.name, + type: p.type, + })) + : []), + ], ...(type === "Patient" ? { operation: [ diff --git a/src/lib/fhir/__tests__/series.test.ts b/src/lib/fhir/__tests__/series.test.ts new file mode 100644 index 000000000..ef832ef0d --- /dev/null +++ b/src/lib/fhir/__tests__/series.test.ts @@ -0,0 +1,375 @@ +/** + * The Observation time series: the `date` and `code` grammar, the scope the + * saved selection allows, and the R4 shape of each emitted resource. + */ +import { describe, expect, it } from "vitest"; + +import { + CALORIES_BURNED_LOINC, + EXERCISE_ACTIVITY_LOINC, + EXERCISE_DURATION_LOINC, + matchesCodeTokens, + measurementSeriesObservation, + parseCodeParams, + parseDateParams, + resolveSeriesScope, + seriesCandidateTypes, + workoutSeriesObservation, +} from "@/lib/fhir/series"; +import { MEASUREMENT_LOINC } from "@/lib/fhir/loinc-map"; +import { isUcumCode } from "@/lib/fhir/ucum"; +import type { FhirObservation } from "@/lib/fhir/types"; +import type { ReportLeafId } from "@/lib/report-selection/catalogue"; + +const iso = (d: Date | null) => d?.toISOString() ?? null; + +describe("parseDateParams", () => { + it("is absent without a date parameter, so the snapshot stays the default", () => { + expect(parseDateParams([])).toBeNull(); + }); + + it("reads prefixes and partial dates as half-open UTC ranges", () => { + const r = parseDateParams(["ge2026-01-01", "lt2026-02-01"]); + expect(r).not.toHaveProperty("error"); + const range = r as { from: Date | null; to: Date | null }; + expect(iso(range.from)).toBe("2026-01-01T00:00:00.000Z"); + expect(iso(range.to)).toBe("2026-02-01T00:00:00.000Z"); + }); + + it("covers a whole month for eq, and the day after for gt and le", () => { + const month = parseDateParams(["2026-02"]) as { from: Date; to: Date }; + expect(iso(month.from)).toBe("2026-02-01T00:00:00.000Z"); + expect(iso(month.to)).toBe("2026-03-01T00:00:00.000Z"); + const gt = parseDateParams(["gt2026-02-10"]) as { from: Date; to: null }; + expect(iso(gt.from)).toBe("2026-02-11T00:00:00.000Z"); + expect(gt.to).toBeNull(); + const le = parseDateParams(["le2026"]) as { from: null; to: Date }; + expect(iso(le.to)).toBe("2027-01-01T00:00:00.000Z"); + }); + + it("honours an offset on a dateTime value", () => { + const r = parseDateParams(["ge2026-03-01T10:00:00+02:00"]) as { + from: Date; + }; + expect(iso(r.from)).toBe("2026-03-01T08:00:00.000Z"); + }); + + it("rejects what it cannot read rather than ignoring it", () => { + expect(parseDateParams(["sa2026-01-01"])).toHaveProperty("error"); + expect(parseDateParams(["2026-02-30"])).toHaveProperty("error"); + expect(parseDateParams(["yesterday"])).toHaveProperty("error"); + }); +}); + +describe("parseCodeParams", () => { + it("maps LOINC codes to their types, with or without a system", () => { + const f = parseCodeParams(["http://loinc.org|80404-7", "29463-7"])!; + expect([...f.types].sort()).toEqual(["HEART_RATE_VARIABILITY", "WEIGHT"]); + expect(f.workouts).toBe(false); + }); + + it("maps any blood-pressure code to the paired panel stream", () => { + const f = parseCodeParams(["8480-6"])!; + expect([...f.types].sort()).toEqual([ + "BLOOD_PRESSURE_DIA", + "BLOOD_PRESSURE_SYS", + ]); + }); + + it("narrows glucose to the contexts its code names", () => { + const fasting = parseCodeParams(["1558-6"])!; + expect([...fasting.types]).toEqual(["BLOOD_GLUCOSE"]); + expect([...fasting.glucoseContexts!]).toEqual(["FASTING"]); + const blood = parseCodeParams(["2339-0"])!; + expect([...blood.glucoseContexts!].sort()).toEqual([ + "BEDTIME", + "NONE", + "RANDOM", + ]); + }); + + it("selects workouts by the exercise-activity code", () => { + expect(parseCodeParams([EXERCISE_ACTIVITY_LOINC])!.workouts).toBe(true); + }); + + it("does not match a LOINC code under a foreign system", () => { + expect( + parseCodeParams(["http://snomed.info/sct|80404-7"])!.types.size, + ).toBe(0); + }); +}); + +describe("resolveSeriesScope", () => { + const gateOf = (leaves: ReportLeafId[]) => ({ + admits: (leaf: ReportLeafId) => leaves.includes(leaf), + }); + + it("carries only what the saved selection admits, never sleep", () => { + const s = resolveSeriesScope( + gateOf(["WEIGHT", "SLEEP_DURATION", "HRV_RMSSD"]), + null, + ); + expect(s.types.sort()).toEqual(["HRV_RMSSD", "WEIGHT"]); + expect(s.workouts).toBe(false); + expect(seriesCandidateTypes()).not.toContain("SLEEP_DURATION"); + }); + + it("pairs blood pressure only when both components are admitted", () => { + expect( + resolveSeriesScope( + gateOf(["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]), + null, + ).pairBloodPressure, + ).toBe(true); + expect( + resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null) + .pairBloodPressure, + ).toBe(false); + }); + + it("intersects the code filter with the gate", () => { + const s = resolveSeriesScope( + gateOf(["WEIGHT", "WORKOUTS"]), + parseCodeParams(["80404-7", EXERCISE_ACTIVITY_LOINC]), + ); + expect(s.types).toEqual([]); + expect(s.workouts).toBe(true); + }); +}); + +/** The structural R4 checks the conformance suite applies to every Observation. */ +function assertR4Observation(o: FhirObservation) { + expect(o.resourceType).toBe("Observation"); + expect(o.status).toBe("final"); + expect(o.subject.reference).toBe("Patient/patient-1"); + // effective[x]: exactly one of dateTime / period. + expect(Boolean(o.effectiveDateTime) !== Boolean(o.effectivePeriod)).toBe( + true, + ); + // value[x]: at most one. + const values = [ + o.valueQuantity, + o.valueCodeableConcept, + o.valueString, + o.valueInteger, + ].filter((v) => v !== undefined); + expect(values.length).toBeLessThanOrEqual(1); + // A coded UCUM unit is a parseable one, and system + code travel together. + const quantities = [ + o.valueQuantity, + ...(o.component ?? []).map((c) => c.valueQuantity), + ].filter(Boolean); + for (const q of quantities) { + if (q!.code !== undefined) expect(isUcumCode(q!.code)).toBe(true); + expect(q!.code === undefined).toBe(q!.system === undefined); + } + // vital-signs category promises a LOINC code. + if (o.category?.some((c) => c.coding?.[0]?.code === "vital-signs")) { + const loinc = o.code.coding?.some((c) => c.system === "http://loinc.org"); + const textOnly = !o.code.coding && Boolean(o.code.text); + expect(loinc || textOnly).toBe(true); + } + expect(JSON.parse(JSON.stringify(o))).toEqual(o); +} + +const AT = new Date("2026-03-04T07:30:00.000Z"); + +describe("measurementSeriesObservation", () => { + it("emits one reading per row with its own id and instant", () => { + const o = measurementSeriesObservation( + { + id: "abc", + type: "WEIGHT", + value: 79.45, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.id).toBe("m-abc"); + expect(o.effectiveDateTime).toBe(AT.toISOString()); + expect(o.valueQuantity?.value).toBe(79.45); + expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc); + }); + + it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => { + const o = measurementSeriesObservation( + { + id: "r1", + type: "HRV_RMSSD", + value: 38, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.code.text).toBe("Heart rate variability RMSSD"); + expect(o.code.coding).toBeUndefined(); + expect(o.valueQuantity).toMatchObject({ value: 38, unit: "ms" }); + }); + + it("reads an hourly heart-rate bucket as a mean over its hour", () => { + const o = measurementSeriesObservation( + { + id: "h1", + type: "PULSE", + value: 72, + valueMin: 60, + valueMax: 95, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.effectivePeriod).toEqual({ + start: AT.toISOString(), + end: "2026-03-04T08:30:00.000Z", + }); + expect(o.note?.[0]?.text).toContain("60 to 95"); + }); + + it("pairs a systolic reading with its diastolic partner as the BP panel", () => { + const o = measurementSeriesObservation( + { + id: "s1", + type: "BLOOD_PRESSURE_SYS", + value: 131, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + 84, + )!; + assertR4Observation(o); + expect(o.code.coding?.[0]?.code).toBe("85354-9"); + expect(o.component?.map((c) => c.valueQuantity?.value)).toEqual([131, 84]); + }); + + it("emits a lone component under its own code and outside the vital-signs profile", () => { + const o = measurementSeriesObservation( + { + id: "d1", + type: "BLOOD_PRESSURE_DIA", + value: 84, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + )!; + assertR4Observation(o); + expect(o.code.coding?.[0]?.code).toBe("8462-4"); + expect(o.category).toBeUndefined(); + }); + + it("codes glucose by context and values it in the owner's unit", () => { + const o = measurementSeriesObservation( + { + id: "g1", + type: "BLOOD_GLUCOSE", + value: 99, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: "FASTING", + }, + "mmol/L", + null, + )!; + assertR4Observation(o); + expect(o.code.coding?.[0]?.code).toBe("1558-6"); + expect(o.valueQuantity?.unit).toBe("mmol/L"); + expect(o.valueQuantity?.value).toBeCloseTo(5.5, 1); + }); + + it("emits nothing for sleep segments", () => { + expect( + measurementSeriesObservation( + { + id: "z", + type: "SLEEP_DURATION", + value: 90, + valueMin: null, + valueMax: null, + measuredAt: AT, + glucoseContext: null, + }, + "mg/dL", + null, + ), + ).toBeNull(); + }); +}); + +describe("workoutSeriesObservation", () => { + it("emits an activity Observation per the Physical Activity IG pattern", () => { + const o = workoutSeriesObservation({ + id: "w1", + sportType: "running", + startedAt: AT, + endedAt: new Date("2026-03-04T08:15:30.000Z"), + durationSec: 2730, + totalDistanceM: 8012.5, + totalEnergyKcal: 610, + avgHeartRate: 151, + }); + assertR4Observation(o); + expect(o.id).toBe("w-w1"); + expect(o.category?.[0]?.coding?.[0]?.code).toBe("activity"); + expect(o.code.coding?.[0]?.code).toBe(EXERCISE_ACTIVITY_LOINC); + expect(o.valueCodeableConcept?.coding?.[0]?.code).toBe("running"); + expect(o.effectivePeriod?.end).toBe("2026-03-04T08:15:30.000Z"); + const byCode = Object.fromEntries( + (o.component ?? []).map((c) => [ + c.code.coding?.[0]?.code ?? c.code.text, + c.valueQuantity, + ]), + ); + expect(byCode[EXERCISE_DURATION_LOINC]).toMatchObject({ + value: 45.5, + code: "min", + }); + expect(byCode[CALORIES_BURNED_LOINC]).toMatchObject({ value: 610 }); + expect(byCode.Distance).toMatchObject({ value: 8012.5, unit: "m" }); + }); + + it("leaves out the components a source did not report", () => { + const o = workoutSeriesObservation({ + id: "w2", + sportType: "yoga", + startedAt: AT, + endedAt: AT, + durationSec: 600, + totalDistanceM: null, + totalEnergyKcal: null, + avgHeartRate: null, + }); + expect(o.component).toHaveLength(1); + }); +}); + +describe("matchesCodeTokens", () => { + it("matches a bare code on any system and a system|code pair exactly", () => { + const o = { + code: { coding: [{ system: "http://loinc.org", code: "29463-7" }] }, + } as FhirObservation; + expect(matchesCodeTokens(o, ["29463-7"])).toBe(true); + expect(matchesCodeTokens(o, ["http://loinc.org|29463-7"])).toBe(true); + expect(matchesCodeTokens(o, ["urn:x|29463-7"])).toBe(false); + expect(matchesCodeTokens(o, ["8480-6,29463-7"])).toBe(true); + }); +}); diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index 87ddb0a9c..75e87ecf2 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -24,7 +24,15 @@ import { } from "@/lib/doctor-report-data"; import type { FhirRecordInputs } from "@/lib/fhir/build-bundle"; import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { resolveReportGate } from "@/lib/doctor-report/selection-gate"; +import { + resolveSeriesScope, + seriesGlucoseUnit, + type SeriesCodeFilter, + type SeriesScope, +} from "@/lib/fhir/series"; +import type { GlucoseUnit } from "@/lib/glucose"; import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto"; import type { FhirBundleEntry, @@ -65,6 +73,16 @@ export const FHIR_EVERYTHING_OPERATION = "$everything"; /** Search parameters honoured uniformly across the search routes. */ export const FHIR_SEARCH_PARAMS = ["_count", "_offset"] as const; +/** + * Further search parameters the Observation search honours (v1.44). `date` + * switches the answer from the snapshot to the time series of every reading + * in the range; `code` narrows either answer to the named codings. + */ +export const FHIR_OBSERVATION_SEARCH_PARAMS = [ + { name: "date", type: "date" }, + { name: "code", type: "token" }, +] as const; + /** Canonical FHIR media type for every response (success or outcome). */ export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8"; @@ -308,3 +326,30 @@ export async function loadFhirContext(userId: string): Promise<{ }, }; } + +/** + * The series face's scope: the same saved selection and module map the + * snapshot replays, narrowed by an optional `code` filter, plus the owner's + * glucose unit. Reads two small rows and the selection — never the + * whole-record aggregate, so a series page costs what its page costs. + * + * Carries the same module backstop as {@link loadFhirContext}. + */ +export async function loadFhirSeriesScope( + userId: string, + codes: SeriesCodeFilter | null, +): Promise<{ scope: SeriesScope; glucoseUnit: GlucoseUnit }> { + if (!(await isModuleEnabled(userId, "doctorReport"))) { + throw new Error( + 'loadFhirSeriesScope called with the "doctorReport" module disabled — ' + + "the calling route is missing its requireModuleEnabled gate", + ); + } + const [selection, moduleMap, glucoseUnit] = await Promise.all([ + resolveSavedSelection(userId), + resolveModuleMap(userId), + seriesGlucoseUnit(userId), + ]); + const gate = resolveReportGate(selection, moduleMap); + return { scope: resolveSeriesScope(gate, codes), glucoseUnit }; +} diff --git a/src/lib/fhir/series.ts b/src/lib/fhir/series.ts new file mode 100644 index 000000000..b7f02fa9f --- /dev/null +++ b/src/lib/fhir/series.ts @@ -0,0 +1,765 @@ +/** + * The time-series face of `GET /api/fhir/Observation`: every recorded reading + * as its own Observation, newest first, paged in the database. + * + * The search route's default answer stays what it always was, a snapshot of + * the record (one latest reading per type plus the derived panels, ids + * `obs-N`). A client opts into the series by naming a `date` range, which is + * what a FHIR client does when it wants readings over time; the snapshot has + * no date range to filter, so the parameter never meant anything there + * before. Additive by construction: no existing request changes its answer. + * + * date=ge2026-01-01&date=lt2026-02-01 readings in January (UTC) + * code=http://loinc.org|80404-7 narrowed to SDNN (also in snapshot mode) + * _count / _offset paging, as on every search route + * + * Series ids are the row's own (`m-` for a measurement, `w-` for a + * workout) so a reading keeps its id across pages and windows, and + * `GET /Observation/{id}` reads it back. + * + * Scope is the snapshot's: the owner's saved report selection and module map + * decide which types may appear (`resolveReportGate`), so the series can + * never reach a type the snapshot would withhold. Memory is bounded by the + * page: one id query (a UNION over measurements and workouts, ordered and + * paged in SQL), one count, and a by-id read of at most `_count` rows. + * + * Every row as recorded. Two sources writing the same weighing are two + * readings here; the snapshot is the de-duplicated view. Sleep is not in the + * series: it is stored as per-stage segments, and a segment is not a night. + */ +import { Prisma } from "@/generated/prisma/client"; +import type { + GlucoseContext, + MeasurementType, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + convertGlucose, + resolveGlucoseUnit, + type GlucoseUnit, +} from "@/lib/glucose"; +import { + BP_DIA_LOINC, + BP_PANEL_LOINC, + BP_SYS_LOINC, + BP_UNIT, + GLUCOSE_LOINC, + HEALTHKIT_CODESYSTEM, + LOINC_SYSTEM, + MEASUREMENT_LOINC, +} from "@/lib/fhir/loinc-map"; +import { ucumQuantity } from "@/lib/fhir/ucum"; +import type { + FhirObservation, + FhirObservationComponent, +} from "@/lib/fhir/types"; +import { + categoryConcept, + codeableFromMapping, + patientRef, +} from "@/lib/fhir/resources/common"; +import type { ReportGate } from "@/lib/doctor-report/selection-gate"; + +// ── date parameter ────────────────────────────────────────────────────── + +/** A half-open instant range; either end may be open. */ +export interface SeriesRange { + from: Date | null; + to: Date | null; +} + +const DATE_RE = + /^(eq|gt|ge|lt|le)?(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/; + +/** + * The instant span one FHIR date value covers: a year, a month, a day, a + * minute or a second. A value without an offset is read as UTC. + */ +function dateSpan(match: RegExpMatchArray): { start: Date; end: Date } | null { + const [, , y, mo, d, h, mi, s, tz] = match; + const year = Number(y); + if (mo === undefined) { + return { + start: new Date(Date.UTC(year, 0, 1)), + end: new Date(Date.UTC(year + 1, 0, 1)), + }; + } + const month = Number(mo) - 1; + if (month < 0 || month > 11) return null; + if (d === undefined) { + return { + start: new Date(Date.UTC(year, month, 1)), + end: new Date(Date.UTC(year, month + 1, 1)), + }; + } + const day = Number(d); + const dayStart = new Date(Date.UTC(year, month, day)); + if (dayStart.getUTCMonth() !== month || dayStart.getUTCDate() !== day) { + return null; + } + if (h === undefined) { + return { start: dayStart, end: new Date(dayStart.getTime() + 86_400_000) }; + } + const iso = `${y}-${mo}-${d}T${h}:${mi}:${s ?? "00"}${tz ?? "Z"}`; + const start = new Date(iso); + if (Number.isNaN(start.getTime())) return null; + return { start, end: new Date(start.getTime() + (s ? 1000 : 60_000)) }; +} + +/** + * Parse every `date` value into one range (their intersection). Returns null + * when no `date` parameter was sent, and an error string for a value the + * server cannot read — a 400, never a silently ignored filter. + */ +export function parseDateParams( + values: readonly string[], +): SeriesRange | { error: string } | null { + if (values.length === 0) return null; + let from: Date | null = null; + let to: Date | null = null; + const later = (a: Date | null, b: Date) => (a && a > b ? a : b); + const earlier = (a: Date | null, b: Date) => (a && a < b ? a : b); + for (const raw of values) { + for (const value of raw.split(",")) { + const match = value.trim().match(DATE_RE); + const span = match ? dateSpan(match) : null; + if (!match || !span) { + return { error: `Unsupported date value: ${value.slice(0, 40)}` }; + } + switch (match[1] ?? "eq") { + case "eq": + from = later(from, span.start); + to = earlier(to, span.end); + break; + case "ge": + from = later(from, span.start); + break; + case "gt": + from = later(from, span.end); + break; + case "le": + to = earlier(to, span.end); + break; + case "lt": + to = earlier(to, span.start); + break; + } + } + } + return { from, to }; +} + +// ── code parameter ────────────────────────────────────────────────────── + +/** Exercise activity — the LOINC a workout Observation is coded with. */ +export const EXERCISE_ACTIVITY_LOINC = "73985-4"; +/** Exercise duration (min). */ +export const EXERCISE_DURATION_LOINC = "55411-3"; +/** Calories burned (kcal). */ +export const CALORIES_BURNED_LOINC = "41981-2"; +/** Local CodeSystem for the canonical workout sport keys. */ +export const WORKOUT_SPORT_CODESYSTEM = + "https://healthlog.dev/fhir/CodeSystem/workout-sport"; + +/** What a set of codes selects from the series. */ +export interface SeriesCodeFilter { + types: Set; + /** Glucose contexts a glucose code narrows to; null keeps every context. */ + glucoseContexts: Set | null; + workouts: boolean; +} + +const GLUCOSE_CONTEXT_OF: Record = { + FASTING: "FASTING", + POSTPRANDIAL: "POSTPRANDIAL", + RANDOM: "RANDOM", + BEDTIME: "BEDTIME", + UNSPECIFIED: "NONE", +}; + +/** + * Resolve `code` tokens (`system|code` or a bare code, comma-separated, any + * number of parameters) to the types they select. Returns null when no + * `code` was sent. An unknown code selects nothing — an empty Bundle, which + * is FHIR's answer to a search that matches nothing. + */ +export function parseCodeParams( + values: readonly string[], +): SeriesCodeFilter | null { + if (values.length === 0) return null; + const filter: SeriesCodeFilter = { + types: new Set(), + glucoseContexts: null, + workouts: false, + }; + let glucoseContexts: Set | null = null; + for (const raw of values) { + for (const token of raw.split(",")) { + const bar = token.indexOf("|"); + const system = bar >= 0 ? token.slice(0, bar) : null; + const code = (bar >= 0 ? token.slice(bar + 1) : token).trim(); + if (!code) continue; + const loinc = system === null || system === "" || system === LOINC_SYSTEM; + const healthkit = system === null || system === HEALTHKIT_CODESYSTEM; + if (loinc && code === EXERCISE_ACTIVITY_LOINC) filter.workouts = true; + if ( + loinc && + (code === BP_PANEL_LOINC || + code === BP_SYS_LOINC || + code === BP_DIA_LOINC) + ) { + filter.types.add("BLOOD_PRESSURE_SYS"); + filter.types.add("BLOOD_PRESSURE_DIA"); + } + if (loinc) { + for (const [ctx, map] of Object.entries(GLUCOSE_LOINC)) { + if (map.loinc !== code) continue; + filter.types.add("BLOOD_GLUCOSE"); + glucoseContexts ??= new Set(); + glucoseContexts.add(GLUCOSE_CONTEXT_OF[ctx]); + } + } + for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) { + if (mapping.loinc !== code) continue; + const isHk = mapping.loinc.startsWith("HKQuantityTypeIdentifier"); + if ((isHk && healthkit) || (!isHk && loinc)) { + filter.types.add(type as MeasurementType); + } + } + } + } + filter.glucoseContexts = glucoseContexts; + return filter; +} + +// ── scope ─────────────────────────────────────────────────────────────── + +/** Types the series can carry: every single-value coding, BP and glucose. Never sleep. */ +export function seriesCandidateTypes(): MeasurementType[] { + const types = Object.keys(MEASUREMENT_LOINC).filter( + (t) => t !== "SLEEP_DURATION", + ) as MeasurementType[]; + return [ + ...types, + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "BLOOD_GLUCOSE", + ]; +} + +export interface SeriesScope { + types: MeasurementType[]; + /** Pair diastolic onto systolic (both admitted): the BP panel. */ + pairBloodPressure: boolean; + glucoseContexts: Set | null; + workouts: boolean; +} + +/** The gate's answer, narrowed by an optional code filter. */ +export function resolveSeriesScope( + gate: Pick, + codes: SeriesCodeFilter | null, +): SeriesScope { + const types = seriesCandidateTypes().filter( + (t) => gate.admits(t) && (codes === null || codes.types.has(t)), + ); + return { + types, + pairBloodPressure: + types.includes("BLOOD_PRESSURE_SYS") && + types.includes("BLOOD_PRESSURE_DIA"), + glucoseContexts: codes?.glucoseContexts ?? null, + workouts: gate.admits("WORKOUTS") && (codes === null || codes.workouts), + }; +} + +// ── query ─────────────────────────────────────────────────────────────── + +/** One page of series row references, newest first. */ +interface SeriesRef { + kind: "m" | "w"; + id: string; +} + +function rangeSql(column: string, range: SeriesRange): Prisma.Sql { + const col = Prisma.raw(column); + return Prisma.sql`${ + range.from ? Prisma.sql`AND ${col} >= ${range.from}` : Prisma.empty + } ${range.to ? Prisma.sql`AND ${col} < ${range.to}` : Prisma.empty}`; +} + +/** + * The UNION the page and the count share. Every value is a bound parameter; + * the only spliced text is the closed column names above. + */ +function seriesUnion( + userId: string, + scope: SeriesScope, + range: SeriesRange, +): Prisma.Sql | null { + const parts: Prisma.Sql[] = []; + if (scope.types.length > 0) { + const contexts = scope.glucoseContexts; + const glucoseFilter = + contexts === null + ? Prisma.empty + : Prisma.sql`AND (m."type" <> 'BLOOD_GLUCOSE'::"measurement_type" OR m."glucose_context"::text = ANY(${[ + ...contexts, + ].filter((c) => c !== "NONE")}::text[]) OR (${contexts.has( + "NONE", + )}::boolean AND m."glucose_context" IS NULL))`; + // A diastolic reading that has its systolic partner rides inside the + // systolic row's panel, never as a second entry. + const pairFilter = scope.pairBloodPressure + ? Prisma.sql`AND NOT (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND EXISTS ( + SELECT 1 FROM measurements s + WHERE s."user_id" = m."user_id" + AND s."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" + AND s."measured_at" = m."measured_at" + AND s."deleted_at" IS NULL))` + : Prisma.empty; + parts.push(Prisma.sql` + SELECT 'm' AS kind, m."id" AS id, m."measured_at" AS at + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."type"::text = ANY(${scope.types}::text[]) + ${rangeSql('m."measured_at"', range)} + ${glucoseFilter} + ${pairFilter}`); + } + if (scope.workouts) { + parts.push(Prisma.sql` + SELECT 'w' AS kind, w."id" AS id, w."started_at" AS at + FROM workouts w + WHERE w."user_id" = ${userId} + ${rangeSql('w."started_at"', range)}`); + } + if (parts.length === 0) return null; + return Prisma.join(parts, " UNION ALL "); +} + +/** The total match count and one page of references, newest first. */ +export async function querySeriesPage(params: { + userId: string; + scope: SeriesScope; + range: SeriesRange; + count: number; + offset: number; +}): Promise<{ total: number; refs: SeriesRef[] }> { + const { userId, scope, range, count, offset } = params; + const union = seriesUnion(userId, scope, range); + if (!union) return { total: 0, refs: [] }; + const [totals, refs] = await Promise.all([ + prisma.$queryRaw>` + SELECT COUNT(*) AS total FROM (${union}) AS series`, + prisma.$queryRaw` + SELECT kind, id FROM (${union}) AS series + ORDER BY at DESC, id DESC + LIMIT ${count} OFFSET ${offset}`, + ]); + return { total: Number(totals[0]?.total ?? 0), refs }; +} + +// ── emit ──────────────────────────────────────────────────────────────── + +const MEASUREMENT_SELECT = { + id: true, + type: true, + value: true, + valueMin: true, + valueMax: true, + measuredAt: true, + glucoseContext: true, +} as const; + +type SeriesMeasurementRow = { + id: string; + type: MeasurementType; + value: number; + valueMin: number | null; + valueMax: number | null; + measuredAt: Date; + glucoseContext: GlucoseContext | null; +}; + +type SeriesWorkoutRow = { + id: string; + sportType: string; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalDistanceM: number | null; + totalEnergyKcal: number | null; + avgHeartRate: number | null; +}; + +const WORKOUT_SELECT = { + id: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, +} as const; + +function bpComponent( + code: string, + display: string, + value: number, +): FhirObservationComponent { + return { + code: { coding: [{ system: LOINC_SYSTEM, code, display }] }, + valueQuantity: ucumQuantity(value, BP_UNIT), + }; +} + +const HOUR_MS = 60 * 60 * 1000; + +/** One measurement row as an Observation; null for a type the series does not code. */ +export function measurementSeriesObservation( + row: SeriesMeasurementRow, + glucoseUnit: GlucoseUnit, + diastolic: number | null, +): FhirObservation | null { + const id = `m-${row.id}`; + const at = row.measuredAt.toISOString(); + if (row.type === "BLOOD_PRESSURE_SYS" || row.type === "BLOOD_PRESSURE_DIA") { + const sys = row.type === "BLOOD_PRESSURE_SYS"; + if (sys && diastolic !== null) { + return { + resourceType: "Observation", + id, + status: "final", + category: [categoryConcept("vital-signs")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: BP_PANEL_LOINC, + display: "Blood pressure panel with all children optional", + }, + ], + text: "Blood pressure", + }, + subject: patientRef, + effectiveDateTime: at, + component: [ + bpComponent(BP_SYS_LOINC, "Systolic blood pressure", row.value), + bpComponent(BP_DIA_LOINC, "Diastolic blood pressure", diastolic), + ], + }; + } + // A lone component (its partner withheld or never recorded) is still a + // reading, coded with its own LOINC. It carries no vital-signs category: + // the R4 blood-pressure profile wants both components, and a category + // that promises the profile would be a claim the resource cannot keep. + return { + resourceType: "Observation", + id, + status: "final", + code: { + coding: [ + sys + ? { + system: LOINC_SYSTEM, + code: BP_SYS_LOINC, + display: "Systolic blood pressure", + } + : { + system: LOINC_SYSTEM, + code: BP_DIA_LOINC, + display: "Diastolic blood pressure", + }, + ], + text: sys ? "Systolic blood pressure" : "Diastolic blood pressure", + }, + subject: patientRef, + effectiveDateTime: at, + valueQuantity: ucumQuantity(row.value, BP_UNIT), + }; + } + if (row.type === "BLOOD_GLUCOSE") { + const map = GLUCOSE_LOINC[row.glucoseContext ?? "UNSPECIFIED"]; + if (!map) return null; + return { + resourceType: "Observation", + id, + status: "final", + category: [categoryConcept("laboratory")], + code: { + coding: [ + { system: LOINC_SYSTEM, code: map.loinc, display: map.display }, + ], + text: map.display, + }, + subject: patientRef, + effectiveDateTime: at, + valueQuantity: ucumQuantity( + convertGlucose(row.value, glucoseUnit), + glucoseUnit, + ), + }; + } + const mapping = MEASUREMENT_LOINC[row.type]; + if (!mapping || row.type === "SLEEP_DURATION") return null; + // An hourly heart-rate bucket (min and max stored beside the mean) is a + // mean over its hour, not a reading at its first instant. + const hourly = row.valueMin !== null && row.valueMax !== null; + return { + resourceType: "Observation", + id, + status: "final", + category: [categoryConcept(mapping.category)], + code: codeableFromMapping(mapping), + subject: patientRef, + ...(hourly + ? { + effectivePeriod: { + start: at, + end: new Date(row.measuredAt.getTime() + HOUR_MS).toISOString(), + }, + note: [ + { + text: `Mean over the hour; range ${row.valueMin} to ${row.valueMax} ${mapping.unit}.`, + }, + ], + } + : { effectiveDateTime: at }), + valueQuantity: ucumQuantity(row.value, mapping.unit), + }; +} + +/** The sport key in words, for `valueCodeableConcept.text`. */ +function sportText(sportType: string): string { + return sportType + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .toLowerCase(); +} + +/** + * A workout as an Observation, after the HL7 Physical Activity IG: the + * session is an `activity` Observation coded 73985-4 (Exercise activity) whose + * value is the kind of exercise, over the session's period, with duration and + * energy as LOINC-coded components. Not a Procedure: R4 reserves Procedure + * for something done to or for the patient by a care process, and a run is a + * measured behaviour. + */ +export function workoutSeriesObservation( + row: SeriesWorkoutRow, +): FhirObservation { + const component: FhirObservationComponent[] = [ + { + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: EXERCISE_DURATION_LOINC, + display: "Exercise duration", + }, + ], + }, + valueQuantity: ucumQuantity( + Math.round((row.durationSec / 60) * 100) / 100, + "min", + ), + }, + ]; + if (row.totalEnergyKcal !== null && row.totalEnergyKcal > 0) { + component.push({ + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: CALORIES_BURNED_LOINC, + display: "Calories burned", + }, + ], + }, + valueQuantity: ucumQuantity(row.totalEnergyKcal, "kcal"), + }); + } + if (row.totalDistanceM !== null && row.totalDistanceM > 0) { + component.push({ + code: { text: "Distance" }, + valueQuantity: ucumQuantity(row.totalDistanceM, "m"), + }); + } + if (row.avgHeartRate !== null && row.avgHeartRate > 0) { + component.push({ + code: { text: "Average heart rate during exercise" }, + valueQuantity: ucumQuantity(row.avgHeartRate, "/min"), + }); + } + return { + resourceType: "Observation", + id: `w-${row.id}`, + status: "final", + category: [categoryConcept("activity")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: EXERCISE_ACTIVITY_LOINC, + display: "Exercise activity", + }, + ], + text: "Exercise activity", + }, + subject: patientRef, + effectivePeriod: { + start: row.startedAt.toISOString(), + end: row.endedAt.toISOString(), + }, + valueCodeableConcept: { + coding: [{ system: WORKOUT_SPORT_CODESYSTEM, code: row.sportType }], + text: sportText(row.sportType), + }, + component, + }; +} + +/** The diastolic value recorded at the same instant as each systolic row. */ +async function diastolicPartners( + userId: string, + systolicAt: Date[], +): Promise> { + if (systolicAt.length === 0) return new Map(); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_PRESSURE_DIA", + deletedAt: null, + measuredAt: { in: systolicAt }, + }, + select: { value: true, measuredAt: true }, + }); + return new Map(rows.map((r) => [r.measuredAt.getTime(), r.value])); +} + +/** Load and emit one page of references, keeping their order. */ +export async function emitSeriesPage(params: { + userId: string; + refs: readonly SeriesRef[]; + scope: SeriesScope; + glucoseUnit: GlucoseUnit; +}): Promise { + const { userId, refs, scope, glucoseUnit } = params; + const mIds = refs.filter((r) => r.kind === "m").map((r) => r.id); + const wIds = refs.filter((r) => r.kind === "w").map((r) => r.id); + const [measurements, workouts] = await Promise.all([ + mIds.length > 0 + ? prisma.measurement.findMany({ + where: { userId, id: { in: mIds }, deletedAt: null }, + select: MEASUREMENT_SELECT, + }) + : Promise.resolve([]), + wIds.length > 0 + ? prisma.workout.findMany({ + where: { userId, id: { in: wIds } }, + select: WORKOUT_SELECT, + }) + : Promise.resolve([]), + ]); + const partners = scope.pairBloodPressure + ? await diastolicPartners( + userId, + measurements + .filter((m) => m.type === "BLOOD_PRESSURE_SYS") + .map((m) => m.measuredAt), + ) + : new Map(); + const byM = new Map(measurements.map((m) => [m.id, m])); + const byW = new Map(workouts.map((w) => [w.id, w])); + const out: FhirObservation[] = []; + for (const ref of refs) { + if (ref.kind === "w") { + const w = byW.get(ref.id); + if (w) out.push(workoutSeriesObservation(w)); + continue; + } + const m = byM.get(ref.id); + if (!m) continue; + const obs = measurementSeriesObservation( + m, + glucoseUnit, + m.type === "BLOOD_PRESSURE_SYS" + ? (partners.get(m.measuredAt.getTime()) ?? null) + : null, + ); + if (obs) out.push(obs); + } + return out; +} + +/** + * Read one series Observation by its id (`m-…` / `w-…`), inside the same + * scope the search applies. Null for an id outside the caller's record or + * scope — the route answers both with the same `not-found`. + */ +export async function readSeriesObservation(params: { + userId: string; + id: string; + scope: SeriesScope; + glucoseUnit: GlucoseUnit; +}): Promise { + const { userId, id, scope, glucoseUnit } = params; + if (id.startsWith("w-")) { + if (!scope.workouts) return null; + const w = await prisma.workout.findFirst({ + where: { userId, id: id.slice(2) }, + select: WORKOUT_SELECT, + }); + return w ? workoutSeriesObservation(w) : null; + } + if (!id.startsWith("m-")) return null; + const m = await prisma.measurement.findFirst({ + where: { userId, id: id.slice(2), deletedAt: null }, + select: MEASUREMENT_SELECT, + }); + if (!m || !scope.types.includes(m.type)) return null; + const partners = + scope.pairBloodPressure && m.type === "BLOOD_PRESSURE_SYS" + ? await diastolicPartners(userId, [m.measuredAt]) + : new Map(); + return measurementSeriesObservation( + m, + glucoseUnit, + partners.get(m.measuredAt.getTime()) ?? null, + ); +} + +/** The owner's display glucose unit, for series values. */ +export async function seriesGlucoseUnit(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { glucoseUnit: true }, + }); + return resolveGlucoseUnit(user?.glucoseUnit ?? null); +} + +/** + * Snapshot-mode `code` filter: keep the Observations whose `code` carries one + * of the tokens (`system|code`, or a bare code matching any system). + */ +export function matchesCodeTokens( + observation: FhirObservation, + values: readonly string[], +): boolean { + const tokens = values.flatMap((v) => v.split(",")).map((t) => t.trim()); + const codings = observation.code.coding ?? []; + return tokens.some((token) => { + if (!token) return false; + const bar = token.indexOf("|"); + const system = bar >= 0 ? token.slice(0, bar) : null; + const code = bar >= 0 ? token.slice(bar + 1) : token; + return codings.some( + (c) => + c.code === code && + (system === null || system === "" || c.system === system), + ); + }); +} diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 43845b213..9673e3aa3 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -353,7 +353,7 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 CapabilityStatement (v1.11.0)", description: - "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).", + "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).", responses: { "200": { description: "CapabilityStatement (application/fhir+json).", @@ -397,11 +397,13 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 Observation search (v1.11.0)", description: - "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`.", + "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-` / `w-` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 OperationOutcome.", requestParams: { query: z.object({ _count: z.coerce.number().optional(), _offset: z.coerce.number().optional(), + date: z.string().optional(), + code: z.string().optional(), }), }, responses: { @@ -557,7 +559,7 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 Observation read", description: - "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.", + "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id (`m-` / `w-`, v1.44), the latter read inside the same saved selection the series search applies. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.", requestParams: { path: z.object({ id: z.string() }), }, diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index c55d1200c..1c1051344 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -59,6 +59,12 @@ "unit": "ms", "category": "vital-signs" }, + "HRV_RMSSD": { + "loinc": null, + "display": "Heart rate variability RMSSD", + "unit": "ms", + "category": "vital-signs" + }, "ACTIVITY_STEPS": { "loinc": "41950-7", "display": "Number of steps in 24 hour Measured", diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index cf467c8be..cd1eabcac 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -211,6 +211,34 @@ export const SIGNALS: Record = { category: "vital-signs", }, }, + // v1.44 — RMSSD, the heart-rate-variability statistic rings and bands + // report (Oura, WHOOP, Polar, Fitbit, Google Health). Registered for its FHIR + // coding only: every other surface reads it through its own tables today, so + // all surface flags stay off and nothing else derives from this entry. + // LOINC publishes an SDNN term (80404-7, above) and none for RMSSD, so the + // coding is text-only rather than borrowing SDNN's code for a different + // statistic. + HRV_RMSSD: { + key: "HRV_RMSSD", + kind: "measurement", + source: { measurementType: "HRV_RMSSD" }, + displayName: "Heart-rate variability (RMSSD)", + unit: "ms", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: null, + display: "Heart rate variability RMSSD", + unit: "ms", + category: "vital-signs", + }, + }, OXYGEN_SATURATION: { key: "OXYGEN_SATURATION", kind: "measurement", diff --git a/tests/integration/fhir-series-and-medication-effect.test.ts b/tests/integration/fhir-series-and-medication-effect.test.ts new file mode 100644 index 000000000..b2cdc169a --- /dev/null +++ b/tests/integration/fhir-series-and-medication-effect.test.ts @@ -0,0 +1,362 @@ +/** + * v1.44 against a real Postgres: the FHIR Observation time series (the UNION + * page query, its count, the BP pairing, the glucose-context filter, owner and + * soft-delete scoping), the segmented medication effect read end to end, and + * the doctor report's workout and effect blocks. + * + * The effect fixture: an ACE inhibitor started on 2026-03-01 with systolic + * readings of exactly 140 for 60 days before and 130 − 0.1·t after, so the + * fit must recover a level change of −10 mmHg and a slope change of + * −0.7 mmHg/week, and a second medication starting inside the window must be + * listed as a confounder. + */ +import { beforeAll, describe, expect, it } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { + emitSeriesPage, + querySeriesPage, + resolveSeriesScope, +} from "@/lib/fhir/series"; +import { + EFFECT_MEDICATION_INCLUDE, + loadSegmentedEffects, +} from "@/lib/medications/efficacy/load-segmented-effects"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; +import type { ReportLeafId } from "@/lib/report-selection/catalogue"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const DAY = 86_400_000; +const PIVOT = Date.parse("2026-03-01T00:00:00.000Z"); + +let userId = ""; +let otherId = ""; +let medId = ""; + +const MODULES = { + cycle: false, + mood: false, + sleep: true, + glucose: true, + workouts: true, + recovery: true, + labs: true, + illness: true, + achievements: true, + coach: true, + insights: true, + medications: true, + doctorReport: true, + environment: true, + mcp: true, + inboundDocuments: true, + mentalHealth: true, + nutrients: true, + vaccinations: true, + timeline: true, +} as const; + +const gateOf = (leaves: ReportLeafId[]) => ({ + admits: (leaf: ReportLeafId) => leaves.includes(leaf), +}); + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const user = await prisma.user.create({ + data: { + username: "fhir-series-effect", + email: "fhir-series-effect@example.test", + timezone: "UTC", + }, + }); + userId = user.id; + const other = await prisma.user.create({ + data: { + username: "fhir-series-other", + email: "fhir-series-other@example.test", + timezone: "UTC", + }, + }); + otherId = other.id; + + const rows: Prisma.MeasurementCreateManyInput[] = []; + // Systolic: 140 before, 130 − 0.1·t after (one reading a day at 08:00). + for (let t = -60; t < 60; t++) { + rows.push({ + userId, + type: "BLOOD_PRESSURE_SYS", + value: t < 0 ? 140 : 130 - 0.1 * t, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + t * DAY + 8 * 3_600_000), + }); + } + // Two paired diastolic readings and one lone diastolic. + rows.push( + { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 85, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 10 * DAY + 8 * 3_600_000), + }, + { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 84, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 11 * DAY + 8 * 3_600_000), + }, + { + userId, + type: "BLOOD_PRESSURE_DIA", + value: 80, + unit: "mmHg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 11 * DAY + 20 * 3_600_000), + }, + ); + rows.push( + { + userId, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source: "MANUAL", + glucoseContext: "FASTING", + measuredAt: new Date(PIVOT + 12 * DAY), + }, + { + userId, + type: "BLOOD_GLUCOSE", + value: 130, + unit: "mg/dL", + source: "MANUAL", + glucoseContext: null, + measuredAt: new Date(PIVOT + 12 * DAY + 3_600_000), + }, + { + userId, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 13 * DAY), + deletedAt: new Date(PIVOT + 14 * DAY), + }, + { + userId: otherId, + type: "WEIGHT", + value: 70, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(PIVOT + 13 * DAY), + }, + ); + await prisma.measurement.createMany({ data: rows }); + + await prisma.workout.createMany({ + data: [ + { + userId, + source: "MANUAL", + sportType: "running", + externalId: "run-1", + startedAt: new Date(PIVOT + 11 * DAY + 12 * 3_600_000), + endedAt: new Date(PIVOT + 11 * DAY + 13 * 3_600_000), + durationSec: 3600, + totalDistanceM: 10_000, + totalEnergyKcal: 700, + avgHeartRate: 150, + }, + { + userId, + source: "MANUAL", + sportType: "running", + externalId: "run-2", + startedAt: new Date(PIVOT + 20 * DAY), + endedAt: new Date(PIVOT + 20 * DAY + 1_800_000), + durationSec: 1800, + }, + ], + }); + + const med = await prisma.medication.create({ + data: { + userId, + name: "Ramipril", + dose: "5 mg", + treatmentClass: "GENERIC", + atcCode: "C09AA05", + trackIntake: false, + startsOn: new Date(PIVOT), + }, + }); + medId = med.id; + await prisma.medication.create({ + data: { + userId, + name: "Second drug", + dose: "1 mg", + treatmentClass: "GENERIC", + startsOn: new Date(PIVOT + 20 * DAY), + }, + }); +}); + +describe("FHIR Observation series in Postgres", () => { + const range = { + from: new Date(PIVOT + 10 * DAY), + to: new Date(PIVOT + 21 * DAY), + }; + + it("pages newest first, pairs BP, scopes to the owner and drops soft-deleted rows", async () => { + const scope = resolveSeriesScope( + gateOf([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "BLOOD_GLUCOSE", + "WEIGHT", + "WORKOUTS", + ]), + null, + ); + const { total, refs } = await querySeriesPage({ + userId, + scope, + range, + count: 200, + offset: 0, + }); + // 11 systolic (days 10..20), 1 lone diastolic, 2 glucose, 2 workouts. + // The two paired diastolic rows ride inside their systolic panels; the + // deleted weight and the other account's weight never appear. + expect(total).toBe(16); + expect(refs).toHaveLength(16); + const page = await emitSeriesPage({ + userId, + refs, + scope, + glucoseUnit: "mg/dL", + }); + expect(page).toHaveLength(16); + // Newest first: the day-20 systolic (08:00, no diastolic partner, so a + // lone 8480-6 reading), then the day-20 run (00:00). + expect(page[0].code.coding?.[0]?.code).toBe("8480-6"); + expect(page[1].id.startsWith("w-")).toBe(true); + const panels = page.filter( + (o) => + o.code.coding?.[0]?.code === "85354-9" && o.component?.length === 2, + ); + expect( + panels.map((p) => p.component?.[1]?.valueQuantity?.value).sort(), + ).toEqual([84, 85]); + expect(page.some((o) => o.code.coding?.[0]?.code === "8462-4")).toBe(true); + expect(page.some((o) => o.code.coding?.[0]?.code === "29463-7")).toBe( + false, + ); + }); + + it("pages with offset and agrees with the count", async () => { + const scope = resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null); + const first = await querySeriesPage({ + userId, + scope, + range, + count: 5, + offset: 0, + }); + const second = await querySeriesPage({ + userId, + scope, + range, + count: 5, + offset: 5, + }); + expect(first.total).toBe(11); + expect(first.refs).toHaveLength(5); + expect(second.refs).toHaveLength(5); + const ids = new Set([...first.refs, ...second.refs].map((r) => r.id)); + expect(ids.size).toBe(10); + }); + + it("narrows glucose to a code's contexts", async () => { + const scope = resolveSeriesScope(gateOf(["BLOOD_GLUCOSE"]), { + types: new Set(["BLOOD_GLUCOSE"]), + glucoseContexts: new Set(["FASTING"]), + workouts: false, + }); + const { total } = await querySeriesPage({ + userId, + scope, + range, + count: 50, + offset: 0, + }); + expect(total).toBe(1); + }); +}); + +describe("segmented medication effect in Postgres", () => { + it("recovers the known level and slope change and lists the confounder", async () => { + const prisma = getPrismaClient(); + const med = await prisma.medication.findFirstOrThrow({ + where: { id: medId }, + include: EFFECT_MEDICATION_INCLUDE, + }); + const effects = await loadSegmentedEffects({ + userId, + med, + targets: ["BLOOD_PRESSURE_SYS"], + timezone: "UTC", + now: new Date(PIVOT + 59 * DAY + 12 * 3_600_000), + }); + const e = effects.get("BLOOD_PRESSURE_SYS")!; + expect(e.present).toBe(true); + expect(e.levelChange!.estimate).toBeCloseTo(-10, 6); + expect(e.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 6); + expect(e.days.before).toBe(60); + expect(e.days.after).toBe(60); + expect(e.adherence.status).toBe("not_tracked"); + expect(e.confounders).toEqual([ + { medicationName: "Second drug", event: "started", date: "2026-03-21" }, + ]); + }); + + it("reaches the doctor report with its workout block", async () => { + const data = await collectDoctorReportData( + userId, + { + start: new Date(PIVOT), + end: new Date(PIVOT + 59 * DAY + 12 * 3_600_000), + days: 60, + }, + selectionFromLeaves([ + "MEDICATION_LIST", + "MEDICATION_EFFECT", + "BLOOD_PRESSURE_SYS", + "WORKOUTS", + ]), + { moduleMap: MODULES as never }, + ); + const effect = data.medicationEffects?.find( + (m) => m.medicationName === "Ramipril", + ); + // Diastolic was not selected, so only the systolic target rides. + expect(effect?.targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]); + expect(effect?.targets[0].effect.present).toBe(true); + expect(data.workouts?.sessions).toBe(2); + expect(data.workouts?.bySport[0]).toMatchObject({ + sportType: "running", + sessions: 2, + durationSec: 5400, + distanceM: 10_000, + distanceSessions: 1, + }); + }); +}); From 6f1232c7334d7c508af4ea238aa1b1a25e25a7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:57:04 +0200 Subject: [PATCH 46/98] fix(activity): format calendar day keys on their calendar date The week label and each day's accessible name went through the zone-aware formatters, which show a noon-UTC day key as the next day east of UTC+12. Read them in UTC like every other stated date. --- .../activity/activity-calendar-card.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/activity/activity-calendar-card.tsx b/src/components/activity/activity-calendar-card.tsx index b5ec47a75..7e9eef083 100644 --- a/src/components/activity/activity-calendar-card.tsx +++ b/src/components/activity/activity-calendar-card.tsx @@ -86,6 +86,18 @@ export function ActivityCalendarCard() { const query = useActivityCalendar(period, anchor); const data = query.data; const weekdays = useMemo(() => weekdayLabels(locale), [locale]); + // A calendar day key names the same date everywhere: read it in UTC, where + // its noon anchor sits, never in the viewer's zone. + const dayNameFormat = useMemo( + () => + new Intl.DateTimeFormat(locale, { + weekday: "long", + day: "numeric", + month: "long", + timeZone: "UTC", + }), + [locale], + ); const ratedOptions = useMemo(() => { if (!data) return [] as RatedMetric[]; @@ -106,8 +118,8 @@ export function ActivityCalendarCard() { timeZone: "UTC", }).format(dateOfKey(data.start)) : t("activityCalendar.weekRange", { - start: fmt.dateShort(dateOfKey(data.start)), - end: fmt.dateShort(dateOfKey(data.end)), + start: fmt.dateShortSmartCalendar(data.start), + end: fmt.dateShortSmartCalendar(data.end), }) : ""; @@ -126,7 +138,7 @@ export function ActivityCalendarCard() { const levelText = (level: DayLevel) => t(`activityCalendar.level.${level}`); const dayLabel = (day: CalendarDay) => { - const parts = [fmt.dateWithWeekday(dateOfKey(day.date))]; + const parts = [dayNameFormat.format(dateOfKey(day.date))]; if (day.future) return parts.join(", "); if (day.steps !== null) parts.push( From c94766efc3e988f83982d42f31b6ab9c7442c572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:57:05 +0200 Subject: [PATCH 47/98] feat(measurements): show where a metric's values come from A read-only view per metric and UTC day: every source with readings, its day value, its place in the person's source priority, and which source the charts use with the reason. A second card lines two sources up over 30, 90 or 365 days. The pick is explained from the rank the canonical SQL returns, so the page cannot disagree with the charts. Linked from the metric pages and from Settings > Source priority. --- docs/api/openapi.yaml | 908 ++++++++++++++++++ e2e/source-provenance.spec.ts | 121 +++ messages/de.json | 42 + messages/en.json | 42 + messages/es.json | 42 + messages/fr.json | 42 + messages/it.json | 42 + messages/ko.json | 42 + messages/nl.json | 42 + messages/pl.json | 42 + src/__tests__/sharing-surface-guard.test.ts | 10 +- .../measurements/provenance/compare/route.ts | 42 + src/app/api/measurements/provenance/route.ts | 40 + src/app/insights/blood-pressure/page.tsx | 3 + src/app/insights/pulse/page.tsx | 3 + src/app/insights/sleep/page.tsx | 3 + src/app/insights/sources/page.tsx | 44 + src/app/insights/weight/page.tsx | 3 + .../insights/healthkit-metric-page.tsx | 4 + .../__tests__/source-provenance-view.test.tsx | 176 ++++ .../provenance/source-provenance-link.tsx | 27 + .../provenance/source-provenance-view.tsx | 416 ++++++++ src/components/settings/sources-section.tsx | 13 + src/hooks/use-source-provenance.ts | 48 + src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/provenance.ts | 67 ++ .../provenance/__tests__/ranked-types.test.ts | 15 + .../__tests__/source-provenance.test.ts | 228 +++++ src/lib/provenance/ranked-types.ts | 35 + src/lib/provenance/source-provenance.ts | 415 ++++++++ src/lib/query-keys/index.ts | 2 + src/lib/query-keys/provenance.ts | 15 + src/lib/validations/source-provenance.ts | 132 +++ tests/integration/source-provenance.test.ts | 174 ++++ 34 files changed, 3281 insertions(+), 1 deletion(-) create mode 100644 e2e/source-provenance.spec.ts create mode 100644 src/app/api/measurements/provenance/compare/route.ts create mode 100644 src/app/api/measurements/provenance/route.ts create mode 100644 src/app/insights/sources/page.tsx create mode 100644 src/components/provenance/__tests__/source-provenance-view.test.tsx create mode 100644 src/components/provenance/source-provenance-link.tsx create mode 100644 src/components/provenance/source-provenance-view.tsx create mode 100644 src/hooks/use-source-provenance.ts create mode 100644 src/lib/openapi/routes/provenance.ts create mode 100644 src/lib/provenance/__tests__/ranked-types.test.ts create mode 100644 src/lib/provenance/__tests__/source-provenance.test.ts create mode 100644 src/lib/provenance/ranked-types.ts create mode 100644 src/lib/provenance/source-provenance.ts create mode 100644 src/lib/query-keys/provenance.ts create mode 100644 src/lib/validations/source-provenance.ts create mode 100644 tests/integration/source-provenance.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8ae7afe7f..0fcf0c875 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5106,6 +5106,311 @@ paths: "401": *a1 "422": *a4 "429": *a12 + /api/measurements/provenance: + get: + tags: + - Measurements + summary: Which source a metric's day value comes from + description: "For one metric and one UTC day: every source with readings, its day value (the total for a cumulative + metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, + and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups + and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and + no pick: every source counts there. Read-only." + parameters: + - in: query + name: type + schema: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + description: The metric. + required: true + description: The metric. + - in: query + name: date + schema: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is + explained for. + required: true + description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is + explained for. + responses: + "200": + description: The day's sources and the pick. + content: + application/json: + schema: + $ref: "#/components/schemas/DayProvenanceEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + /api/measurements/provenance/compare: + get: + tags: + - Measurements + summary: Two sources of one metric side by side + description: "Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the + ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. + Without `a` / `b` the two sources covering the most days are compared. Read-only." + parameters: + - in: query + name: type + schema: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + description: The metric. + required: true + description: The metric. + - in: query + name: from + schema: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: First UTC day, inclusive. + required: true + description: First UTC day, inclusive. + - in: query + name: to + schema: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: Last UTC day, inclusive. + required: true + description: Last UTC day, inclusive. + - in: query + name: a + schema: + description: First source. Defaults to the source covering the most days. + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: First source. Defaults to the source covering the most days. + - in: query + name: b + schema: + description: Second source. Defaults to the next one. + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: Second source. Defaults to the next one. + responses: + "200": + description: The comparison. + content: + application/json: + schema: + $ref: "#/components/schemas/SourceComparisonEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/devices: post: tags: @@ -49690,6 +49995,609 @@ components: - data - error additionalProperties: false + DayProvenanceEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DayProvenance" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayProvenance: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + date: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + aggregation: + type: string + enum: + - sum + - mean + ranked: + type: boolean + metricKey: + type: + - string + - "null" + ladder: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + picked: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + pickReason: + anyOf: + - type: string + enum: + - only_source + - highest_on_ladder + - name_tiebreak + - not_ranked + description: "`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's + sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this + metric has no ladder, every source counts." + - type: "null" + sources: + type: array + items: + type: object + properties: + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + rank: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Position on the ladder, 0 = highest; 90 = not on the ladder. + readings: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + value: + description: "The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), + the mean of the readings otherwise." + type: + - number + - "null" + min: + type: + - number + - "null" + max: + type: + - number + - "null" + firstAt: + type: string + description: ISO instant of the first reading that day. + lastAt: + type: string + unit: + type: + - string + - "null" + devices: + type: array + items: + type: string + description: Device kinds the readings carry; `unknown` when untagged. + pass: + anyOf: + - type: string + enum: + - lower_on_ladder + - not_on_ladder + - name_tiebreak + - type: "null" + description: Why this source was not the one picked; null for the picked one. + required: + - source + - rank + - readings + - value + - min + - max + - firstAt + - lastAt + - unit + - devices + - pass + additionalProperties: false + required: + - type + - date + - aggregation + - ranked + - metricKey + - ladder + - picked + - pickReason + - sources + additionalProperties: false + SourceComparisonEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SourceComparison" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SourceComparison: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + from: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + to: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + aggregation: + type: string + enum: + - sum + - mean + ranked: + type: boolean + ladder: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + unit: + type: + - string + - "null" + sources: + type: array + items: + type: object + properties: + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - source + - days + additionalProperties: false + a: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + b: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + days: + type: array + items: + type: object + properties: + date: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + a: + type: + - number + - "null" + b: + type: + - number + - "null" + picked: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + required: + - date + - a + - b + - picked + additionalProperties: false + summary: + type: object + properties: + daysA: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + daysB: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + daysBoth: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + meanA: + type: + - number + - "null" + meanB: + type: + - number + - "null" + meanDifference: + type: + - number + - "null" + meanAbsoluteDifference: + type: + - number + - "null" + pickedA: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + pickedB: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - daysA + - daysB + - daysBoth + - meanA + - meanB + - meanDifference + - meanAbsoluteDifference + - pickedA + - pickedB + additionalProperties: false + required: + - type + - from + - to + - aggregation + - ranked + - ladder + - unit + - sources + - a + - b + - days + - summary + additionalProperties: false DeviceRegisterResponse: type: object properties: diff --git a/e2e/source-provenance.spec.ts b/e2e/source-provenance.spec.ts new file mode 100644 index 000000000..24348d13d --- /dev/null +++ b/e2e/source-provenance.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The provenance page (#1204) at phone width, against mocked reads: the + * day's sources with the used one marked, the comparison table, and the + * link from Settings > Source priority. + */ +const DAY = { + type: "ACTIVITY_STEPS", + date: "2026-10-01", + aggregation: "sum", + ranked: true, + metricKey: "steps", + ladder: ["APPLE_HEALTH", "FITBIT"], + picked: "APPLE_HEALTH", + pickReason: "highest_on_ladder", + sources: [ + { + source: "APPLE_HEALTH", + rank: 0, + readings: 24, + value: 9500, + min: 10, + max: 900, + firstAt: "2026-10-01T06:00:00.000Z", + lastAt: "2026-10-01T20:00:00.000Z", + unit: "steps", + devices: ["phone", "watch"], + pass: null, + }, + { + source: "FITBIT", + rank: 1, + readings: 1, + value: 8800, + min: 8800, + max: 8800, + firstAt: "2026-10-01T23:00:00.000Z", + lastAt: "2026-10-01T23:00:00.000Z", + unit: "steps", + devices: ["band"], + pass: "lower_on_ladder", + }, + ], +}; +const COMPARE = { + type: "ACTIVITY_STEPS", + from: "2026-09-02", + to: "2026-10-01", + aggregation: "sum", + ranked: true, + ladder: ["APPLE_HEALTH", "FITBIT"], + unit: "steps", + sources: [ + { source: "APPLE_HEALTH", days: 30 }, + { source: "FITBIT", days: 28 }, + ], + a: "APPLE_HEALTH", + b: "FITBIT", + days: [{ date: "2026-10-01", a: 9500, b: 8800, picked: "APPLE_HEALTH" }], + summary: { + daysA: 30, + daysB: 28, + daysBoth: 28, + meanA: 9100, + meanB: 8700, + meanDifference: -400, + meanAbsoluteDifference: 450, + pickedA: 30, + pickedB: 0, + }, +}; + +test.describe("source provenance", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test("explains the day's pick and compares two sources", async ({ page }) => { + await page.route(/\/api\/measurements\/provenance\/compare/, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ data: COMPARE, error: null }), + }), + ); + await page.route(/\/api\/measurements\/provenance\?/, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ data: DAY, error: null }), + }), + ); + // Yesterday in UTC: the page refuses nothing, but a day the date field + // cannot pick (after today) would read as a fixture gone stale. + const day = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + await page.goto(`/insights/sources?type=ACTIVITY_STEPS&date=${day}`); + await expect( + page.locator('[data-slot="provenance-source-row"][data-picked="true"]'), + ).toHaveAttribute("data-source", "APPLE_HEALTH"); + await expect( + page.locator('[data-slot="provenance-verdict"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="provenance-compare-row"]'), + ).toHaveCount(1); + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test("is reachable from Settings > Source priority", async ({ page }) => { + await page.goto("/settings/sources"); + await page.locator('[data-slot="sources-provenance-cross-link"]').click(); + await expect(page).toHaveURL(/\/insights\/sources/); + }); +}); diff --git a/messages/de.json b/messages/de.json index 077caaefb..84b30f1e9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13539,5 +13539,47 @@ "body": "Deine Schritte als Woche oder Monat, verglichen mit deinem Üblichen.", "cta": "Kalender öffnen" } + }, + "sourceProvenance": { + "title": "Woher die Werte kommen", + "description": "Welche Quelle einen Wert an jedem Tag geliefert hat und welche deine Quellen-Priorität verwendet.", + "metric": "Messwert", + "day": "Tag", + "dayTitle": "Quellen an diesem Tag", + "loadError": "Die Quellen konnten nicht geladen werden.", + "dayEmpty": "An diesem Tag gibt es keine Werte dieser Art.", + "reason": { + "only_source": "{source} ist an diesem Tag die einzige Quelle.", + "highest_on_ladder": "{source} wird verwendet: Unter den Quellen dieses Tages steht sie in deiner Quellen-Priorität am höchsten.", + "name_tiebreak": "{source} wird verwendet: Keine der Quellen dieses Tages steht in deiner Quellen-Priorität, deshalb entscheidet der Name.", + "not_ranked": "Für diesen Wert gibt es keine Quellen-Priorität: Die Werte aller Quellen zählen." + }, + "pass": { + "lower_on_ladder": "Steht in deiner Quellen-Priorität weiter unten.", + "not_on_ladder": "Steht für diesen Wert nicht in deiner Quellen-Priorität.", + "name_tiebreak": "Gleicher Rang wie die verwendete Quelle; der Name hat entschieden." + }, + "used": "Verwendet", + "readings": "{count} Messungen, Geräte: {devices}", + "ladder": "Deine Reihenfolge für diesen Wert: {ladder}", + "utcNote": "Die Quelle wird pro Tag von Mitternacht bis Mitternacht UTC gewählt, genauso wie bei den Diagrammen.", + "openSettings": "Reihenfolge in der Quellen-Priorität ändern", + "compareTitle": "Zwei Quellen vergleichen", + "range": "Zeitraum", + "rangeDays": "{days} Tage", + "compareNeedsTwo": "In diesem Zeitraum hat nur eine Quelle Werte dieser Art.", + "side": { + "a": "Erste Quelle", + "b": "Zweite Quelle" + }, + "daysBoth": "Tage mit beiden", + "meanOf": "Durchschnitt von {source}", + "meanAbsDiff": "Durchschnittliche Abweichung", + "pickedDays": "Verwendete Tage (erste / zweite)", + "pickedSplit": "{a} / {b}", + "compareNote": "Die Durchschnitte zählen nur Tage, an denen beide Quellen Werte haben, damit Gleiches mit Gleichem verglichen wird. Fett ist der Wert, den die Diagramme verwenden.", + "settingsHint": "Was diese Reihenfolge Tag für Tag entscheidet:", + "settingsHintLink": "Woher die Werte kommen", + "metricLink": "Woher diese Werte kommen" } } diff --git a/messages/en.json b/messages/en.json index 41c212bfc..fa195cc63 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13539,5 +13539,47 @@ "body": "Your steps as a week or a month, against your own usual.", "cta": "Open calendar" } + }, + "sourceProvenance": { + "title": "Where values come from", + "description": "Which source delivered a metric each day, and which one your source priority uses.", + "metric": "Metric", + "day": "Day", + "dayTitle": "Sources on this day", + "loadError": "The sources could not be loaded.", + "dayEmpty": "No readings of this metric on this day.", + "reason": { + "only_source": "{source} is the only source on this day.", + "highest_on_ladder": "{source} is used: it stands highest in your source priority among this day's sources.", + "name_tiebreak": "{source} is used: none of this day's sources is in your source priority, so the name decided.", + "not_ranked": "This metric has no source priority: every source's readings count." + }, + "pass": { + "lower_on_ladder": "Lower in your source priority.", + "not_on_ladder": "Not in your source priority for this metric.", + "name_tiebreak": "Same rank as the source used; the name decided." + }, + "used": "Used", + "readings": "{count} readings, devices: {devices}", + "ladder": "Your order for this metric: {ladder}", + "utcNote": "The source is chosen per day from midnight to midnight UTC, the same way the charts choose it.", + "openSettings": "Change the order in Source priority", + "compareTitle": "Compare two sources", + "range": "Range", + "rangeDays": "{days} days", + "compareNeedsTwo": "Only one source has readings of this metric in this range.", + "side": { + "a": "First source", + "b": "Second source" + }, + "daysBoth": "Days with both", + "meanOf": "Average from {source}", + "meanAbsDiff": "Average difference", + "pickedDays": "Days used (first / second)", + "pickedSplit": "{a} / {b}", + "compareNote": "Averages cover only the days both sources have, so they compare like with like. The value in bold is the one the charts use.", + "settingsHint": "To see what this order decides day by day:", + "settingsHintLink": "Where values come from", + "metricLink": "Where these values come from" } } diff --git a/messages/es.json b/messages/es.json index 7a297a960..9d70fdebf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13539,5 +13539,47 @@ "body": "Tus pasos por semana o por mes, frente a lo que es habitual para ti.", "cta": "Abrir calendario" } + }, + "sourceProvenance": { + "title": "De dónde vienen los valores", + "description": "Qué fuente aportó un valor cada día y cuál usa tu prioridad de fuentes.", + "metric": "Medida", + "day": "Día", + "dayTitle": "Fuentes de este día", + "loadError": "No se pudieron cargar las fuentes.", + "dayEmpty": "No hay valores de esta medida en este día.", + "reason": { + "only_source": "{source} es la única fuente de este día.", + "highest_on_ladder": "Se usa {source}: es la más alta en tu prioridad de fuentes entre las fuentes de este día.", + "name_tiebreak": "Se usa {source}: ninguna fuente de este día está en tu prioridad, así que decidió el nombre.", + "not_ranked": "Esta medida no tiene prioridad de fuentes: cuentan los valores de todas las fuentes." + }, + "pass": { + "lower_on_ladder": "Más abajo en tu prioridad de fuentes.", + "not_on_ladder": "No está en tu prioridad de fuentes para esta medida.", + "name_tiebreak": "Mismo rango que la fuente usada; decidió el nombre." + }, + "used": "Usada", + "readings": "{count} mediciones, dispositivos: {devices}", + "ladder": "Tu orden para esta medida: {ladder}", + "utcNote": "La fuente se elige por día, de medianoche a medianoche UTC, igual que en los gráficos.", + "openSettings": "Cambiar el orden en Prioridad de fuentes", + "compareTitle": "Comparar dos fuentes", + "range": "Periodo", + "rangeDays": "{days} días", + "compareNeedsTwo": "Solo una fuente tiene valores de esta medida en este periodo.", + "side": { + "a": "Primera fuente", + "b": "Segunda fuente" + }, + "daysBoth": "Días con ambas", + "meanOf": "Media de {source}", + "meanAbsDiff": "Diferencia media", + "pickedDays": "Días usados (primera / segunda)", + "pickedSplit": "{a} / {b}", + "compareNote": "Las medias solo cuentan los días en que ambas fuentes tienen valores, para comparar lo comparable. En negrita, el valor que usan los gráficos.", + "settingsHint": "Para ver lo que decide este orden día a día:", + "settingsHintLink": "De dónde vienen los valores", + "metricLink": "De dónde vienen estos valores" } } diff --git a/messages/fr.json b/messages/fr.json index e9c8f45c1..bf6037632 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13539,5 +13539,47 @@ "body": "Tes pas par semaine ou par mois, comparés à ton habitude.", "cta": "Ouvrir le calendrier" } + }, + "sourceProvenance": { + "title": "D'où viennent les valeurs", + "description": "Quelle source a fourni une valeur chaque jour, et laquelle ta priorité des sources utilise.", + "metric": "Mesure", + "day": "Jour", + "dayTitle": "Sources de ce jour", + "loadError": "Les sources n'ont pas pu être chargées.", + "dayEmpty": "Aucune valeur de cette mesure ce jour-là.", + "reason": { + "only_source": "{source} est la seule source ce jour-là.", + "highest_on_ladder": "{source} est utilisée : c'est la plus haute dans ta priorité des sources parmi celles de ce jour.", + "name_tiebreak": "{source} est utilisée : aucune source de ce jour n'est dans ta priorité, le nom a donc décidé.", + "not_ranked": "Cette mesure n'a pas de priorité des sources : les valeurs de toutes les sources comptent." + }, + "pass": { + "lower_on_ladder": "Plus bas dans ta priorité des sources.", + "not_on_ladder": "Absente de ta priorité des sources pour cette mesure.", + "name_tiebreak": "Même rang que la source utilisée ; le nom a décidé." + }, + "used": "Utilisée", + "readings": "{count} mesures, appareils : {devices}", + "ladder": "Ton ordre pour cette mesure : {ladder}", + "utcNote": "La source est choisie par jour, de minuit à minuit UTC, comme pour les graphiques.", + "openSettings": "Modifier l'ordre dans Priorité des sources", + "compareTitle": "Comparer deux sources", + "range": "Période", + "rangeDays": "{days} jours", + "compareNeedsTwo": "Une seule source a des valeurs de cette mesure sur cette période.", + "side": { + "a": "Première source", + "b": "Deuxième source" + }, + "daysBoth": "Jours avec les deux", + "meanOf": "Moyenne de {source}", + "meanAbsDiff": "Écart moyen", + "pickedDays": "Jours utilisés (première / deuxième)", + "pickedSplit": "{a} / {b}", + "compareNote": "Les moyennes ne comptent que les jours où les deux sources ont des valeurs, pour comparer ce qui est comparable. En gras, la valeur utilisée par les graphiques.", + "settingsHint": "Pour voir ce que cet ordre décide jour après jour :", + "settingsHintLink": "D'où viennent les valeurs", + "metricLink": "D'où viennent ces valeurs" } } diff --git a/messages/it.json b/messages/it.json index c522cb6d7..79fea56c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13539,5 +13539,47 @@ "body": "I tuoi passi per settimana o per mese, rispetto al tuo solito.", "cta": "Apri il calendario" } + }, + "sourceProvenance": { + "title": "Da dove vengono i valori", + "description": "Quale fonte ha fornito un valore ogni giorno e quale usa la tua priorità delle fonti.", + "metric": "Misura", + "day": "Giorno", + "dayTitle": "Fonti di questo giorno", + "loadError": "Impossibile caricare le fonti.", + "dayEmpty": "Nessun valore di questa misura in questo giorno.", + "reason": { + "only_source": "{source} è l'unica fonte di questo giorno.", + "highest_on_ladder": "Viene usata {source}: tra le fonti di questo giorno è la più alta nella tua priorità delle fonti.", + "name_tiebreak": "Viene usata {source}: nessuna fonte di questo giorno è nella tua priorità, quindi ha deciso il nome.", + "not_ranked": "Questa misura non ha una priorità delle fonti: contano i valori di tutte le fonti." + }, + "pass": { + "lower_on_ladder": "Più in basso nella tua priorità delle fonti.", + "not_on_ladder": "Non è nella tua priorità delle fonti per questa misura.", + "name_tiebreak": "Stesso rango della fonte usata; ha deciso il nome." + }, + "used": "Usata", + "readings": "{count} misurazioni, dispositivi: {devices}", + "ladder": "Il tuo ordine per questa misura: {ladder}", + "utcNote": "La fonte viene scelta per giorno, da mezzanotte a mezzanotte UTC, come nei grafici.", + "openSettings": "Cambia l'ordine in Priorità delle fonti", + "compareTitle": "Confronta due fonti", + "range": "Periodo", + "rangeDays": "{days} giorni", + "compareNeedsTwo": "In questo periodo solo una fonte ha valori di questa misura.", + "side": { + "a": "Prima fonte", + "b": "Seconda fonte" + }, + "daysBoth": "Giorni con entrambe", + "meanOf": "Media di {source}", + "meanAbsDiff": "Differenza media", + "pickedDays": "Giorni usati (prima / seconda)", + "pickedSplit": "{a} / {b}", + "compareNote": "Le medie contano solo i giorni in cui entrambe le fonti hanno valori, per confrontare cose confrontabili. In grassetto il valore usato dai grafici.", + "settingsHint": "Per vedere cosa decide questo ordine giorno per giorno:", + "settingsHintLink": "Da dove vengono i valori", + "metricLink": "Da dove vengono questi valori" } } diff --git a/messages/ko.json b/messages/ko.json index 91597e3e2..72a30343d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13539,5 +13539,47 @@ "body": "주 또는 월 단위의 걸음 수를 내 평소 값과 비교합니다.", "cta": "캘린더 열기" } + }, + "sourceProvenance": { + "title": "값의 출처", + "description": "날마다 어떤 소스가 값을 보냈고, 소스 우선순위가 어느 것을 쓰는지 보여 줍니다.", + "metric": "측정 항목", + "day": "날짜", + "dayTitle": "이 날의 소스", + "loadError": "소스를 불러오지 못했습니다.", + "dayEmpty": "이 날에는 이 항목의 값이 없습니다.", + "reason": { + "only_source": "이 날의 소스는 {source}뿐입니다.", + "highest_on_ladder": "{source}을(를) 사용합니다. 이 날의 소스 중 소스 우선순위가 가장 높습니다.", + "name_tiebreak": "{source}을(를) 사용합니다. 이 날의 소스가 모두 우선순위에 없어 이름으로 정했습니다.", + "not_ranked": "이 항목에는 소스 우선순위가 없어 모든 소스의 값이 반영됩니다." + }, + "pass": { + "lower_on_ladder": "소스 우선순위에서 더 아래에 있습니다.", + "not_on_ladder": "이 항목의 소스 우선순위에 없습니다.", + "name_tiebreak": "사용한 소스와 순위가 같아 이름으로 정했습니다." + }, + "used": "사용됨", + "readings": "측정 {count}회, 기기: {devices}", + "ladder": "이 항목의 순서: {ladder}", + "utcNote": "소스는 차트와 같은 방식으로 UTC 자정부터 자정까지 하루 단위로 선택됩니다.", + "openSettings": "소스 우선순위에서 순서 변경", + "compareTitle": "두 소스 비교", + "range": "기간", + "rangeDays": "{days}일", + "compareNeedsTwo": "이 기간에는 한 소스에만 이 항목의 값이 있습니다.", + "side": { + "a": "첫 번째 소스", + "b": "두 번째 소스" + }, + "daysBoth": "둘 다 있는 날", + "meanOf": "{source} 평균", + "meanAbsDiff": "평균 차이", + "pickedDays": "사용된 날 (첫 번째 / 두 번째)", + "pickedSplit": "{a} / {b}", + "compareNote": "평균은 두 소스 모두 값이 있는 날만 계산하여 같은 조건으로 비교합니다. 굵은 값이 차트에 쓰이는 값입니다.", + "settingsHint": "이 순서가 날마다 무엇을 정하는지 보려면:", + "settingsHintLink": "값의 출처", + "metricLink": "이 값의 출처" } } diff --git a/messages/nl.json b/messages/nl.json index 85559a6eb..fdbb3164d 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13539,5 +13539,47 @@ "body": "Je stappen per week of maand, vergeleken met wat gewoon is voor jou.", "cta": "Kalender openen" } + }, + "sourceProvenance": { + "title": "Waar de waarden vandaan komen", + "description": "Welke bron elke dag een waarde leverde en welke je bronprioriteit gebruikt.", + "metric": "Meetwaarde", + "day": "Dag", + "dayTitle": "Bronnen op deze dag", + "loadError": "De bronnen konden niet worden geladen.", + "dayEmpty": "Geen waarden van deze meting op deze dag.", + "reason": { + "only_source": "{source} is op deze dag de enige bron.", + "highest_on_ladder": "{source} wordt gebruikt: van de bronnen van deze dag staat die het hoogst in je bronprioriteit.", + "name_tiebreak": "{source} wordt gebruikt: geen enkele bron van deze dag staat in je bronprioriteit, dus de naam besliste.", + "not_ranked": "Deze meting heeft geen bronprioriteit: de waarden van alle bronnen tellen mee." + }, + "pass": { + "lower_on_ladder": "Lager in je bronprioriteit.", + "not_on_ladder": "Staat voor deze meting niet in je bronprioriteit.", + "name_tiebreak": "Zelfde rang als de gebruikte bron; de naam besliste." + }, + "used": "Gebruikt", + "readings": "{count} metingen, apparaten: {devices}", + "ladder": "Je volgorde voor deze meting: {ladder}", + "utcNote": "De bron wordt per dag gekozen, van middernacht tot middernacht UTC, net als bij de grafieken.", + "openSettings": "Volgorde wijzigen in Bronprioriteit", + "compareTitle": "Twee bronnen vergelijken", + "range": "Periode", + "rangeDays": "{days} dagen", + "compareNeedsTwo": "In deze periode heeft maar één bron waarden van deze meting.", + "side": { + "a": "Eerste bron", + "b": "Tweede bron" + }, + "daysBoth": "Dagen met beide", + "meanOf": "Gemiddelde van {source}", + "meanAbsDiff": "Gemiddeld verschil", + "pickedDays": "Gebruikte dagen (eerste / tweede)", + "pickedSplit": "{a} / {b}", + "compareNote": "De gemiddelden tellen alleen dagen waarop beide bronnen waarden hebben, zodat je gelijke dagen vergelijkt. Vetgedrukt is de waarde die de grafieken gebruiken.", + "settingsHint": "Om te zien wat deze volgorde per dag beslist:", + "settingsHintLink": "Waar de waarden vandaan komen", + "metricLink": "Waar deze waarden vandaan komen" } } diff --git a/messages/pl.json b/messages/pl.json index 0e77760d4..f39398420 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13539,5 +13539,47 @@ "body": "Twoje kroki w tygodniu lub miesiącu na tle twojej normy.", "cta": "Otwórz kalendarz" } + }, + "sourceProvenance": { + "title": "Skąd pochodzą wartości", + "description": "Które źródło dostarczyło wartość każdego dnia i którego używa twój priorytet źródeł.", + "metric": "Pomiar", + "day": "Dzień", + "dayTitle": "Źródła tego dnia", + "loadError": "Nie udało się wczytać źródeł.", + "dayEmpty": "Brak wartości tego pomiaru w tym dniu.", + "reason": { + "only_source": "{source} to jedyne źródło tego dnia.", + "highest_on_ladder": "Używane jest {source}: spośród źródeł tego dnia stoi najwyżej w twoim priorytecie źródeł.", + "name_tiebreak": "Używane jest {source}: żadnego ze źródeł tego dnia nie ma w twoim priorytecie, więc zdecydowała nazwa.", + "not_ranked": "Ten pomiar nie ma priorytetu źródeł: liczą się wartości ze wszystkich źródeł." + }, + "pass": { + "lower_on_ladder": "Niżej w twoim priorytecie źródeł.", + "not_on_ladder": "Nie ma go w twoim priorytecie źródeł dla tego pomiaru.", + "name_tiebreak": "Ta sama pozycja co użyte źródło; zdecydowała nazwa." + }, + "used": "Używane", + "readings": "Pomiary: {count}, urządzenia: {devices}", + "ladder": "Twoja kolejność dla tego pomiaru: {ladder}", + "utcNote": "Źródło jest wybierane na dzień, od północy do północy UTC, tak samo jak na wykresach.", + "openSettings": "Zmień kolejność w priorytecie źródeł", + "compareTitle": "Porównaj dwa źródła", + "range": "Okres", + "rangeDays": "{days} dni", + "compareNeedsTwo": "W tym okresie tylko jedno źródło ma wartości tego pomiaru.", + "side": { + "a": "Pierwsze źródło", + "b": "Drugie źródło" + }, + "daysBoth": "Dni z oboma", + "meanOf": "Średnia z {source}", + "meanAbsDiff": "Średnia różnica", + "pickedDays": "Użyte dni (pierwsze / drugie)", + "pickedSplit": "{a} / {b}", + "compareNote": "Średnie liczą tylko dni, w których oba źródła mają wartości, aby porównywać podobne dni. Pogrubiona jest wartość używana na wykresach.", + "settingsHint": "Aby zobaczyć, co ta kolejność rozstrzyga dzień po dniu:", + "settingsHintLink": "Skąd pochodzą wartości", + "metricLink": "Skąd pochodzą te wartości" } } diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index cdd3e4159..79a51c137 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1077,6 +1077,14 @@ const DELEGABLE_ROUTES: Record = { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", }, + "app/api/measurements/provenance/route.ts": { + domain: "measurements", + why: "Which source delivered a metric on one UTC day and which one the record's source priority picks, with the reason. Read-only aggregates of the resolved user's readings; no AI, no note text.", + }, + "app/api/measurements/provenance/compare/route.ts": { + domain: "measurements", + why: "Two sources of one metric side by side over at most a year of UTC days, with the source the ladder picked each day. Read-only aggregates of the resolved user's readings; no AI, no note text.", + }, "app/api/activity/calendar/route.ts": { domain: "measurements", why: "The activity calendar: day totals of steps, active minutes and load, workouts by sport, and the record owner's goals, computed without AI. Read-only, scoped to the resolved user, module-gated on the record's own `workouts` switch like the workout list beside it.", @@ -2176,7 +2184,7 @@ const ACTOR_ROUTES: Record = { * v1.44 -- a guardian connects and disconnects a managed profile's OAuth * providers: two guardian-only entries. 266 -> 268. */ -const FROZEN_ENTRY_COUNT = 269; +const FROZEN_ENTRY_COUNT = 271; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/measurements/provenance/compare/route.ts b/src/app/api/measurements/provenance/compare/route.ts new file mode 100644 index 000000000..c889e7504 --- /dev/null +++ b/src/app/api/measurements/provenance/compare/route.ts @@ -0,0 +1,42 @@ +/** + * `GET /api/measurements/provenance/compare` — two sources of one metric side + * by side over up to a year of UTC days, with the source the ladder picked + * each day (#1204). Read-only; one aggregate query, at most one row per day + * and source. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { readSourceComparison } from "@/lib/provenance/source-provenance"; +import { sourceComparisonQuerySchema } from "@/lib/validations/source-provenance"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "measurements"); + annotate({ action: { name: "measurement.provenance.compare" } }); + const params = new URL(request.url).searchParams; + const parsed = sourceComparisonQuerySchema.safeParse({ + type: params.get("type") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + a: params.get("a") ?? undefined, + b: params.get("b") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const result = await readSourceComparison({ + userId: user.id, + ...parsed.data, + }); + annotate({ + meta: { + provenance_type: result.type, + provenance_days: result.days.length, + provenance_sources: result.sources.length, + }, + }); + return apiSuccess(result); +}); diff --git a/src/app/api/measurements/provenance/route.ts b/src/app/api/measurements/provenance/route.ts new file mode 100644 index 000000000..23eac328d --- /dev/null +++ b/src/app/api/measurements/provenance/route.ts @@ -0,0 +1,40 @@ +/** + * `GET /api/measurements/provenance` — which sources delivered a metric on a + * day, which one the source-priority ladder picked, and why (#1204, #1108, + * #576). Read-only; the explanation comes from the same rank SQL the charts + * and summaries pick with (`src/lib/provenance/source-provenance.ts`). + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { readDayProvenance } from "@/lib/provenance/source-provenance"; +import { dayProvenanceQuerySchema } from "@/lib/validations/source-provenance"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "measurements"); + annotate({ action: { name: "measurement.provenance.day" } }); + const params = new URL(request.url).searchParams; + const parsed = dayProvenanceQuerySchema.safeParse({ + type: params.get("type") ?? undefined, + date: params.get("date") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const result = await readDayProvenance({ + userId: user.id, + type: parsed.data.type, + date: parsed.data.date, + }); + annotate({ + meta: { + provenance_type: result.type, + provenance_sources: result.sources.length, + provenance_pick_reason: result.pickReason ?? "none", + }, + }); + return apiSuccess(result); +}); diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 43186b0a4..df2d6d797 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { ArrowDownRight, ArrowUpRight, HeartPulse } from "lucide-react"; @@ -226,6 +227,8 @@ export default function InsightsBlutdruckPage() { slug="blood-pressure" icon={} /> + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index f99f89681..9f41dbe02 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { Gauge, Heart } from "lucide-react"; @@ -211,6 +212,8 @@ export default function InsightsPulsPage() { cta={t("insights.vo2Max.cardioLinkCta")} /> ) : null} + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index d7a1d99b2..8b7659f36 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { Moon } from "lucide-react"; @@ -153,6 +154,8 @@ export default function InsightsSchlafPage() { + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/app/insights/sources/page.tsx b/src/app/insights/sources/page.tsx new file mode 100644 index 000000000..d59fc7286 --- /dev/null +++ b/src/app/insights/sources/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; + +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { SourceProvenanceView } from "@/components/provenance/source-provenance-view"; +import { useTranslations } from "@/lib/i18n/context"; +import { isProvenanceType } from "@/lib/provenance/ranked-types"; + +/** + * v1.44 — `/insights/sources` (#1204, #1108, #576). + * + * Where a metric's values come from: per day, every source that delivered + * one and the one the source priority picked, with the reason; and two + * sources side by side over a range. Reached from a metric's page and from + * Settings > Source priority; `?type=` and `?date=` open it on a metric and a + * day. Read-only: the order that decides the pick is edited in Settings. + */ +function SourcesPageBody() { + const params = useSearchParams(); + const type = params.get("type"); + const date = params.get("date"); + return ( + + ); +} + +export default function InsightsSourcesPage() { + const { t } = useTranslations(); + return ( + + + + + + ); +} diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c91f9b1b9..c5ac87fff 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { Scale } from "lucide-react"; @@ -222,6 +223,8 @@ export default function InsightsGewichtPage() { slug="weight" icon={} /> + {/* v1.44 (#1204) — where these values come from. */} + ); } diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 22129a95e..a59a2baa3 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import type { ComponentProps, ComponentType, ReactNode } from "react"; @@ -523,6 +524,9 @@ export function HealthKitMetricPage({ {targetSummarySlug ? ( ) : null} + {/* v1.44 (#1204) — where the values come from, for a metric the + source priority decides for. Nothing for any other metric. */} + {/* v1.17.0 — page-specific extra block (blood glucose: the clinical panel). Mounted on the data-bearing branch only. */} {afterChart} diff --git a/src/components/provenance/__tests__/source-provenance-view.test.tsx b/src/components/provenance/__tests__/source-provenance-view.test.tsx new file mode 100644 index 000000000..a951201f5 --- /dev/null +++ b/src/components/provenance/__tests__/source-provenance-view.test.tsx @@ -0,0 +1,176 @@ +/** + * `` draws the server's answer (SSR markup): the day's + * sources with the used one marked and the reason in words, the person's + * ladder, and the side-by-side table with the used value in bold. The link + * from a metric page appears only for a metric the ladder decides for. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const state: { day: unknown; compare: unknown } = { + day: undefined, + compare: undefined, +}; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); +vi.mock("@/hooks/use-source-provenance", () => ({ + useDayProvenance: () => ({ + data: state.day, + isError: false, + error: null, + refetch: vi.fn(), + }), + useSourceComparison: () => ({ + data: state.compare, + isError: false, + error: null, + refetch: vi.fn(), + }), +})); + +import { SourceProvenanceView } from "../source-provenance-view"; +import { SourceProvenanceLink } from "../source-provenance-link"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +const DAY = { + type: "SLEEP_DURATION", + date: "2026-10-01", + aggregation: "sum", + ranked: true, + metricKey: "sleep", + ladder: ["WHOOP", "OURA"], + picked: "WHOOP", + pickReason: "highest_on_ladder", + sources: [ + { + source: "WHOOP", + rank: 0, + readings: 1, + value: 420, + min: 420, + max: 420, + firstAt: "", + lastAt: "", + unit: "min", + devices: ["band"], + pass: null, + }, + { + source: "OURA", + rank: 1, + readings: 3, + value: 391, + min: 30, + max: 301, + firstAt: "", + lastAt: "", + unit: "min", + devices: ["ring"], + pass: "lower_on_ladder", + }, + ], +}; + +const COMPARE = { + type: "SLEEP_DURATION", + from: "2026-09-02", + to: "2026-10-01", + aggregation: "sum", + ranked: true, + ladder: ["WHOOP", "OURA"], + unit: "min", + sources: [ + { source: "OURA", days: 30 }, + { source: "WHOOP", days: 20 }, + ], + a: "OURA", + b: "WHOOP", + days: [{ date: "2026-10-01", a: 391, b: 420, picked: "WHOOP" }], + summary: { + daysA: 30, + daysB: 20, + daysBoth: 20, + meanA: 395, + meanB: 440, + meanDifference: 45, + meanAbsoluteDifference: 45, + pickedA: 10, + pickedB: 20, + }, +}; + +describe("SourceProvenanceView", () => { + it("names the used source, why, and why the other was not used", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + expect(html).toContain('data-source="WHOOP" data-picked="true"'); + expect(html).toContain('data-source="OURA" data-picked="false"'); + expect(html).toContain("stands highest in your source priority"); + expect(html).toContain("Lower in your source priority."); + expect(html).toContain("Your order for this metric: WHOOP, Oura"); + expect(html).toContain('href="/settings/sources"'); + }); + + it("lines up two sources and marks the used value", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-compare-row"'); + expect(html).toMatch(/font-semibold tabular-nums">420/); + expect(html).toContain("10 / 20"); + }); + + it("says when there is nothing to compare", () => { + state.day = { ...DAY, sources: [], picked: null, pickReason: null }; + state.compare = { + ...COMPARE, + sources: [{ source: "OURA", days: 3 }], + b: null, + }; + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-day-empty"'); + expect(html).toContain('data-slot="provenance-compare-empty"'); + }); +}); + +describe("SourceProvenanceLink", () => { + it("links ranked metrics and stays away from the rest", () => { + expect(render()).toContain( + "/insights/sources?type=ACTIVITY_STEPS", + ); + expect(render()).toBe(""); + }); +}); diff --git a/src/components/provenance/source-provenance-link.tsx b/src/components/provenance/source-provenance-link.tsx new file mode 100644 index 000000000..6017748dd --- /dev/null +++ b/src/components/provenance/source-provenance-link.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Link from "next/link"; + +import { InlineAction } from "@/components/ui/inline-action"; +import { useTranslations } from "@/lib/i18n/context"; +import { isProvenanceType } from "@/lib/provenance/ranked-types"; + +/** + * "Where these values come from": a small link from a metric's page to the + * provenance view, offered for the metrics the source priority decides for. + * Renders nothing for any other type. + */ +export function SourceProvenanceLink({ type }: { type: string }) { + const { t } = useTranslations(); + if (!isProvenanceType(type)) return null; + return ( + + + {t("sourceProvenance.metricLink")} + + + ); +} diff --git a/src/components/provenance/source-provenance-view.tsx b/src/components/provenance/source-provenance-view.tsx new file mode 100644 index 000000000..e5a1dffb0 --- /dev/null +++ b/src/components/provenance/source-provenance-view.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { GitCompareArrows, Layers } from "lucide-react"; + +import { TileHeader } from "@/components/insights/tile-header"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { DateField } from "@/components/ui/date-field"; +import { EmptyState } from "@/components/ui/empty-state"; +import { InlineAction } from "@/components/ui/inline-action"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Segmented } from "@/components/ui/segmented"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TagChip } from "@/components/ui/tag-chip"; +import { + useDayProvenance, + useSourceComparison, +} from "@/hooks/use-source-provenance"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; +import { + PROVENANCE_TYPES, + type ProvenanceType, +} from "@/lib/provenance/ranked-types"; +import { shiftDateKey } from "@/lib/tz/format"; + +type RangeDays = "30" | "90" | "365"; + +/** + * Today as a UTC day key: the source ladder picks per UTC day, so the page + * names its days the way the pick cuts them. + */ +function utcToday(): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the ladder pick is per UTC day, and this page explains that pick + return new Date().toISOString().slice(0, 10); +} + +/** + * The provenance view (#1204, #1108, #576): for one metric, which sources + * delivered values on a day, which one the source priority picked and why, + * and two sources side by side over a range. Read-only; the settings that + * decide the pick stay in Settings > Source priority. + */ +export function SourceProvenanceView({ + initialType, + initialDate, +}: { + initialType: ProvenanceType; + initialDate: string | null; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const [type, setType] = useState(initialType); + const [date, setDate] = useState(initialDate ?? utcToday()); + const [range, setRange] = useState("30"); + const [a, setA] = useState(undefined); + const [b, setB] = useState(undefined); + const to = utcToday(); + const from = shiftDateKey(to, -(Number(range) - 1)); + + const day = useDayProvenance(type, date); + const compare = useSourceComparison({ type, from, to, a, b }); + + const sourceLabel = (source: string) => { + const key = + MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[ + source as keyof typeof MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS + ]; + return key ? t(key) : source; + }; + const typeLabel = (value: ProvenanceType) => + t(MEASUREMENT_TYPE_LABEL_KEYS[value]); + const sortedTypes = [...PROVENANCE_TYPES].sort((x, y) => + typeLabel(x).localeCompare(typeLabel(y)), + ); + + const formatValue = (value: number | null, aggregation: "sum" | "mean") => + value === null + ? "–" + : aggregation === "sum" + ? fmt.integer(Math.round(value)) + : fmt.number(value, 1); + + return ( +
+
+
+ + { + setType(e.target.value as ProvenanceType); + setA(undefined); + setB(undefined); + }} + > + {sortedTypes.map((value) => ( + + ))} + +
+
+ + value && setDate(value)} + /> +
+
+ + + + + + + {day.isError ? ( + day.refetch()} + /> + ) : !day.data ? ( + + ) : day.data.sources.length === 0 ? ( + + ) : ( + <> +

+ {day.data.pickReason === "not_ranked" + ? t("sourceProvenance.reason.not_ranked") + : t( + `sourceProvenance.reason.${day.data.pickReason ?? "only_source"}`, + { + source: sourceLabel(day.data.picked ?? ""), + }, + )} +

+
    + {day.data.sources.map((row) => ( +
  • +
    +

    + {sourceLabel(row.source)} + {row.source === day.data?.picked ? ( + {t("sourceProvenance.used")} + ) : null} +

    +

    + {t("sourceProvenance.readings", { + count: fmt.integer(row.readings), + devices: row.devices.join(", "), + })} +

    + {row.pass ? ( +

    + {t(`sourceProvenance.pass.${row.pass}`)} +

    + ) : null} +
    +

    + {formatValue(row.value, day.data?.aggregation ?? "mean")}{" "} + + {row.unit} + +

    +
  • + ))} +
+ {day.data.ranked ? ( +

+ {t("sourceProvenance.ladder", { + ladder: day.data.ladder.map(sourceLabel).join(", "), + })} +

+ ) : null} +

+ {t("sourceProvenance.utcNote")} +

+ + )} + + + {t("sourceProvenance.openSettings")} + + +
+
+ + + + + } + /> + + + {compare.isError ? ( + compare.refetch()} + /> + ) : !compare.data ? ( + + ) : compare.data.sources.length < 2 ? ( + + ) : ( + <> +
+ {(["a", "b"] as const).map((side) => ( +
+ + + side === "a" + ? setA(e.target.value) + : setB(e.target.value) + } + > + {compare.data?.sources.map((s) => ( + + ))} + +
+ ))} +
+
+
+ {t("sourceProvenance.daysBoth")} +
+
+ {fmt.integer(compare.data.summary.daysBoth)} +
+
+ {t("sourceProvenance.meanOf", { + source: sourceLabel(compare.data.a ?? ""), + })} +
+
+ {formatValue( + compare.data.summary.meanA, + compare.data.aggregation, + )} +
+
+ {t("sourceProvenance.meanOf", { + source: sourceLabel(compare.data.b ?? ""), + })} +
+
+ {formatValue( + compare.data.summary.meanB, + compare.data.aggregation, + )} +
+
+ {t("sourceProvenance.meanAbsDiff")} +
+
+ {formatValue( + compare.data.summary.meanAbsoluteDifference, + "mean", + )}{" "} + + {compare.data.unit} + +
+
+ {t("sourceProvenance.pickedDays")} +
+
+ {t("sourceProvenance.pickedSplit", { + a: fmt.integer(compare.data.summary.pickedA), + b: fmt.integer(compare.data.summary.pickedB), + })} +
+
+
+ + + + + + + + + + {[...compare.data.days].reverse().map((d) => ( + + + {(["a", "b"] as const).map((side) => { + const source = compare.data?.[side]; + const used = source !== null && d.picked === source; + return ( + + ); + })} + + ))} + +
+ {t("sourceProvenance.day")} + + {sourceLabel(compare.data.a ?? "")} + + {sourceLabel(compare.data.b ?? "")} +
+ setDate(d.date)} + > + {fmt.dateShortSmartCalendar(d.date)} + + + {formatValue( + d[side], + compare.data?.aggregation ?? "mean", + )} + {used ? ( + + {" "} + {t("sourceProvenance.used")} + + ) : null} +
+
+

+ {t("sourceProvenance.compareNote")} +

+ + )} +
+
+
+ ); +} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 4846c99eb..b6d7f51ae 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -679,6 +679,19 @@ export function SourcesSection() { {t("settings.sections.sources.integrationsHintLink")}

+ {/* v1.44 (#1204) — the read-only view of what this ladder decides: + which source delivered what on a day, and two sources side by + side. Above the action row, like the hint before it. */} +

+ {t("sourceProvenance.settingsHint")}{" "} + + {t("sourceProvenance.settingsHintLink")} + +

{/* One action row, last in the card (design standards §12). The reset used to ride the header's status slot, which carries diff --git a/src/hooks/use-source-provenance.ts b/src/hooks/use-source-provenance.ts new file mode 100644 index 000000000..27a609e02 --- /dev/null +++ b/src/hooks/use-source-provenance.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DayProvenanceResponse, + SourceComparisonResponse, +} from "@/lib/validations/source-provenance"; + +/** `GET /api/measurements/provenance` for one metric and UTC day. */ +export function useDayProvenance(type: string, date: string) { + return useQuery({ + queryKey: queryKeys.provenanceDay(type, date), + queryFn: () => + apiGet( + `/api/measurements/provenance?${new URLSearchParams({ type, date })}`, + ), + staleTime: 60_000, + }); +} + +/** `GET /api/measurements/provenance/compare` over a range of UTC days. */ +export function useSourceComparison(opts: { + type: string; + from: string; + to: string; + a?: string; + b?: string; +}) { + return useQuery({ + queryKey: queryKeys.provenanceCompare(opts), + queryFn: () => { + const params = new URLSearchParams({ + type: opts.type, + from: opts.from, + to: opts.to, + }); + if (opts.a) params.set("a", opts.a); + if (opts.b) params.set("b", opts.b); + return apiGet( + `/api/measurements/provenance/compare?${params}`, + ); + }, + staleTime: 60_000, + }); +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 6cfa8666c..e72f9655a 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -28,6 +28,7 @@ import { recordScopeParameter, } from "./account-sharing"; import { activityPaths } from "./activity"; +import { provenancePaths } from "./provenance"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { aiUsagePaths } from "./ai-usage"; import { dawarichPaths } from "./dawarich"; @@ -107,6 +108,7 @@ export const openApiPaths: NonNullable = { ...measurementPaths, ...workoutPaths, ...activityPaths, + ...provenancePaths, ...devicePaths, ...medicationPaths, ...profilePaths, diff --git a/src/lib/openapi/routes/provenance.ts b/src/lib/openapi/routes/provenance.ts new file mode 100644 index 000000000..60f8c64ff --- /dev/null +++ b/src/lib/openapi/routes/provenance.ts @@ -0,0 +1,67 @@ +/** + * OpenAPI route table — source provenance (#1204, #1108, #576). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas + * are the runtime ones from `@/lib/validations/source-provenance`. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + dayProvenanceQuerySchema, + dayProvenanceResponseSchema, + sourceComparisonQuerySchema, + sourceComparisonResponseSchema, +} from "@/lib/validations/source-provenance"; + +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; + +export const provenancePaths: NonNullable = { + "/api/measurements/provenance": { + get: { + tags: ["Measurements"], + summary: "Which source a metric's day value comes from", + description: + "For one metric and one UTC day: every source with readings, its day value (the total for a cumulative metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and no pick: every source counts there. Read-only.", + requestParams: { query: dayProvenanceQuerySchema }, + responses: { + "200": { + description: "The day's sources and the pick.", + content: { + "application/json": { + schema: dataEnvelope( + dayProvenanceResponseSchema, + "DayProvenanceEnvelope", + ), + }, + }, + }, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, + "/api/measurements/provenance/compare": { + get: { + tags: ["Measurements"], + summary: "Two sources of one metric side by side", + description: + "Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. Without `a` / `b` the two sources covering the most days are compared. Read-only.", + requestParams: { query: sourceComparisonQuerySchema }, + responses: { + "200": { + description: "The comparison.", + content: { + "application/json": { + schema: dataEnvelope( + sourceComparisonResponseSchema, + "SourceComparisonEnvelope", + ), + }, + }, + }, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/provenance/__tests__/ranked-types.test.ts b/src/lib/provenance/__tests__/ranked-types.test.ts new file mode 100644 index 000000000..5eca4497c --- /dev/null +++ b/src/lib/provenance/__tests__/ranked-types.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { RANKED_TYPES } from "@/lib/analytics/source-rank-sql"; + +import { PROVENANCE_TYPES, isProvenanceType } from "../ranked-types"; + +describe("the provenance metric list", () => { + it("is exactly the set of types the ladder ranks", () => { + expect([...PROVENANCE_TYPES].sort()).toEqual([...RANKED_TYPES].sort()); + }); + it("answers membership", () => { + expect(isProvenanceType("ACTIVITY_STEPS")).toBe(true); + expect(isProvenanceType("BLOOD_GLUCOSE")).toBe(false); + }); +}); diff --git a/src/lib/provenance/__tests__/source-provenance.test.ts b/src/lib/provenance/__tests__/source-provenance.test.ts new file mode 100644 index 000000000..17cc5f773 --- /dev/null +++ b/src/lib/provenance/__tests__/source-provenance.test.ts @@ -0,0 +1,228 @@ +/** + * The provenance explanation says what the canonical readers do. + * + * 1. The ladder the explanation reads (`ladderFor` + `ladderRank`) gives + * every (type, source) the rank the SQL CASE from `buildSourceRankCase` + * gives it, for the default ladders and for a reordered one. The CASE is + * evaluated from its own text here, so a change to either side fails. + * 2. The provenance query orders a day's sources by exactly the expression + * `canonicalMeasurementsCte` picks with: the same rank CASE, then the + * source name. + * 3. The pick explanation and the comparison are right on hand-made rows. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + queryRawUnsafe: vi.fn(), + priority: null as unknown, +})); +vi.mock("@/lib/db", () => ({ + prisma: { $queryRawUnsafe: mocks.queryRawUnsafe }, +})); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => mocks.priority), +})); + +import { + RANKED_TYPES, + buildSourceRankCase, + canonicalMeasurementsCte, +} from "@/lib/analytics/source-rank-sql"; +import { measurementSourceEnum } from "@/lib/validations/measurement"; +import type { MeasurementType } from "@/generated/prisma/client"; + +import { + OFF_LADDER_RANK, + buildComparison, + explainPick, + ladderFor, + ladderRank, + readDayProvenance, + type SourceRow, +} from "../source-provenance"; + +/** Evaluate the CASE text `buildSourceRankCase` emits for one (type, source). */ +function evaluateCase(sql: string, type: string, source: string): number { + const branch = new RegExp( + `WHEN "type" = '${type}' THEN \\(CASE "source" (.*?) ELSE 90 END\\)`, + ).exec(sql); + if (!branch) return 90; + const when = new RegExp(`WHEN '${source}' THEN (\\d+)`).exec(branch[1]); + return when ? Number(when[1]) : 90; +} + +const REORDERED = { + metricPriority: { + steps: ["OURA", "WHOOP", "APPLE_HEALTH"], + sleep: ["WHOOP", "OURA"], + hrvRmssd: ["OURA"], + }, +}; + +describe("the explanation ranks exactly like the SQL", () => { + it.each([ + ["default ladders", null], + ["a reordered ladder", REORDERED], + ])("%s", (_label, priority) => { + const sql = buildSourceRankCase(priority, '"type"', '"source"'); + let compared = 0; + for (const type of RANKED_TYPES) { + const { ladder } = ladderFor(priority, type); + for (const source of measurementSourceEnum.options) { + expect(ladderRank(ladder, source), `${type}/${source}`).toBe( + evaluateCase(sql, type, source), + ); + compared += 1; + } + } + // A matcher that found no branch would agree with everything. + expect(compared).toBeGreaterThan(100); + expect(sql).toContain(`WHEN "type" = 'ACTIVITY_STEPS'`); + }); + + it("a type without a ladder is not ranked", () => { + expect(ladderFor(null, "BLOOD_GLUCOSE" as MeasurementType).ranked).toBe( + false, + ); + }); + + it("a reordered ladder moves the rank", () => { + const { ladder } = ladderFor(REORDERED, "ACTIVITY_STEPS"); + expect(ladderRank(ladder, "OURA")).toBe(0); + expect(ladderRank(ladder, "WHOOP")).toBe(1); + expect(ladderRank(ladder, "NOT_A_SOURCE")).toBe(OFF_LADDER_RANK); + }); +}); + +describe("the provenance query orders like the canonical pick", () => { + beforeEach(() => { + mocks.queryRawUnsafe.mockReset(); + mocks.queryRawUnsafe.mockResolvedValue([]); + }); + + it.each([null, REORDERED])("with priority %j", async (priority) => { + mocks.priority = priority; + await readDayProvenance({ + userId: "u1", + type: "SLEEP_DURATION", + date: "2026-10-01", + }); + const [sql, userId, from, to] = mocks.queryRawUnsafe.mock.calls[0]; + const rank = buildSourceRankCase(priority, '"type"', '"source"'); + const squash = (s: string) => s.replace(/\s+/g, " "); + expect(squash(sql)).toContain( + squash( + `ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`, + ), + ); + expect(squash(canonicalMeasurementsCte(rank))).toContain( + squash( + `ORDER BY "type", date_trunc('day', "measured_at"), (${rank}), "source"::text`, + ), + ); + // Values are bound, never spliced. + expect(userId).toBe("u1"); + expect((from as Date).toISOString()).toBe("2026-10-01T00:00:00.000Z"); + expect((to as Date).toISOString()).toBe("2026-10-02T00:00:00.000Z"); + expect(sql).not.toContain("u1"); + }); +}); + +function row(source: string, rank: number, value: number): SourceRow { + return { + source, + rank, + readings: 1, + value, + min: value, + max: value, + firstAt: "2026-10-01T06:00:00.000Z", + lastAt: "2026-10-01T06:00:00.000Z", + unit: "min", + devices: ["unknown"], + }; +} + +describe("explainPick", () => { + it("names the highest on the ladder and why the others lost", () => { + const out = explainPick(true, [ + row("WHOOP", 0, 420), + row("OURA", 2, 400), + row("FITBIT", 90, 380), + ]); + expect(out.picked).toBe("WHOOP"); + expect(out.pickReason).toBe("highest_on_ladder"); + expect(out.passes).toEqual([null, "lower_on_ladder", "not_on_ladder"]); + }); + + it("calls a pick among off-ladder sources a name tiebreak", () => { + const out = explainPick(true, [row("FITBIT", 90, 1), row("OURA", 90, 2)]); + expect(out.pickReason).toBe("name_tiebreak"); + expect(out.passes).toEqual([null, "name_tiebreak"]); + }); + + it("one source is simply the only one; an unranked type picks nothing", () => { + expect(explainPick(true, [row("OURA", 1, 5)]).pickReason).toBe( + "only_source", + ); + const unranked = explainPick(false, [row("A", 90, 1), row("B", 90, 2)]); + expect(unranked.picked).toBeNull(); + expect(unranked.pickReason).toBe("not_ranked"); + expect(explainPick(true, []).picked).toBeNull(); + }); +}); + +describe("buildComparison", () => { + const rows = [ + { ...row("OURA", 1, 400), day: "2026-10-01" }, + { ...row("WHOOP", 0, 430), day: "2026-10-01" }, + { ...row("OURA", 1, 410), day: "2026-10-02" }, + { ...row("OURA", 1, 390), day: "2026-10-03" }, + { ...row("WHOOP", 0, 450), day: "2026-10-03" }, + ].sort((x, y) => x.day.localeCompare(y.day) || x.rank - y.rank); + + it("lines the two up per day and compares only days both cover", () => { + const c = buildComparison({ + type: "SLEEP_DURATION", + from: "2026-10-01", + to: "2026-10-03", + ranked: true, + ladder: ["WHOOP", "APPLE_HEALTH", "OURA"], + rows, + a: "OURA", + b: "WHOOP", + }); + expect(c.days).toEqual([ + { date: "2026-10-01", a: 400, b: 430, picked: "WHOOP" }, + { date: "2026-10-02", a: 410, b: null, picked: "OURA" }, + { date: "2026-10-03", a: 390, b: 450, picked: "WHOOP" }, + ]); + expect(c.summary).toMatchObject({ + daysA: 3, + daysB: 2, + daysBoth: 2, + meanA: 395, + meanB: 440, + meanDifference: 45, + meanAbsoluteDifference: 45, + pickedA: 1, + pickedB: 2, + }); + }); + + it("defaults to the two sources that cover the most days", () => { + const c = buildComparison({ + type: "SLEEP_DURATION", + from: "2026-10-01", + to: "2026-10-03", + ranked: true, + ladder: [], + rows, + }); + expect([c.a, c.b]).toEqual(["OURA", "WHOOP"]); + expect(c.sources).toEqual([ + { source: "OURA", days: 3 }, + { source: "WHOOP", days: 2 }, + ]); + }); +}); diff --git a/src/lib/provenance/ranked-types.ts b/src/lib/provenance/ranked-types.ts new file mode 100644 index 000000000..cf84a298f --- /dev/null +++ b/src/lib/provenance/ranked-types.ts @@ -0,0 +1,35 @@ +/** + * The measurement types that carry a source-priority ladder, as a plain list + * the browser can import (the server's `RANKED_TYPES` lives beside SQL + * builders and the schema library). `ranked-types.test.ts` holds the two + * lists equal, so the metric picker on the provenance page offers exactly + * the types the ladder decides for. + */ +export const PROVENANCE_TYPES = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "HRV_RMSSD", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "SKIN_TEMPERATURE", + "WEIGHT", + "BODY_FAT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "VO2_MAX", + "RECOVERY_SCORE", + "STRESS_SCORE", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "WALKING_RUNNING_DISTANCE", + "FLIGHTS_CLIMBED", +] as const; + +export type ProvenanceType = (typeof PROVENANCE_TYPES)[number]; + +export function isProvenanceType(type: string): type is ProvenanceType { + return (PROVENANCE_TYPES as readonly string[]).includes(type); +} diff --git a/src/lib/provenance/source-provenance.ts b/src/lib/provenance/source-provenance.ts new file mode 100644 index 000000000..ce20e4ea8 --- /dev/null +++ b/src/lib/provenance/source-provenance.ts @@ -0,0 +1,415 @@ +/** + * Source provenance (#1204, #1108, #576): for one metric, which sources + * delivered values on a day, which one the source-priority ladder picked and + * why, and two sources side by side over a range. + * + * Read-only and computed by the SAME SQL the canonical readers use. The pick + * is made by `ORDER BY (rank), "source"::text` over one UTC day of one type, + * with the rank CASE from `buildSourceRankCase`: exactly the ordering inside + * `canonicalMeasurementsCte`'s `DISTINCT ON`, which the charts, the rollups + * and the summaries read through. The explanation is derived from the rank + * that SQL returned, not recomputed beside it, so the page cannot say one + * thing while the charts do another. `source-provenance.test.ts` holds the + * TypeScript ladder reading to the CASE the SQL runs. + * + * Two facts the page states plainly because they follow from that rule: + * - the pick is per UTC day, the unit every ladder read works in; + * - a type without a ladder (glucose, body water, ...) is not collapsed at + * all: every source's readings count, so no source is "picked". + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + RANKED_TYPES, + buildSourceRankCase, +} from "@/lib/analytics/source-rank-sql"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { shiftDateKey } from "@/lib/tz/format"; +import { + getSourceLadder, + parseSourcePriority, +} from "@/lib/validations/source-priority"; + +/** The rank the CASE gives a source that is not on the type's ladder. */ +export const OFF_LADDER_RANK = 90; + +const ENUM_RE = /^[A-Z0-9_]+$/; + +/** How one day's value of a source is formed: a day total, or a mean. */ +export function dayAggregation(type: MeasurementType): "sum" | "mean" { + return type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type) + ? "sum" + : "mean"; +} + +export interface LadderReading { + /** False for a type without a ladder: nothing is collapsed, nothing picked. */ + ranked: boolean; + metricKey: string | null; + /** The ladder as the person's settings resolve it, highest first. */ + ladder: string[]; +} + +/** The ladder a type is ranked by, read the way `buildSourceRankCase` reads it. */ +export function ladderFor( + priorityJson: unknown, + type: MeasurementType, +): LadderReading { + const metricKey = metricKeyForType(type); + if (!RANKED_TYPES.includes(type) || !metricKey) { + return { ranked: false, metricKey: metricKey ?? null, ladder: [] }; + } + const ladder = [ + ...getSourceLadder(parseSourcePriority(priorityJson), metricKey), + ]; + return { ranked: ladder.length > 0, metricKey, ladder }; +} + +/** The rank `buildSourceRankCase` assigns, computed from the ladder. */ +export function ladderRank(ladder: readonly string[], source: string): number { + const i = ladder.indexOf(source); + return i === -1 ? OFF_LADDER_RANK : i; +} + +export type PickReason = + /** The day has one source; there was nothing to choose. */ + | "only_source" + /** Picked because it stands highest on the ladder among the day's sources. */ + | "highest_on_ladder" + /** None of the day's sources is on the ladder; the name decided. */ + | "name_tiebreak" + /** No ladder for this type: every source counts. */ + | "not_ranked"; + +export type PassReason = + /** Lower on the ladder than the picked source. */ + | "lower_on_ladder" + /** Not on the ladder at all, below every source that is. */ + | "not_on_ladder" + /** Same rank as the picked one (both off the ladder); the name decided. */ + | "name_tiebreak"; + +export interface SourceRow { + source: string; + /** The rank the SQL CASE returned; `OFF_LADDER_RANK` when not on the ladder. */ + rank: number; + readings: number; + /** The day's value from this source: total or mean (see `aggregation`). */ + value: number | null; + min: number | null; + max: number | null; + firstAt: string; + lastAt: string; + unit: string | null; + /** Device kinds the readings carry (watch, ring, ...); `unknown` when untagged. */ + devices: string[]; +} + +export interface DayProvenance { + type: MeasurementType; + /** The UTC day the pick is made for. */ + date: string; + aggregation: "sum" | "mean"; + ranked: boolean; + metricKey: string | null; + ladder: string[]; + /** The source the canonical readers use for this day; null when not ranked or empty. */ + picked: string | null; + pickReason: PickReason | null; + sources: Array; +} + +interface RawSourceRow { + source: string; + rank: number; + readings: number; + total: number | null; + asleep: number | null; + mean: number | null; + min: number | null; + max: number | null; + first_at: Date; + last_at: Date; + unit: string | null; + devices: string[] | null; +} + +/** UTC midnight to UTC midnight: the day the ladder pick is made for. */ +function utcDayBounds(date: string): { from: Date; to: Date } { + return { + from: dayKeyAsUtcMidnight(date), + to: dayKeyAsUtcMidnight(shiftDateKey(date, 1)), + }; +} + +/** + * Per (UTC day, source) aggregates for one type over `[from, to)`, ordered the + * way the canonical pick orders them. The rank CASE and the tiebreak are + * spliced from the same builder and in the same order as + * `canonicalMeasurementsCte`; every request value is a bound parameter. + */ +async function readSourceDays( + userId: string, + priorityJson: unknown, + type: MeasurementType, + from: Date, + to: Date, +): Promise> { + if (!ENUM_RE.test(type)) { + throw new Error(`unsafe enum literal for SQL splice: ${type}`); + } + const rank = buildSourceRankCase(priorityJson, '"type"', '"source"'); + return prisma.$queryRawUnsafe>( + ` + SELECT + to_char(date_trunc('day', "measured_at"), 'YYYY-MM-DD') AS day, + "source"::text AS source, + (${rank})::int AS rank, + COUNT(*)::int AS readings, + SUM("value")::double precision AS total, + SUM("value") FILTER ( + WHERE "sleep_stage" IS NULL + OR "sleep_stage" NOT IN ('AWAKE'::"sleep_stage", 'IN_BED'::"sleep_stage") + )::double precision AS asleep, + (SUM("value") / NULLIF(COUNT(*), 0))::double precision AS mean, + MIN("value")::double precision AS min, + MAX("value")::double precision AS max, + MIN("measured_at") AS first_at, + MAX("measured_at") AS last_at, + MIN("unit") AS unit, + ARRAY_AGG(DISTINCT COALESCE("device_type", 'unknown')) AS devices + FROM measurements + WHERE "user_id" = $1 + AND "type" = '${type}'::"measurement_type" + AND "deleted_at" IS NULL + AND "measured_at" >= $2 + AND "measured_at" < $3 + GROUP BY date_trunc('day', "measured_at"), "type", "source" + ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text + `, + userId, + from, + to, + ); +} + +function dayValue(type: MeasurementType, row: RawSourceRow): number | null { + if (type === "SLEEP_DURATION") return row.asleep; + return dayAggregation(type) === "sum" ? row.total : row.mean; +} + +function toSourceRow(type: MeasurementType, row: RawSourceRow): SourceRow { + return { + source: row.source, + rank: row.rank, + readings: row.readings, + value: dayValue(type, row), + min: row.min, + max: row.max, + firstAt: row.first_at.toISOString(), + lastAt: row.last_at.toISOString(), + unit: row.unit, + devices: [...(row.devices ?? [])].sort(), + }; +} + +/** + * Explain a day's pick from rows already in pick order (rank, then name). + * Pure, so the reasoning is tested without a database. + */ +export function explainPick( + ranked: boolean, + rows: readonly SourceRow[], +): { + picked: string | null; + pickReason: PickReason | null; + passes: Array; +} { + if (rows.length === 0) { + return { picked: null, pickReason: null, passes: [] }; + } + if (!ranked) { + return { + picked: null, + pickReason: "not_ranked", + passes: rows.map(() => null), + }; + } + const winner = rows[0]; + const pickReason: PickReason = + rows.length === 1 + ? "only_source" + : winner.rank === OFF_LADDER_RANK + ? "name_tiebreak" + : "highest_on_ladder"; + const passes = rows.map((row, i): PassReason | null => { + if (i === 0) return null; + if (row.rank === winner.rank) return "name_tiebreak"; + return row.rank === OFF_LADDER_RANK ? "not_on_ladder" : "lower_on_ladder"; + }); + return { picked: winner.source, pickReason, passes }; +} + +export async function readDayProvenance(args: { + userId: string; + type: MeasurementType; + date: string; +}): Promise { + const { userId, type, date } = args; + const priorityJson = await loadUserSourcePriority(userId); + const ladder = ladderFor(priorityJson, type); + const { from, to } = utcDayBounds(date); + const raw = await readSourceDays(userId, priorityJson, type, from, to); + const rows = raw.map((r) => toSourceRow(type, r)); + const { picked, pickReason, passes } = explainPick(ladder.ranked, rows); + return { + type, + date, + aggregation: dayAggregation(type), + ranked: ladder.ranked, + metricKey: ladder.metricKey, + ladder: ladder.ladder, + picked, + pickReason, + sources: rows.map((row, i) => ({ ...row, pass: passes[i] ?? null })), + }; +} + +export interface CompareDay { + date: string; + a: number | null; + b: number | null; + /** The source the canonical readers use that day (among all its sources). */ + picked: string | null; +} + +export interface SourceComparison { + type: MeasurementType; + from: string; + to: string; + aggregation: "sum" | "mean"; + ranked: boolean; + ladder: string[]; + unit: string | null; + /** Every source with readings in the range, by days covered. */ + sources: Array<{ source: string; days: number }>; + a: string | null; + b: string | null; + days: CompareDay[]; + summary: { + daysA: number; + daysB: number; + daysBoth: number; + /** Means over the days both sources cover, so the two are comparable. */ + meanA: number | null; + meanB: number | null; + /** Mean of (b − a) over the days both cover. */ + meanDifference: number | null; + meanAbsoluteDifference: number | null; + /** Days each source was the one the ladder picked. */ + pickedA: number; + pickedB: number; + }; +} + +/** Pure assembly of a comparison from per-(day, source) rows in pick order. */ +export function buildComparison(args: { + type: MeasurementType; + from: string; + to: string; + ranked: boolean; + ladder: string[]; + rows: ReadonlyArray; + a?: string | null; + b?: string | null; +}): SourceComparison { + const { type, from, to, ranked, ladder, rows } = args; + const byDay = new Map>(); + const coverage = new Map(); + for (const row of rows) { + const list = byDay.get(row.day) ?? []; + list.push(row); + byDay.set(row.day, list); + coverage.set(row.source, (coverage.get(row.source) ?? 0) + 1); + } + const sources = [...coverage] + .map(([source, days]) => ({ source, days })) + .sort((x, y) => y.days - x.days || x.source.localeCompare(y.source)); + const a = args.a ?? sources[0]?.source ?? null; + const b = args.b ?? sources.find((s) => s.source !== a)?.source ?? null; + + const days: CompareDay[] = [...byDay.keys()].sort().map((date) => { + const list = byDay.get(date) ?? []; + const pick = explainPick(ranked, list); + return { + date, + a: list.find((r) => r.source === a)?.value ?? null, + b: list.find((r) => r.source === b)?.value ?? null, + picked: pick.picked, + }; + }); + + const both = days.filter((d) => d.a !== null && d.b !== null); + const mean = (xs: number[]) => + xs.length === 0 ? null : xs.reduce((s, x) => s + x, 0) / xs.length; + return { + type, + from, + to, + aggregation: dayAggregation(type), + ranked, + ladder, + unit: rows.find((r) => r.unit)?.unit ?? null, + sources, + a, + b, + days, + summary: { + daysA: days.filter((d) => d.a !== null).length, + daysB: days.filter((d) => d.b !== null).length, + daysBoth: both.length, + meanA: mean(both.map((d) => d.a as number)), + meanB: mean(both.map((d) => d.b as number)), + meanDifference: mean(both.map((d) => (d.b as number) - (d.a as number))), + meanAbsoluteDifference: mean( + both.map((d) => Math.abs((d.b as number) - (d.a as number))), + ), + pickedA: days.filter((d) => a !== null && d.picked === a).length, + pickedB: days.filter((d) => b !== null && d.picked === b).length, + }, + }; +} + +export async function readSourceComparison(args: { + userId: string; + type: MeasurementType; + /** Inclusive UTC days. */ + from: string; + to: string; + a?: string | null; + b?: string | null; +}): Promise { + const { userId, type, from, to } = args; + const priorityJson = await loadUserSourcePriority(userId); + const ladder = ladderFor(priorityJson, type); + const raw = await readSourceDays( + userId, + priorityJson, + type, + utcDayBounds(from).from, + utcDayBounds(to).to, + ); + return buildComparison({ + type, + from, + to, + ranked: ladder.ranked, + ladder: ladder.ladder, + rows: raw.map((r) => ({ ...toSourceRow(type, r), day: r.day })), + a: args.a, + b: args.b, + }); +} diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 59425c8ab..9f43af097 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -48,6 +48,7 @@ import { sharingKeys } from "./sharing"; import { vaccinationKeys } from "./vaccinations"; import { workoutKeys } from "./workouts"; import { activityKeys } from "./activity"; +import { provenanceKeys } from "./provenance"; export const queryKeys = { ...authKeys, @@ -62,6 +63,7 @@ export const queryKeys = { ...integrationKeys, ...workoutKeys, ...activityKeys, + ...provenanceKeys, ...settingsKeys, ...cycleKeys, ...measurementReminderKeys, diff --git a/src/lib/query-keys/provenance.ts b/src/lib/query-keys/provenance.ts new file mode 100644 index 000000000..e6b0a0b57 --- /dev/null +++ b/src/lib/query-keys/provenance.ts @@ -0,0 +1,15 @@ +/** + * Query keys — the source-provenance reads (#1204). + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const provenanceKeys = { + provenanceDay: (type: string, date: string) => + ["provenance", "day", type, date] as const, + provenanceCompare: (opts: { + type: string; + from: string; + to: string; + a?: string; + b?: string; + }) => ["provenance", "compare", opts] as const, +}; diff --git a/src/lib/validations/source-provenance.ts b/src/lib/validations/source-provenance.ts new file mode 100644 index 000000000..f3fcf880e --- /dev/null +++ b/src/lib/validations/source-provenance.ts @@ -0,0 +1,132 @@ +/** + * Wire schemas of the source-provenance reads (#1204, #1108, #576). + */ +import { z } from "zod/v4"; + +import { + measurementSourceEnum, + measurementTypeEnum, +} from "@/lib/validations/measurement"; + +export const PROVENANCE_MAX_DAYS = 366; + +export const dayProvenanceQuerySchema = z.object({ + type: measurementTypeEnum.describe("The metric."), + date: z.iso + .date() + .describe( + "The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is explained for.", + ), +}); + +export const sourceComparisonQuerySchema = z + .object({ + type: measurementTypeEnum.describe("The metric."), + from: z.iso.date().describe("First UTC day, inclusive."), + to: z.iso.date().describe("Last UTC day, inclusive."), + a: measurementSourceEnum + .optional() + .describe("First source. Defaults to the source covering the most days."), + b: measurementSourceEnum + .optional() + .describe("Second source. Defaults to the next one."), + }) + .refine((q) => q.from <= q.to, { + message: "from must not be after to", + path: ["from"], + }) + .refine( + (q) => + (Date.parse(q.to) - Date.parse(q.from)) / 86_400_000 < + PROVENANCE_MAX_DAYS, + { message: `At most ${PROVENANCE_MAX_DAYS} days`, path: ["to"] }, + ); + +const pickReasonEnum = z + .enum(["only_source", "highest_on_ladder", "name_tiebreak", "not_ranked"]) + .describe( + "`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this metric has no ladder, every source counts.", + ); + +const passReasonEnum = z + .enum(["lower_on_ladder", "not_on_ladder", "name_tiebreak"]) + .nullable() + .describe("Why this source was not the one picked; null for the picked one."); + +const sourceRowSchema = z.object({ + source: measurementSourceEnum, + rank: z + .number() + .int() + .describe("Position on the ladder, 0 = highest; 90 = not on the ladder."), + readings: z.number().int(), + value: z + .number() + .nullable() + .describe( + "The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), the mean of the readings otherwise.", + ), + min: z.number().nullable(), + max: z.number().nullable(), + firstAt: z.string().describe("ISO instant of the first reading that day."), + lastAt: z.string(), + unit: z.string().nullable(), + devices: z + .array(z.string()) + .describe("Device kinds the readings carry; `unknown` when untagged."), +}); + +export const dayProvenanceResponseSchema = z + .object({ + type: measurementTypeEnum, + date: z.iso.date(), + aggregation: z.enum(["sum", "mean"]), + ranked: z.boolean(), + metricKey: z.string().nullable(), + ladder: z.array(measurementSourceEnum), + picked: measurementSourceEnum.nullable(), + pickReason: pickReasonEnum.nullable(), + sources: z.array(sourceRowSchema.extend({ pass: passReasonEnum })), + }) + .meta({ id: "DayProvenance" }); + +export const sourceComparisonResponseSchema = z + .object({ + type: measurementTypeEnum, + from: z.iso.date(), + to: z.iso.date(), + aggregation: z.enum(["sum", "mean"]), + ranked: z.boolean(), + ladder: z.array(measurementSourceEnum), + unit: z.string().nullable(), + sources: z.array( + z.object({ source: measurementSourceEnum, days: z.number().int() }), + ), + a: measurementSourceEnum.nullable(), + b: measurementSourceEnum.nullable(), + days: z.array( + z.object({ + date: z.iso.date(), + a: z.number().nullable(), + b: z.number().nullable(), + picked: measurementSourceEnum.nullable(), + }), + ), + summary: z.object({ + daysA: z.number().int(), + daysB: z.number().int(), + daysBoth: z.number().int(), + meanA: z.number().nullable(), + meanB: z.number().nullable(), + meanDifference: z.number().nullable(), + meanAbsoluteDifference: z.number().nullable(), + pickedA: z.number().int(), + pickedB: z.number().int(), + }), + }) + .meta({ id: "SourceComparison" }); + +export type DayProvenanceResponse = z.infer; +export type SourceComparisonResponse = z.infer< + typeof sourceComparisonResponseSchema +>; diff --git a/tests/integration/source-provenance.test.ts b/tests/integration/source-provenance.test.ts new file mode 100644 index 000000000..5e2c93052 --- /dev/null +++ b/tests/integration/source-provenance.test.ts @@ -0,0 +1,174 @@ +/** + * The provenance page names the source the charts actually use (#1204). + * + * Against a real Postgres: for every day of a two-source account, the source + * `readDayProvenance` reports as picked is the source the canonical CTE keeps + * (`canonicalMeasurementsCte`, which the series, rollups and summaries read + * through), under the default ladder and after the person reorders it. Sleep + * day values count asleep stages only, and the comparison lines the two + * sources up per day. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + buildSourceRankCase, + canonicalMeasurementsCte, +} from "@/lib/analytics/source-rank-sql"; +import { + readDayProvenance, + readSourceComparison, +} from "@/lib/provenance/source-provenance"; +import type { Prisma } from "@/generated/prisma/client"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "provenance-user"; +const DAY = 86_400_000; +const base = new Date(Date.now() - 10 * DAY); +base.setUTCHours(0, 0, 0, 0); +const dayKey = (n: number) => + new Date(base.getTime() + n * DAY).toISOString().slice(0, 10); +const at = (n: number, hour: number) => + new Date(base.getTime() + n * DAY + hour * 3_600_000); + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ data: { id: USER, username: USER } }); + const rows: Prisma.MeasurementCreateManyInput[] = []; + for (let d = 0; d < 5; d++) { + // Oura on every day, WHOOP on days 0, 2, 4. + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 300 + d, + unit: "min", + source: "OURA", + measuredAt: at(d, 3), + sleepStage: "CORE", + }); + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 90, + unit: "min", + source: "OURA", + measuredAt: at(d, 5), + sleepStage: "DEEP", + }); + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 30, + unit: "min", + source: "OURA", + measuredAt: at(d, 6), + sleepStage: "AWAKE", + }); + if (d % 2 === 0) { + rows.push({ + userId: USER, + type: "SLEEP_DURATION", + value: 420, + unit: "min", + source: "WHOOP", + measuredAt: at(d, 4), + sleepStage: "ASLEEP", + }); + } + rows.push({ + userId: USER, + type: "ACTIVITY_STEPS", + value: 8000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: at(d, 12), + }); + rows.push({ + userId: USER, + type: "ACTIVITY_STEPS", + value: 7000, + unit: "steps", + source: "FITBIT", + measuredAt: at(d, 12), + }); + } + await prisma.measurement.createMany({ data: rows }); +}); + +async function canonicalSource(type: string, date: string, priority: unknown) { + const rank = buildSourceRankCase(priority, '"type"', '"source"'); + const found = await prisma.$queryRawUnsafe>( + `SELECT DISTINCT m."source"::text AS source FROM (${canonicalMeasurementsCte(rank, "30 days")}) m + WHERE m."type"::text = $2 AND to_char(date_trunc('day', m."measured_at"), 'YYYY-MM-DD') = $3`, + USER, + type, + date, + ); + return found.map((r) => r.source); +} + +describe("provenance against the canonical pick", () => { + it.each([ + ["default ladder", null], + [ + "reordered", + { + metricPriority: { + sleep: ["OURA", "WHOOP"], + steps: ["FITBIT", "APPLE_HEALTH"], + }, + }, + ], + ])("%s", async (_label, priority) => { + await prisma.user.update({ + where: { id: USER }, + data: { sourcePriorityJson: priority ?? undefined }, + }); + if (priority === null) { + await prisma.$executeRawUnsafe( + `UPDATE users SET source_priority_json = NULL WHERE id = $1`, + USER, + ); + } + for (const type of ["SLEEP_DURATION", "ACTIVITY_STEPS"] as const) { + for (let d = 0; d < 5; d++) { + const p = await readDayProvenance({ + userId: USER, + type, + date: dayKey(d), + }); + expect(await canonicalSource(type, dayKey(d), priority)).toEqual([ + p.picked, + ]); + } + } + }); + + it("counts asleep stages only and compares per day", async () => { + const p = await readDayProvenance({ + userId: USER, + type: "SLEEP_DURATION", + date: dayKey(1), + }); + expect(p.sources).toHaveLength(1); + expect(p.sources[0].value).toBe(301 + 90); + expect(p.pickReason).toBe("only_source"); + + const c = await readSourceComparison({ + userId: USER, + type: "SLEEP_DURATION", + from: dayKey(0), + to: dayKey(4), + a: "OURA", + b: "WHOOP", + }); + expect(c.summary.daysA).toBe(5); + expect(c.summary.daysB).toBe(3); + expect(c.summary.daysBoth).toBe(3); + expect(c.days[1]).toMatchObject({ a: 391, b: null, picked: "OURA" }); + }); +}); From 9cfc72fe1fac02c50e8903538bbc9a3a4367dbd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:58:22 +0200 Subject: [PATCH 48/98] fix(settings): channel switches act on tap again, beside their form The Telegram, ntfy, webhook and email cards had folded the enable switch into the form, so turning a channel off took a Save as well. The switch saves on tap again, optimistically with a rollback, and sits outside the form; the form's Save covers the channel's fields and sends the stored enable state unchanged. The save-model guard now splits a card into save units, each form and the rest of the card, and refuses a unit that mixes both models: an instant switch inside the form its Save submits, or a Save outside any form next to one, still fails. A form shows its refusal inline only, no longer as a toast on top. --- e2e/notification-dispatch-journey.spec.ts | 20 +++- .../__tests__/channel-form-dirty.test.ts | 77 +++------------ .../notification-channel-cards.test.tsx | 64 ++++++++++--- .../notification-channel-toggle.test.ts | 93 ++++++++++++++++++ .../__tests__/save-model-guard.test.ts | 65 ++++++++++++- src/components/settings/channel-form-dirty.ts | 46 +++------ src/components/settings/email-card.tsx | 31 +++--- .../settings/notification-channel-toggle.ts | 95 +++++++++++++++++++ src/components/settings/ntfy-card.tsx | 27 +++--- src/components/settings/telegram-card.tsx | 58 +++++------ src/components/settings/webhook-card.tsx | 26 ++--- 11 files changed, 413 insertions(+), 189 deletions(-) create mode 100644 src/components/settings/__tests__/notification-channel-toggle.test.ts create mode 100644 src/components/settings/notification-channel-toggle.ts diff --git a/e2e/notification-dispatch-journey.spec.ts b/e2e/notification-dispatch-journey.spec.ts index b2b0e8061..ea8b68a90 100644 --- a/e2e/notification-dispatch-journey.spec.ts +++ b/e2e/notification-dispatch-journey.spec.ts @@ -270,6 +270,21 @@ async function saveCard( return (await settled).status(); } +/** Flip a channel card's enable switch and wait for the route to answer. */ +async function toggleCard( + page: Page, + switchId: string, + endpoint: string, +): Promise { + const settled = page.waitForResponse( + (res) => + new URL(res.url()).pathname === endpoint && + res.request().method() === "PUT", + ); + await page.locator(switchId).click(); + return (await settled).status(); +} + /** * Configure both channels the way a person would: email on with a recipient, * ntfy saved and left off. Everything here happens on the settings surface. @@ -277,10 +292,11 @@ async function saveCard( async function configureChannels(page: Page): Promise { await openChannels(page); - // The enable switch is a field of the form: it saves with the one Save. await fillField(page, "#email-recipient", EMAIL_RECIPIENT); - await page.locator("#email-toggle").click(); expect(await saveCard(page, "#email", "/api/settings/email")).toBe(200); + expect(await toggleCard(page, "#email-toggle", "/api/settings/email")).toBe( + 200, + ); await expect(page.locator("#email-toggle")).toHaveAttribute( "data-state", "checked", diff --git a/src/components/settings/__tests__/channel-form-dirty.test.ts b/src/components/settings/__tests__/channel-form-dirty.test.ts index cb20527dc..63e57592c 100644 --- a/src/components/settings/__tests__/channel-form-dirty.test.ts +++ b/src/components/settings/__tests__/channel-form-dirty.test.ts @@ -21,7 +21,6 @@ describe("channel form dirty state", () => { headerName: "X-Gotify-Key", headerValue: "", format: "gotify" as const, - enabled: false, }; it("webhook: a form that matches the saved config is clean", () => { @@ -57,12 +56,7 @@ describe("channel form dirty state", () => { it("ntfy: server, topic and a typed auth token count", () => { const saved = { serverUrl: "https://ntfy.sh", topic: "t" }; - const form = { - serverUrl: "https://ntfy.sh", - topic: "t", - authToken: "", - enabled: false, - }; + const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" }; expect(isNtfyFormDirty(form, saved)).toBe(false); expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true); expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe( @@ -73,86 +67,39 @@ describe("channel form dirty state", () => { it("email: the recipient counts", () => { expect( - isEmailFormDirty( - { recipient: "a@x.io", enabled: false }, - { recipient: "a@x.io" }, - ), + isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }), ).toBe(false); expect( - isEmailFormDirty( - { recipient: "b@x.io", enabled: false }, - { recipient: "a@x.io" }, - ), + isEmailFormDirty({ recipient: "b@x.io" }, { recipient: "a@x.io" }), ).toBe(true); }); it("telegram: a typed bot token or a changed chat id counts; whitespace alone does not", () => { expect( - isTelegramFormDirty( - { botToken: "", chatId: "1", enabled: false }, - { chatId: "1" }, - ), + isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }), + ).toBe(false); + expect( + isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }), ).toBe(false); expect( isTelegramFormDirty( - { botToken: " ", chatId: "", enabled: false }, - { chatId: null }, - ), - ).toBe(false); - expect( - isTelegramFormDirty( - { botToken: "123:abc", chatId: "1", enabled: false }, + { botToken: "123:abc", chatId: "1" }, { chatId: "1" }, ), ).toBe(true); expect( - isTelegramFormDirty( - { botToken: "", chatId: "2", enabled: false }, - { chatId: "1" }, - ), + isTelegramFormDirty({ botToken: "", chatId: "2" }, { chatId: "1" }), ).toBe(true); }); - it("a flipped enable switch is a change on every channel, since it saves with the form", () => { - expect( - isWebhookFormDirty({ ...webhookForm, enabled: true }, webhookSaved), - ).toBe(true); - expect( - isNtfyFormDirty( - { serverUrl: "s", topic: "t", authToken: "", enabled: true }, - { serverUrl: "s", topic: "t", enabled: false }, - ), - ).toBe(true); - expect( - isEmailFormDirty( - { recipient: "a@x.io", enabled: false }, - { recipient: "a@x.io", enabled: true }, - ), - ).toBe(true); - expect( - isTelegramFormDirty( - { botToken: "", chatId: "1", enabled: true }, - { chatId: "1", enabled: true }, - ), - ).toBe(false); - }); - it("nothing loaded yet is never a change", () => { expect(isWebhookFormDirty(webhookForm, undefined)).toBe(false); expect( - isNtfyFormDirty( - { serverUrl: "", topic: "", authToken: "x", enabled: true }, - undefined, - ), + isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined), ).toBe(false); - expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe( + expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false); + expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe( false, ); - expect( - isTelegramFormDirty( - { botToken: "x", chatId: "", enabled: true }, - undefined, - ), - ).toBe(false); }); }); diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx index 26a2d5b2a..0e09da9cf 100644 --- a/src/components/settings/__tests__/notification-channel-cards.test.tsx +++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx @@ -126,22 +126,56 @@ describe("notification channel cards", () => { ["email", ], ["telegram", ], ] as const)( - "%s enable switch is a form field: it writes nothing on its own", - async (_channel, card) => { + "%s enable switch saves on tap, alone, outside the form", + async (channel, card) => { const html = render(card); - // Rendering the switch writes nothing, and the card mounts no - // switch-only mutation: every write it can make is the full form. - expect(html).toContain('role="switch"'); + // The switch precedes the form: its Save covers the fields, not the + // switch. + expect(html.indexOf('role="switch"')).toBeGreaterThan(-1); + expect(html.indexOf('role="switch"')).toBeLessThan(html.indexOf(") { - const body = JSON.parse(init.body) as Record; - expect(Object.keys(body).length).toBeGreaterThan(1); - } + + // The card's first mutation is the switch's, and it sends `enabled` + // and nothing else. + await mocks.mutationOptions[0]!.mutationFn(true); + expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1); + expect(mocks.apiFetchRaw).toHaveBeenCalledWith( + `/api/settings/${channel}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }, + ); + }, + ); + + it.each([ + [, ["settings", "webhook"]], + [, ["settings", "ntfy"]], + [, ["settings", "email"]], + [, ["telegram", "settings"]], + ] as const)( + "rolls each switch back to its previous server state on error", + async (card, queryKey) => { + const previous = { enabled: false, persistedConfig: "unchanged" }; + mocks.queryClient.getQueryData.mockReturnValue(previous); + render(card); + const toggle = mocks.mutationOptions[0]!; + + const context = await toggle.onMutate!(true); + const optimisticUpdater = mocks.queryClient.setQueryData.mock + .calls[0]![1] as (current: typeof previous) => typeof previous; + expect(optimisticUpdater(previous)).toEqual({ + ...previous, + enabled: true, + }); + + toggle.onError!(new Error("save failed"), true, context); + expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith( + queryKey, + previous, + ); }, ); @@ -153,7 +187,7 @@ describe("notification channel cards", () => { expect(html).toContain('
@@ -136,7 +139,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { />
@@ -180,7 +179,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { />
@@ -214,7 +216,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { /> + + + ); +} From 5c64192afc6f61dee60ef3bcdb04dad23cb2c964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:59:42 +0200 Subject: [PATCH 50/98] feat(mental-health): measure screener change against the meaningful-change threshold The assessments list now carries, per PHQ-9, GAD-7 and WHO-5, the latest total against the previous and the first administration, classified against the change studies treat as meaningful: 5 points on the PHQ-9, 4 on the GAD-7, 10 on the WHO-5 percentage scale. A smaller change reads as within the threshold rather than better or worse. The instrument detail states each change and whether it reaches that size, in neutral words. The read takes totals and dates only, never an answer. --- docs/api/openapi.yaml | 98 +++++++++++++ messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/nl.json | 11 +- messages/pl.json | 11 +- .../analysis-depth-no-ai-guard.test.ts | 1 + .../assessments/__tests__/route.test.ts | 46 ++++++ .../api/mental-health/assessments/route.ts | 28 +++- .../__tests__/assessment-trend-note.test.tsx | 65 +++++++++ .../mental-health/assessment-trend-note.tsx | 58 ++++++++ .../mental-health/instrument-detail.tsx | 8 +- .../mental-health/mental-wellbeing.tsx | 11 +- src/components/mental-health/types.ts | 15 ++ .../__tests__/assessment-trend.test.ts | 80 +++++++++++ src/lib/mental-health/assessment-trend.ts | 134 ++++++++++++++++++ src/lib/openapi/routes/mental-health.ts | 42 +++++- 20 files changed, 659 insertions(+), 15 deletions(-) create mode 100644 src/components/mental-health/__tests__/assessment-trend-note.test.tsx create mode 100644 src/components/mental-health/assessment-trend-note.tsx create mode 100644 src/lib/mental-health/__tests__/assessment-trend.test.ts create mode 100644 src/lib/mental-health/assessment-trend.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c9b3511d3..1b7a1c041 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -74922,6 +74922,11 @@ components: type: array items: $ref: "#/components/schemas/MentalHealthAssessment" + trends: + description: v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/MentalHealthAssessmentTrend" required: - assessments additionalProperties: false @@ -74971,6 +74976,99 @@ components: diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read returns them decrypted. + MentalHealthAssessmentTrend: + type: object + properties: + instrument: + type: string + enum: + - PHQ9 + - GAD7 + - WHO5 + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + threshold: + type: number + description: "Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale)." + higherIsBetter: + type: boolean + first: + type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + previous: + anyOf: + - type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + - type: "null" + latest: + type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + changeFromPrevious: + type: + - number + - "null" + changeFromFirst: + type: + - number + - "null" + sincePrevious: + anyOf: + - type: string + enum: + - improved + - worsened + - within_threshold + - type: "null" + sinceFirst: + anyOf: + - type: string + enum: + - improved + - worsened + - within_threshold + - type: "null" + required: + - instrument + - n + - threshold + - higherIsBetter + - first + - previous + - latest + - changeFromPrevious + - changeFromFirst + - sincePrevious + - sinceFirst + additionalProperties: false + description: v1.44 — the latest total against the previous and the first administration, classified against the + instrument's meaningful-change threshold in its own direction. A change below the threshold is + `within_threshold`. Server-computed over the whole history, not the page. CreateMentalHealthAssessmentEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index fc61d1d46..7fa10ef44 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11075,7 +11075,16 @@ "openDetail": "Details anzeigen", "historyLoadError": "Dein Testverlauf konnte nicht geladen werden", "historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.", - "privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht." + "privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht.", + "trend": { + "sincePrevious": "Seit deinem vorigen Check-in am {date}: {change} Punkte.", + "sinceFirst": "Seit deinem ersten Check-in am {date}: {change} Punkte.", + "verdict": { + "improved": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die bessere Richtung.", + "worsened": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die weniger gute Richtung.", + "within_threshold": "Das ist weniger als die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen." + } + } }, "records": { "aiInclusion": { diff --git a/messages/en.json b/messages/en.json index be2db6114..3e8a2fd08 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11075,7 +11075,16 @@ "openDetail": "Show details", "historyLoadError": "Your assessment history could not be loaded", "historyLoadErrorHint": "This does not mean you have no results; the list could not be fetched. Try again.", - "privacyNote": "Your answers are stored encrypted and never reach the AI Coach." + "privacyNote": "Your answers are stored encrypted and never reach the AI Coach.", + "trend": { + "sincePrevious": "Since your previous check-in on {date}: {change} points.", + "sinceFirst": "Since your first check-in on {date}: {change} points.", + "verdict": { + "improved": "That reaches the {threshold}-point change studies treat as meaningful, in the better direction.", + "worsened": "That reaches the {threshold}-point change studies treat as meaningful, in the less good direction.", + "within_threshold": "That is smaller than the {threshold}-point change studies treat as meaningful." + } + } }, "records": { "aiInclusion": { diff --git a/messages/es.json b/messages/es.json index 287b36a61..74d46bd52 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11075,7 +11075,16 @@ "openDetail": "Ver detalles", "historyLoadError": "No se pudo cargar tu historial de evaluaciones", "historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.", - "privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA." + "privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA.", + "trend": { + "sincePrevious": "Desde tu revisión anterior del {date}: {change} puntos.", + "sinceFirst": "Desde tu primera revisión del {date}: {change} puntos.", + "verdict": { + "improved": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección favorable.", + "worsened": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección menos favorable.", + "within_threshold": "Es menor que el cambio de {threshold} puntos que los estudios consideran relevante." + } + } }, "records": { "aiInclusion": { diff --git a/messages/fr.json b/messages/fr.json index d1c564694..b5fe83be3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11075,7 +11075,16 @@ "openDetail": "Voir les détails", "historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé", "historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.", - "privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA." + "privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA.", + "trend": { + "sincePrevious": "Depuis ton bilan précédent du {date} : {change} points.", + "sinceFirst": "Depuis ton premier bilan du {date} : {change} points.", + "verdict": { + "improved": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le bon sens.", + "worsened": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le sens moins favorable.", + "within_threshold": "C'est moins que la variation de {threshold} points que les études jugent significative." + } + } }, "records": { "aiInclusion": { diff --git a/messages/it.json b/messages/it.json index dd0c39841..d4b271a51 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11075,7 +11075,16 @@ "openDetail": "Mostra dettagli", "historyLoadError": "Non è stato possibile caricare la cronologia dei test", "historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.", - "privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA." + "privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA.", + "trend": { + "sincePrevious": "Dal tuo check-in precedente del {date}: {change} punti.", + "sinceFirst": "Dal tuo primo check-in del {date}: {change} punti.", + "verdict": { + "improved": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione migliore.", + "worsened": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione meno buona.", + "within_threshold": "È meno della variazione di {threshold} punti che gli studi considerano rilevante." + } + } }, "records": { "aiInclusion": { diff --git a/messages/ko.json b/messages/ko.json index f8ac7397c..80faf9d15 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11075,7 +11075,16 @@ "openDetail": "자세히 보기", "historyLoadError": "검사 기록을 불러오지 못했어요", "historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.", - "privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요." + "privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요.", + "trend": { + "sincePrevious": "{date} 이전 체크인 이후: {change}점.", + "sinceFirst": "{date} 첫 체크인 이후: {change}점.", + "verdict": { + "improved": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 좋아지는 방향입니다.", + "worsened": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 덜 좋은 방향입니다.", + "within_threshold": "연구에서 의미 있다고 보는 {threshold}점 변화보다 작습니다." + } + } }, "records": { "aiInclusion": { diff --git a/messages/nl.json b/messages/nl.json index bc81ba2ae..e4bf20265 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -11075,7 +11075,16 @@ "openDetail": "Details tonen", "historyLoadError": "Je testgeschiedenis kon niet worden geladen", "historyLoadErrorHint": "Dit betekent niet dat je geen resultaten hebt; de lijst kon niet worden opgehaald. Probeer het opnieuw.", - "privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht." + "privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht.", + "trend": { + "sincePrevious": "Sinds je vorige check-in op {date}: {change} punten.", + "sinceFirst": "Sinds je eerste check-in op {date}: {change} punten.", + "verdict": { + "improved": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de betere richting.", + "worsened": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de minder goede richting.", + "within_threshold": "Dat is minder dan de verandering van {threshold} punten die studies als betekenisvol zien." + } + } }, "records": { "aiInclusion": { diff --git a/messages/pl.json b/messages/pl.json index 3939cf2eb..8913a25f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11075,7 +11075,16 @@ "openDetail": "Pokaż szczegóły", "historyLoadError": "Nie udało się wczytać historii testów", "historyLoadErrorHint": "To nie znaczy, że nie masz wyników; nie udało się pobrać listy. Spróbuj ponownie.", - "privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI." + "privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI.", + "trend": { + "sincePrevious": "Od poprzedniego sprawdzenia {date}: {change} pkt.", + "sinceFirst": "Od pierwszego sprawdzenia {date}: {change} pkt.", + "verdict": { + "improved": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w lepszym kierunku.", + "worsened": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w mniej korzystnym kierunku.", + "within_threshold": "To mniej niż zmiana o {threshold} pkt, którą badania uznają za istotną." + } + } }, "records": { "aiInclusion": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts index f5aa84195..b3b9e4067 100644 --- a/src/__tests__/analysis-depth-no-ai-guard.test.ts +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -30,6 +30,7 @@ const ANALYSIS_MODULES = [ "lib/insights/discovery-matrix.ts", "lib/insights/derived/changepoint.ts", "lib/labs/lab-trend.ts", + "lib/mental-health/assessment-trend.ts", ] as const; /** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index 715d25a99..3dfa8ded0 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -479,4 +479,50 @@ describe("GET /api/mental-health/assessments", () => { .calls[0][0] as { orderBy: unknown }; expect(findArgs.orderBy).toEqual([{ takenAt: "desc" }, { id: "desc" }]); }); + + it("carries each screener's change against its meaningful-change threshold (v1.44)", async () => { + const at = (d: string) => new Date(`${d}T09:00:00.000Z`); + const history = [ + { + id: "p2", + instrument: "PHQ9", + totalScore: 8, + takenAt: at("2026-06-01"), + }, + { + id: "p1", + instrument: "PHQ9", + totalScore: 15, + takenAt: at("2026-02-01"), + }, + ]; + vi.mocked(prisma.mentalHealthAssessment.findMany) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce(history as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest( + new URL("http://localhost/api/mental-health/assessments?limit=1"), + ), + ); + const body = await res.json(); + expect(body.data.trends).toEqual([ + expect.objectContaining({ + instrument: "PHQ9", + n: 2, + threshold: 5, + changeFromPrevious: -7, + sincePrevious: "improved", + }), + ]); + // The trend read selects totals and dates only, never the answers. + const trendArgs = vi.mocked(prisma.mentalHealthAssessment.findMany).mock + .calls[1][0] as { select: Record; where: unknown }; + expect(Object.keys(trendArgs.select).sort()).toEqual([ + "id", + "instrument", + "takenAt", + "totalScore", + ]); + expect(trendArgs.where).toMatchObject({ deletedAt: null }); + }); }); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 610bceab3..740ae8d0d 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -58,6 +58,16 @@ import type { MeasurementSource, } from "@/generated/prisma/client"; import { apiListSuccess } from "@/lib/api/list-cap"; +import { + computeAssessmentTrends, + TREND_INSTRUMENTS, +} from "@/lib/mental-health/assessment-trend"; + +/** + * Upper bound on administrations the trends read; years of weekly check-ins + * across three instruments stay far below it. The cap falls on the oldest. + */ +const TREND_READ_CAP = 1500; import { listPage } from "@/lib/http/list-query"; function shapeRow(row: { @@ -149,11 +159,25 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }), ); + // v1.44 — the change of each screener against its meaningful-change + // threshold, over the whole history rather than the page: totals and dates + // only, never an item answer. + const trendRows = await prisma.mentalHealthAssessment.findMany({ + where: { + userId: user.id, + deletedAt: null, + instrument: { in: TREND_INSTRUMENTS }, + }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take: TREND_READ_CAP, + select: { id: true, instrument: true, totalScore: true, takenAt: true }, + }); + const trends = computeAssessmentTrends(trendRows); annotate({ action: { name: "mental-health.list" }, - meta: { count: rows.length }, + meta: { count: rows.length, trends: trends.length }, }); - return apiListSuccess({ assessments: rows.map(shapeRow) }, cap); + return apiListSuccess({ assessments: rows.map(shapeRow), trends }, cap); }); // `withIdempotency` lets a double-tap / retry re-send the same diff --git a/src/components/mental-health/__tests__/assessment-trend-note.test.tsx b/src/components/mental-health/__tests__/assessment-trend-note.test.tsx new file mode 100644 index 000000000..3c81d65ee --- /dev/null +++ b/src/components/mental-health/__tests__/assessment-trend-note.test.tsx @@ -0,0 +1,65 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +import { AssessmentTrendNote } from "../assessment-trend-note"; +import type { AssessmentTrendDto } from "../types"; + +primeLocales("de"); + +const TREND: AssessmentTrendDto = { + instrument: "PHQ9", + n: 3, + threshold: 5, + higherIsBetter: false, + first: { score: 16, takenAt: "2026-01-10T09:00:00.000Z" }, + previous: { score: 12, takenAt: "2026-03-15T09:00:00.000Z" }, + latest: { score: 9, takenAt: "2026-06-01T09:00:00.000Z" }, + changeFromPrevious: -3, + changeFromFirst: -7, + sincePrevious: "within_threshold", + sinceFirst: "improved", +}; + +const render = (trend: AssessmentTrendDto, locale: "en" | "de" = "en") => + renderToStaticMarkup( + + + , + ); + +describe("", () => { + it("states each change and whether it reaches the threshold", () => { + const html = render(TREND); + expect(html).toContain("-3 points"); + expect(html).toContain("smaller than the 5-point change"); + expect(html).toContain("-7 points"); + expect(html).toContain('data-verdict="improved"'); + }); + + it("shows a rise with its sign, in German with du", () => { + const html = render( + { ...TREND, n: 2, changeFromPrevious: 6, sincePrevious: "worsened" }, + "de", + ); + expect(html).toContain("Seit deinem vorigen Check-in"); + expect(html).toContain("+6 Punkte"); + expect(html).not.toContain("ersten"); + }); + + it("is silent with a single check-in", () => { + expect( + render({ + ...TREND, + n: 1, + previous: null, + changeFromPrevious: null, + changeFromFirst: null, + sincePrevious: null, + sinceFirst: null, + }), + ).toBe(""); + }); +}); diff --git a/src/components/mental-health/assessment-trend-note.tsx b/src/components/mental-health/assessment-trend-note.tsx new file mode 100644 index 000000000..f604ba1f1 --- /dev/null +++ b/src/components/mental-health/assessment-trend-note.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { AssessmentTrendDto } from "./types"; + +/** + * v1.44 — how the latest score compares with the previous check-in (and, + * from the third, with the first), against the change size studies treat as + * meaningful for this instrument. Every number and verdict is the server's; + * the wording stays neutral: a change, its size, and whether it reaches the + * threshold. No band words, no advice. + */ +export function AssessmentTrendNote({ trend }: { trend: AssessmentTrendDto }) { + const { t } = useTranslations(); + const { date: formatDate, number } = useFormatters(); + if (trend.previous === null || trend.changeFromPrevious === null) return null; + + const signed = (delta: number) => + delta > 0 ? `+${number(delta, 0)}` : number(delta, 0); + const line = ( + key: "sincePrevious" | "sinceFirst", + at: string, + delta: number, + verdict: NonNullable, + ) => + `${t(`mentalHealth.trend.${key}`, { + date: formatDate(at), + change: signed(delta), + })} ${t(`mentalHealth.trend.verdict.${verdict}`, { + threshold: trend.threshold, + })}`; + + return ( +
+ {trend.sincePrevious ? ( +

+ {line( + "sincePrevious", + trend.previous.takenAt, + trend.changeFromPrevious, + trend.sincePrevious, + )} +

+ ) : null} + {trend.n >= 3 && trend.sinceFirst && trend.changeFromFirst !== null ? ( +

+ {line( + "sinceFirst", + trend.first.takenAt, + trend.changeFromFirst, + trend.sinceFirst, + )} +

+ ) : null} +
+ ); +} diff --git a/src/components/mental-health/instrument-detail.tsx b/src/components/mental-health/instrument-detail.tsx index fb1b2d36b..4e798ee49 100644 --- a/src/components/mental-health/instrument-detail.tsx +++ b/src/components/mental-health/instrument-detail.tsx @@ -21,16 +21,20 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; import { AssessmentHistory } from "./assessment-history"; -import type { AssessmentRow, InstrumentId } from "./types"; +import type { AssessmentRow, AssessmentTrendDto, InstrumentId } from "./types"; +import { AssessmentTrendNote } from "./assessment-trend-note"; export function InstrumentDetail({ instrument, rows, + trend, onStart, }: { instrument: InstrumentId; /** ALL assessments; the pinned history filters to this instrument. */ rows: AssessmentRow[]; + /** v1.44 — the server's change against the meaningful-change threshold. */ + trend?: AssessmentTrendDto; /** Start a check-in for this instrument (closes the host sheet). */ onStart: () => void; }) { @@ -75,6 +79,8 @@ export function InstrumentDetail({ )} + {trend ? : null} + {/* The Start action stays reachable here too — the detail is a calm reading surface, not a dead end. */} {canManageMind && ( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 3919ed1e6..a6341fb47 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -45,6 +45,7 @@ import { InstrumentCard } from "./instrument-card"; import { InstrumentDetail } from "./instrument-detail"; import type { AssessmentRow, + AssessmentTrendDto, CreateResponse, InstrumentId, Phase, @@ -81,9 +82,10 @@ export function MentalWellbeing() { } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), queryFn: () => - apiGetList<{ assessments: AssessmentRow[] }>( - "/api/mental-health/assessments", - ), + apiGetList<{ + assessments: AssessmentRow[]; + trends?: AssessmentTrendDto[]; + }>("/api/mental-health/assessments"), }); const history = historyPage?.data; @@ -229,6 +231,9 @@ export function MentalWellbeing() { trend.instrument === detailInstrument, + )} onStart={() => begin(detailInstrument)} /> )} diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts index 26d47f766..b5b679876 100644 --- a/src/components/mental-health/types.ts +++ b/src/components/mental-health/types.ts @@ -48,3 +48,18 @@ export interface CreateResponse { actionThreshold: number; crisis: CrisisSet | null; } + +/** v1.44 — `GET /api/mental-health/assessments` `trends[]` (`computeAssessmentTrends`). */ +export interface AssessmentTrendDto { + instrument: "PHQ9" | "GAD7" | "WHO5"; + n: number; + threshold: number; + higherIsBetter: boolean; + first: { score: number; takenAt: string }; + previous: { score: number; takenAt: string } | null; + latest: { score: number; takenAt: string }; + changeFromPrevious: number | null; + changeFromFirst: number | null; + sincePrevious: "improved" | "worsened" | "within_threshold" | null; + sinceFirst: "improved" | "worsened" | "within_threshold" | null; +} diff --git a/src/lib/mental-health/__tests__/assessment-trend.test.ts b/src/lib/mental-health/__tests__/assessment-trend.test.ts new file mode 100644 index 000000000..5af9791c0 --- /dev/null +++ b/src/lib/mental-health/__tests__/assessment-trend.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + computeAssessmentTrends, + MEANINGFUL_CHANGE, + verdictForChange, +} from "../assessment-trend"; + +const row = ( + id: string, + instrument: string, + totalScore: number, + day: string, +) => ({ + id, + instrument, + totalScore, + takenAt: new Date(`${day}T09:00:00.000Z`), +}); + +describe("meaningful-change thresholds", () => { + it("are the published minimal important differences", () => { + expect(MEANINGFUL_CHANGE).toEqual({ PHQ9: 5, GAD7: 4, WHO5: 10 }); + }); + + it("classify in the instrument's direction, inclusive at the threshold", () => { + expect(verdictForChange(-5, 5, false)).toBe("improved"); + expect(verdictForChange(-4, 5, false)).toBe("within_threshold"); + expect(verdictForChange(5, 5, false)).toBe("worsened"); + expect(verdictForChange(12, 10, true)).toBe("improved"); + expect(verdictForChange(-10, 10, true)).toBe("worsened"); + }); +}); + +describe("computeAssessmentTrends", () => { + it("compares the latest score with the previous and the first", () => { + const trends = computeAssessmentTrends([ + row("c", "PHQ9", 9, "2026-06-01"), + row("a", "PHQ9", 16, "2026-01-10"), + row("b", "PHQ9", 12, "2026-03-15"), + row("g", "GAD7", 10, "2026-03-15"), + row("h", "GAD7", 13, "2026-06-01"), + row("w", "WHO5", 44, "2026-06-01"), + row("s", "SCI", 20, "2026-06-01"), + ]); + expect(trends.map((t) => t.instrument)).toEqual(["PHQ9", "GAD7", "WHO5"]); + const [phq, gad, who] = trends; + expect(phq).toMatchObject({ + n: 3, + threshold: 5, + changeFromPrevious: -3, + changeFromFirst: -7, + sincePrevious: "within_threshold", + sinceFirst: "improved", + first: { score: 16 }, + latest: { score: 9 }, + }); + expect(gad).toMatchObject({ + n: 2, + changeFromPrevious: 3, + sincePrevious: "within_threshold", + }); + expect(who).toMatchObject({ + n: 1, + previous: null, + changeFromPrevious: null, + sincePrevious: null, + sinceFirst: null, + }); + }); + + it("orders same-day administrations by id", () => { + const [phq] = computeAssessmentTrends([ + row("b", "PHQ9", 20, "2026-06-01"), + row("a", "PHQ9", 10, "2026-06-01"), + ]); + expect(phq.latest.score).toBe(20); + expect(phq.sincePrevious).toBe("worsened"); + }); +}); diff --git a/src/lib/mental-health/assessment-trend.ts b/src/lib/mental-health/assessment-trend.ts new file mode 100644 index 000000000..1922be139 --- /dev/null +++ b/src/lib/mental-health/assessment-trend.ts @@ -0,0 +1,134 @@ +/** + * v1.44 — screener change measured against the size studies treat as + * meaningful, instead of the last score on its own. + * + * A difference between two administrations is only worth naming when it is + * at least the instrument's minimal clinically important difference, which + * the primary literature puts at: + * + * - PHQ-9: 5 points (Löwe, Unützer, Callahan, Perkins & Kroenke 2004, + * Med Care 42(12):1194–1201). + * - GAD-7: 4 points (Toussaint, Hüsing, Gumz et al. 2020, J Affect Disord + * 265:395–401). + * - WHO-5: 10 points on the 0–100 percentage scale (Topp, Østergaard, + * Søndergaard & Bech 2015, Psychother Psychosom 84(3):167–176). + * + * A change below the threshold reads as "within the threshold", not as + * better or worse; a change at or beyond it reads in the instrument's own + * direction (lower is better on PHQ-9 / GAD-7, higher on WHO-5). This is a + * description of the person's own scores, never a diagnosis and never a + * treatment response; the SCI has no published threshold of this kind and + * gets no trend. Pure; the read lives in the assessments route. + */ +import { + INSTRUMENTS, + type InstrumentId, +} from "@/lib/mental-health/instruments"; + +export const MEANINGFUL_CHANGE: Readonly< + Partial> +> = { + PHQ9: 5, + GAD7: 4, + WHO5: 10, +}; + +export const TREND_INSTRUMENTS = Object.keys( + MEANINGFUL_CHANGE, +) as InstrumentId[]; + +export type ChangeVerdict = "improved" | "worsened" | "within_threshold"; + +export interface ScorePoint { + score: number; + takenAt: string; +} + +export interface AssessmentTrend { + instrument: InstrumentId; + /** Administrations of this instrument on record. */ + n: number; + /** The meaningful-change threshold, in the instrument's reported points. */ + threshold: number; + higherIsBetter: boolean; + first: ScorePoint; + previous: ScorePoint | null; + latest: ScorePoint; + /** latest − previous; null with one administration. */ + changeFromPrevious: number | null; + /** latest − first; null with one administration. */ + changeFromFirst: number | null; + sincePrevious: ChangeVerdict | null; + sinceFirst: ChangeVerdict | null; +} + +/** Classify a signed change against the threshold in the instrument's direction. */ +export function verdictForChange( + delta: number, + threshold: number, + higherIsBetter: boolean, +): ChangeVerdict { + if (Math.abs(delta) < threshold) return "within_threshold"; + return delta > 0 === higherIsBetter ? "improved" : "worsened"; +} + +/** + * One trend per instrument that has a threshold and at least one + * administration, in the instrument order. Rows may arrive in any order; + * they are ordered by `takenAt`, then `id`. + */ +export function computeAssessmentTrends( + rows: ReadonlyArray<{ + id: string; + instrument: string; + totalScore: number; + takenAt: Date; + }>, +): AssessmentTrend[] { + const out: AssessmentTrend[] = []; + for (const instrument of TREND_INSTRUMENTS) { + const threshold = MEANINGFUL_CHANGE[instrument] as number; + const series = rows + .filter( + (r) => r.instrument === instrument && Number.isFinite(r.totalScore), + ) + .sort( + (a, b) => + a.takenAt.getTime() - b.takenAt.getTime() || (a.id < b.id ? -1 : 1), + ); + if (series.length === 0) continue; + const { higherIsBetter } = INSTRUMENTS[instrument]; + const point = (r: (typeof series)[number]): ScorePoint => ({ + score: r.totalScore, + takenAt: r.takenAt.toISOString(), + }); + const latest = series[series.length - 1]; + const previous = series.length > 1 ? series[series.length - 2] : null; + const first = series[0]; + const changeFromPrevious = previous + ? latest.totalScore - previous.totalScore + : null; + const changeFromFirst = + series.length > 1 ? latest.totalScore - first.totalScore : null; + out.push({ + instrument, + n: series.length, + threshold, + higherIsBetter, + first: point(first), + previous: previous ? point(previous) : null, + latest: point(latest), + changeFromPrevious, + changeFromFirst, + sincePrevious: + changeFromPrevious === null + ? null + : verdictForChange(changeFromPrevious, threshold, higherIsBetter), + sinceFirst: + changeFromFirst === null + ? null + : verdictForChange(changeFromFirst, threshold, higherIsBetter), + }); + } + return out; +} diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index b7b81e7d8..8a15fd8e7 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -100,8 +100,48 @@ const createResponse = z }) .meta({ id: "CreateMentalHealthAssessmentResponse" }); +const scorePoint = z.object({ + score: z.number(), + takenAt: z.string(), +}); +const changeVerdict = z + .enum(["improved", "worsened", "within_threshold"]) + .nullable(); + +const assessmentTrend = z + .object({ + instrument: z.enum(["PHQ9", "GAD7", "WHO5"]), + n: z.number().int(), + threshold: z + .number() + .describe( + "Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale).", + ), + higherIsBetter: z.boolean(), + first: scorePoint, + previous: scorePoint.nullable(), + latest: scorePoint, + changeFromPrevious: z.number().nullable(), + changeFromFirst: z.number().nullable(), + sincePrevious: changeVerdict, + sinceFirst: changeVerdict, + }) + .meta({ + id: "MentalHealthAssessmentTrend", + description: + "v1.44 — the latest total against the previous and the first administration, classified against the instrument's meaningful-change threshold in its own direction. A change below the threshold is `within_threshold`. Server-computed over the whole history, not the page.", + }); + const listResponse = z - .object({ assessments: z.array(assessmentRow) }) + .object({ + assessments: z.array(assessmentRow), + trends: z + .array(assessmentTrend) + .optional() + .describe( + "v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.", + ), + }) .meta({ id: "ListMentalHealthAssessmentsResponse" }); const detailResponse = z From e8f34315d48e576748974cf9974b7472aa8a9926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:59:46 +0200 Subject: [PATCH 51/98] docs(mcp): the switch is in Modules, the MCP page shows its state --- docs/self-hosting/mcp.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/self-hosting/mcp.md b/docs/self-hosting/mcp.md index 822c85b38..a54b2d1c5 100644 --- a/docs/self-hosting/mcp.md +++ b/docs/self-hosting/mcp.md @@ -30,7 +30,7 @@ Three things must be true before `/mcp` will answer: | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`APP_URL` is set** | The endpoint fails closed without a pinned canonical origin. `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is the origin every OAuth document and audience check is anchored to — `Host` is never trusted. Without it the surface stays invisible (`404`). | | **The global API is on** | The operator's instance-wide API switch must be enabled (it is by default). With it off, every token path — including `/mcp` — returns `404`. | -| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in Settings. Until then `/mcp` answers `404` for that account. | +| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in **Settings → Modules**. Until then `/mcp` answers `404` for that account. | `APP_URL` is the same origin you already use for passkeys and invite links — for example `https://health.example`. It must be reachable by the @@ -40,11 +40,15 @@ LAN-only origin works for Claude Desktop over stdio). ## 1. Enable the module -In the app: **Settings → MCP**. The card has three parts: +The switch lives with every other module: **Settings → Modules**, the +**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account. -- **Enable** — the opt-in switch. Toggle it on; the `/mcp` endpoint goes - live for your account and the card shows your endpoint URL - (`/mcp`). +**Settings → MCP** is where you use it. The page has three parts: + +- **State** — whether the module is on, with a link to its switch in + Modules, and your endpoint URL (`/mcp`). Turning the module off + pauses the endpoint; your tokens and connections stay and work again + once it is back on. - **Connections** — cloud connectors that completed the OAuth flow appear here; revoke any of them. - **Tokens** — manual connector tokens (see below). From ac149460ba743f5cca7e9a098e2e2b198e90c2dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:00:41 +0200 Subject: [PATCH 52/98] docs(home-assistant): one room, intervals that divide the hour, current keys The room-climate guide said both to create one automation per room and to track only one; it now recommends the bedroom and says why a second room cannot work (no room name, same-minute pushes collide on 409). The blueprint offers 5, 10, 15, 20, 30 or 60 minutes, so the push lands on the stated minutes, and it and the guide's examples use the triggers, conditions and actions keys of Home Assistant 2024.10 and later. A test holds all three. --- .../blueprints/healthlog-room-climate.yaml | 28 +++--- docs/integrations/home-assistant.md | 39 ++++---- .../home-assistant-room-climate-docs.test.ts | 99 +++++++++++++++++++ 3 files changed, 136 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/home-assistant-room-climate-docs.test.ts diff --git a/docs/integrations/blueprints/healthlog-room-climate.yaml b/docs/integrations/blueprints/healthlog-room-climate.yaml index 205ad0708..c573eb036 100644 --- a/docs/integrations/blueprints/healthlog-room-climate.yaml +++ b/docs/integrations/blueprints/healthlog-room-climate.yaml @@ -1,5 +1,8 @@ # Push a room's CO2, temperature and humidity to HealthLog every few minutes. # +# Use it for one room. The readings carry no room name, so a second room would +# land in the same series, and a push in the same minute is refused (409). +# # Needs the `healthlog_room_climate` rest_command from # docs/integrations/home-assistant.md ("Room climate") and a measurement # ingest token. Every sensor is optional; a sensor that reports `unknown`, @@ -12,6 +15,8 @@ blueprint: HealthLog through a measurement ingest token. HealthLog keeps each reading and shows the day's mean, low and high. domain: automation + homeassistant: + min_version: 2024.10.0 source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml input: co2_sensor: @@ -40,14 +45,13 @@ blueprint: device_class: humidity interval_minutes: name: Interval - description: Minutes between pushes. 15 is plenty for a daily picture. - default: 15 + description: >- + Minutes between pushes, on the hour and every interval after it. + 15 is plenty for a daily picture. + default: "15" selector: - number: - min: 5 - max: 60 - step: 5 - unit_of_measurement: min + select: + options: ["5", "10", "15", "20", "30", "60"] mode: single @@ -77,17 +81,17 @@ variables: {% endfor %} {{ ns.items }} -trigger: - - platform: time_pattern +triggers: + - trigger: time_pattern minutes: "/5" -condition: +conditions: - condition: template value_template: >- {{ now().minute % (interval_minutes | int(15)) == 0 and readings | count > 0 }} -action: - - service: rest_command.healthlog_room_climate +actions: + - action: rest_command.healthlog_room_climate data: readings: "{{ readings }}" diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index d5423e650..d858e7e80 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -109,20 +109,20 @@ a new weight: ```yaml automation: - alias: "Push weight to HealthLog" - trigger: - - platform: state + triggers: + - trigger: state entity_id: sensor.bathroom_scale_weight - condition: + conditions: - condition: template value_template: "{{ trigger.to_state.state not in ['unknown', 'unavailable'] }}" - action: - - service: rest_command.healthlog_measurement + actions: + - action: rest_command.healthlog_measurement data: type: WEIGHT value: "{{ trigger.to_state.state | float }}" ``` -The `condition` is not decoration. Home Assistant sensors publish `unknown` and +The condition is not decoration. Home Assistant sensors publish `unknown` and `unavailable` as ordinary states, and forwarding one produces a 422 on every restart. @@ -179,11 +179,17 @@ rest_command: payload: "{{ readings | to_json }}" ``` -Then import the blueprint, either from +Track one room, the one you sleep in: that is the one worth reading next to +your sleep. The readings carry no room name, so a second room would land in the +same series and blur both, and two pushes in the same minute collide on a 409 +(see below). + +Then import the blueprint (Home Assistant 2024.10 or newer), either from [`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml) or by its raw GitHub URL under Settings → Automations & scenes → Blueprints → -Import blueprint, and create one automation per room. It asks for the three -sensors (each optional) and an interval. Every push: +Import blueprint, and create one automation from it for that room. It asks for +the three sensors (each optional) and an interval of 5, 10, 15, 20, 30 or 60 +minutes, counted from the full hour. Every push: - leaves out a sensor that reports `unknown`, `unavailable` or anything that is not a number, and skips the push when nothing is left; @@ -197,8 +203,8 @@ single room without the blueprint: ```yaml automation: - alias: "Push bedroom climate to HealthLog" - trigger: - - platform: time_pattern + triggers: + - trigger: time_pattern minutes: "/15" variables: readings: >- @@ -215,19 +221,16 @@ automation: {% endif %} {% endfor %} {{ ns.items }} - condition: + conditions: - condition: template value_template: "{{ readings | count > 0 }}" - action: - - service: rest_command.healthlog_room_climate + actions: + - action: rest_command.healthlog_room_climate data: readings: "{{ readings }}" ``` -One request takes up to five readings, so a room is one call. Several rooms -are several automations: the readings carry no room name, and two rooms pushed -in the same minute collide on the 409 above. If you track more than one room, -pick the one you sleep in; that is the one worth reading next to your sleep. +One request takes up to five readings, so the room is one call. ## Sending several readings at once diff --git a/src/__tests__/home-assistant-room-climate-docs.test.ts b/src/__tests__/home-assistant-room-climate-docs.test.ts new file mode 100644 index 000000000..58d583504 --- /dev/null +++ b/src/__tests__/home-assistant-room-climate-docs.test.ts @@ -0,0 +1,99 @@ +/** + * The room-climate blueprint and its guide say one consistent thing. + * + * - The blueprint uses Home Assistant's current automation keys (`triggers`, + * `conditions`, `actions`, `trigger:` and `action:` inside them) and says + * which release it needs for them. + * - Every interval it offers divides the hour, so "every N minutes from the + * full hour" is what actually happens: the trigger fires every five minutes + * and the condition keeps `minute % N == 0`, which for an N that does not + * divide 60 drifts at the hour. + * - The guide recommends one room and never tells a reader to create one + * automation per room, which the readings (no room name) cannot support. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse, Scalar, type CollectionTag, type ScalarTag } from "yaml"; + +const ROOT = process.cwd(); +const BLUEPRINT = join( + ROOT, + "docs/integrations/blueprints/healthlog-room-climate.yaml", +); +const GUIDE = join(ROOT, "docs/integrations/home-assistant.md"); + +const inputTag: ScalarTag = { + tag: "!input", + resolve: (value: string) => new Scalar(`!input ${value}`), +}; + +function blueprint(): Record { + return parse(readFileSync(BLUEPRINT, "utf8"), { + customTags: [inputTag as ScalarTag | CollectionTag], + }) as Record; +} + +describe("room-climate blueprint", () => { + it("uses the current automation keys and names the release they need", () => { + const doc = blueprint(); + for (const legacy of ["trigger", "condition", "action"]) { + expect(doc, legacy).not.toHaveProperty(legacy); + } + const triggers = doc.triggers as Array>; + const actions = doc.actions as Array>; + expect(triggers.every((t) => "trigger" in t && !("platform" in t))).toBe( + true, + ); + expect(actions.every((a) => "action" in a && !("service" in a))).toBe(true); + const meta = doc.blueprint as { + homeassistant?: { min_version?: string }; + }; + expect(meta.homeassistant?.min_version).toMatch(/^2024\.10\./); + }); + + it("offers only intervals that divide the hour", () => { + const meta = blueprint().blueprint as { + input: { + interval_minutes: { + default: string; + selector: { select: { options: string[] } }; + }; + }; + }; + const options = meta.input.interval_minutes.selector.select.options; + expect(options.length).toBeGreaterThan(0); + for (const option of options) { + expect(60 % Number(option), option).toBe(0); + } + expect(options).toContain(meta.input.interval_minutes.default); + }); +}); + +describe("room-climate guide", () => { + const guide = readFileSync(GUIDE, "utf8"); + const section = guide.slice( + guide.indexOf("## Room climate"), + guide.indexOf("## Sending several readings at once"), + ); + + it("recommends one room and explains why", () => { + expect(section.length).toBeGreaterThan(200); + expect(section).toMatch(/Track one room/); + expect(section).toMatch(/409/); + expect(section).not.toMatch(/one automation per room/i); + expect(section).not.toMatch(/several automations/i); + }); + + it("writes its examples with the current keys", () => { + const examples = [...guide.matchAll(/```yaml\n([\s\S]*?)```/g)].map( + (m) => m[1]!, + ); + expect(examples.length).toBeGreaterThan(0); + for (const example of examples) { + expect(example).not.toMatch(/^\s+- platform:/m); + expect(example).not.toMatch(/^\s+- service:/m); + } + }); +}); From f428b14ce99ce5906ae86efad9d44bfb4c88c129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:01:50 +0200 Subject: [PATCH 53/98] fix(i18n): future tense for the Topics preview, settled German wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Topics preview describes what it will add, the workout-route detail says off for new accounts, and German reads 'Zurück zu den Modulen', 'Unter Module ändern', 'Unter Anordnung öffnen' and 'Erfassung' for the Tracking group. Strings that name the Insights area call it Insights; generic uses of Auswertung stay. --- messages/de.json | 22 ++++++------- messages/en.json | 4 +-- messages/es.json | 4 +-- messages/fr.json | 4 +-- messages/it.json | 4 +-- messages/ko.json | 4 +-- messages/nl.json | 4 +-- messages/pl.json | 4 +-- src/__tests__/settings-copy-tense.test.ts | 29 +++++++++++++++++ src/__tests__/settings-glossary-de.test.ts | 37 ++++++++++++++++++++++ 10 files changed, 91 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/settings-copy-tense.test.ts diff --git a/messages/de.json b/messages/de.json index f2372f6ae..4fe4a1817 100644 --- a/messages/de.json +++ b/messages/de.json @@ -845,7 +845,7 @@ "previousPage": "Vorherige Seite", "nextPage": "Nächste Seite", "dailyTotalCaption": "({count} Einzelwerte)", - "openMetricInsights": "{type}-Auswertung öffnen", + "openMetricInsights": "{type} in Insights öffnen", "sleepNapCaption": "inkl. {count} Nickerchen", "sleepAwakeningsCaption": "{count} Wachphasen", "expandDay": "Einzelwerte dieses Tages anzeigen", @@ -2406,7 +2406,7 @@ "create": "Hinzufügen", "limitReached": "Du hast die Grenze von 20 eigenen Kategorien erreicht.", "saveFailed": "Das wurde nicht gespeichert. Bitte versuch es noch einmal.", - "bpGateNote": "Die Blutdruck-Auswertung zählt nur Medikamente in der eingebauten Kategorie Blutdruck.", + "bpGateNote": "Die Blutdruck-Insights zählen nur Medikamente in der eingebauten Kategorie Blutdruck.", "manage": "Kategorien verwalten", "manageDescription": "Deine eigenen Kategorien, neben den eingebauten.", "hidden": "Ausgeblendet", @@ -5304,7 +5304,7 @@ "ai": "KI", "connectivity": "Konnektivität", "system": "System", - "tracking": "Tracking", + "tracking": "Erfassung", "arrangement": "Anordnung", "notifications": "Benachrichtigungen", "connections": "Verbindungen", @@ -5883,10 +5883,10 @@ "title": "Was du trackst", "description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Insights, deine Daten bleiben erhalten." }, - "manageIn": "In {section} verwalten", + "manageIn": "Unter {section} verwalten", "operatorDisabled": "Serverweit deaktiviert", "subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt.", - "backToHub": "Zurück zu Module", + "backToHub": "Zurück zu den Modulen", "openModuleSettings": "Moduleinstellungen", "pages": { "medications": { @@ -5903,7 +5903,7 @@ }, "arrangementTitle": "Reihenfolge und Ansicht", "arrangementDescription": "Wie diese Liste sortiert und angezeigt wird, legst du unter Anordnung fest.", - "openArrangement": "In Anordnung öffnen", + "openArrangement": "Unter Anordnung öffnen", "moduleSettingsTitle": "Moduleinstellungen", "moduleSettingsDescription": "Alles außer Reihenfolge und Ansicht findest du auf der Seite des Moduls unter Module." } @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "Trainingsrouten", "description": "Den GPS-Verlauf neuer Trainings aus der iPhone-App und dem Apple-Health-Export speichern.", - "detail": "Standardmäßig aus. Eine Route zeigt, wo du wohnst, arbeitest und trainierst, deshalb wird sie erst gespeichert, wenn du das einschaltest. Die Trainings selbst werden immer gespeichert, und keine KI-Funktion liest jemals eine Route. Ausschalten stoppt neue Routen; schon gespeicherte bleiben, bis du das Training löschst.", + "detail": "Für neue Konten aus. Eine Route zeigt, wo du wohnst, arbeitest und trainierst, deshalb wird sie erst gespeichert, wenn du das einschaltest. Die Trainings selbst werden immer gespeichert, und keine KI-Funktion liest jemals eine Route. Ausschalten stoppt neue Routen; schon gespeicherte bleiben, bis du das Training löschst.", "loadError": "Die Routen-Einstellung konnte nicht geladen werden." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "Themen (Beta)", - "description": "Fügt einen eigenen Eintrag Themen hinzu, mit Übersichtsseiten über den einzelnen Insights-Seiten.", + "description": "Fügt einen eigenen Eintrag Themen hinzu, sobald die Übersichtsseiten über den einzelnen Insights-Seiten fertig sind.", "detail": "Deine Insights-Seiten, die Navigation und alle Links bleiben genau wie sie sind. Die Themenseiten kommen mit einem späteren Update; dieser Schalter legt fest, ob du sie siehst.", "savedOn": "Themen-Vorschau eingeschaltet.", "savedOff": "Themen-Vorschau ausgeschaltet." @@ -7551,7 +7551,7 @@ "moduleState": { "on": "An", "off": "Aus", - "change": "In Module ändern" + "change": "Unter Module ändern" }, "timezoneUseDevice": "Zeitzone dieses Geräts übernehmen ({zone})" }, @@ -7633,7 +7633,7 @@ "usageLevels": "Konten je KI-Stufe: {frugal} sparsam, {standard} Standard, {full} voll." }, "aiQuality": { - "title": "Auswertungs-Qualität", + "title": "Insights-Qualität", "loading": "Feedback-Zusammenfassung wird geladen…", "loadError": "Qualitätszusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.", "empty": "Noch kein Feedback. Der Aggregator läuft täglich um 04:00. Sobald Nutzer Empfehlungen bewerten, erscheinen hier die Hilfreich-Quoten.", @@ -12367,7 +12367,7 @@ "accessWriteDetail": "Sie kann Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament und ein neues Medikament hinzufügen sowie eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das Eigene, und eine deiner Erinnerungen verschieben kann sie nicht. Das kannst nur du.", "accessManageLabel": "Darf verwalten", "accessManageCapability": "Sie kann Einträge anlegen, ändern und löschen, auch solche, die du selbst erfasst hast.", - "accessManageDetail": "Sie kann außerdem die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.", + "accessManageDetail": "Sie kann außerdem die daraus erzeugten Insights lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.", "manageStepUpNote": "Das geht nur im Browser, und falls du einen zweiten Faktor eingerichtet hast, wird er abgefragt.", "scopeLegend": "Was die Person öffnen darf", "scopeAllLabel": "Gesamte Akte", diff --git a/messages/en.json b/messages/en.json index 2d14f363e..bfefe46ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "Workout routes", "description": "Keep the GPS track of new workouts from the iPhone app and the Apple Health export.", - "detail": "Off by default. A route shows where you live, work and train, so it is only kept once you turn this on. Workouts are kept either way, and no AI feature ever reads a route. Turning it off stops new routes; routes already kept stay until you delete the workout.", + "detail": "Off for new accounts. A route shows where you live, work and train, so it is only kept once you turn this on. Workouts are kept either way, and no AI feature ever reads a route. Turning it off stops new routes; routes already kept stay until you delete the workout.", "loadError": "The route setting could not be loaded." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "Topics (beta)", - "description": "Adds a separate Topics entry with overview pages above the single-metric Insights pages.", + "description": "Will add a separate Topics entry with overview pages above the single-metric Insights pages once they are ready.", "detail": "Your Insights pages, navigation and links stay exactly as they are. The topic pages arrive in a later update; this switch decides whether you see them.", "savedOn": "Topics preview switched on.", "savedOff": "Topics preview switched off." diff --git a/messages/es.json b/messages/es.json index 1eaa503cc..949a677b4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "Rutas de entrenamiento", "description": "Guardar el recorrido GPS de los entrenamientos nuevos de la app de iPhone y de la exportación de Apple Salud.", - "detail": "Desactivado por defecto. Una ruta muestra dónde vives, trabajas y entrenas, así que solo se guarda cuando lo activas. Los entrenamientos se guardan siempre y ninguna función de IA lee nunca una ruta. Al desactivarlo dejan de guardarse rutas nuevas; las ya guardadas se quedan hasta que borres el entrenamiento.", + "detail": "Desactivado en las cuentas nuevas. Una ruta muestra dónde vives, trabajas y entrenas, así que solo se guarda cuando lo activas. Los entrenamientos se guardan siempre y ninguna función de IA lee nunca una ruta. Al desactivarlo dejan de guardarse rutas nuevas; las ya guardadas se quedan hasta que borres el entrenamiento.", "loadError": "No se pudo cargar el ajuste de rutas." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "Temas (beta)", - "description": "Añade una entrada aparte, Temas, con páginas de resumen por encima de las páginas de cada métrica.", + "description": "Añadirá una entrada aparte, Temas, con páginas de resumen por encima de las páginas de cada métrica cuando estén listas.", "detail": "Tus páginas de análisis, la navegación y los enlaces se quedan exactamente como están. Las páginas de temas llegan en una actualización posterior; este interruptor decide si las ves.", "savedOn": "Vista previa de Temas activada.", "savedOff": "Vista previa de Temas desactivada." diff --git a/messages/fr.json b/messages/fr.json index 4eb0009e4..0ec8901fa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "Parcours d’entraînement", "description": "Conserver le tracé GPS des nouveaux entraînements de l’app iPhone et de l’export Apple Santé.", - "detail": "Désactivé par défaut. Un parcours montre où vous habitez, travaillez et vous entraînez : il n’est donc conservé qu’une fois l’option activée. Les entraînements sont conservés dans tous les cas, et aucune fonction d’IA ne lit jamais un parcours. Désactiver l’option arrête les nouveaux parcours ; ceux déjà conservés restent jusqu’à la suppression de l’entraînement.", + "detail": "Désactivé pour les nouveaux comptes. Un parcours montre où vous habitez, travaillez et vous entraînez : il n’est donc conservé qu’une fois l’option activée. Les entraînements sont conservés dans tous les cas, et aucune fonction d’IA ne lit jamais un parcours. Désactiver l’option arrête les nouveaux parcours ; ceux déjà conservés restent jusqu’à la suppression de l’entraînement.", "loadError": "Impossible de charger le réglage des parcours." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "Thèmes (bêta)", - "description": "Ajoute une entrée Thèmes distincte, avec des pages de synthèse au-dessus des pages par mesure.", + "description": "Ajoutera une entrée Thèmes distincte, avec des pages de synthèse au-dessus des pages par mesure, dès qu'elles seront prêtes.", "detail": "Vos pages d’analyse, la navigation et les liens restent exactement tels quels. Les pages de thèmes arrivent dans une mise à jour ultérieure ; cet interrupteur décide si vous les voyez.", "savedOn": "Aperçu des thèmes activé.", "savedOff": "Aperçu des thèmes désactivé." diff --git a/messages/it.json b/messages/it.json index 13d89eb9e..de5a63fa6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "Percorsi degli allenamenti", "description": "Conserva il tracciato GPS dei nuovi allenamenti dall’app per iPhone e dall’esportazione di Apple Salute.", - "detail": "Disattivato di default. Un percorso mostra dove vivi, lavori e ti alleni, quindi viene conservato solo dopo averlo attivato. Gli allenamenti vengono conservati comunque e nessuna funzione IA legge mai un percorso. Disattivandolo si fermano i nuovi percorsi; quelli già conservati restano finché non elimini l’allenamento.", + "detail": "Disattivato per i nuovi account. Un percorso mostra dove vivi, lavori e ti alleni, quindi viene conservato solo dopo averlo attivato. Gli allenamenti vengono conservati comunque e nessuna funzione IA legge mai un percorso. Disattivandolo si fermano i nuovi percorsi; quelli già conservati restano finché non elimini l’allenamento.", "loadError": "Impossibile caricare l’impostazione dei percorsi." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "Temi (beta)", - "description": "Aggiunge una voce Temi separata, con pagine di panoramica sopra le pagine delle singole metriche.", + "description": "Aggiungerà una voce Temi separata, con pagine di panoramica sopra le pagine delle singole metriche, appena saranno pronte.", "detail": "Le pagine delle analisi, la navigazione e i link restano esattamente come sono. Le pagine dei temi arrivano con un aggiornamento successivo; questo interruttore decide se le vedi.", "savedOn": "Anteprima dei temi attivata.", "savedOff": "Anteprima dei temi disattivata." diff --git a/messages/ko.json b/messages/ko.json index 4f02ccff3..f7a6044ca 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "운동 경로", "description": "iPhone 앱과 Apple 건강 내보내기에서 새 운동의 GPS 경로를 저장합니다.", - "detail": "기본값은 꺼짐입니다. 경로는 사는 곳, 일하는 곳, 운동하는 곳을 보여 주므로 이 설정을 켠 뒤에만 저장됩니다. 운동 기록은 항상 저장되며, 어떤 AI 기능도 경로를 읽지 않습니다. 끄면 새 경로 저장이 멈추고, 이미 저장된 경로는 운동을 삭제할 때까지 남습니다.", + "detail": "새 계정에서는 꺼져 있어요. 경로는 사는 곳, 일하는 곳, 운동하는 곳을 보여 주므로 이 설정을 켠 뒤에만 저장됩니다. 운동 기록은 항상 저장되며, 어떤 AI 기능도 경로를 읽지 않습니다. 끄면 새 경로 저장이 멈추고, 이미 저장된 경로는 운동을 삭제할 때까지 남습니다.", "loadError": "경로 설정을 불러오지 못했습니다." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "주제 (베타)", - "description": "개별 지표 페이지 위에 개요 페이지가 있는 별도의 주제 항목을 추가합니다.", + "description": "준비되면 개별 지표 페이지 위에 개요 페이지가 있는 별도의 주제 항목을 추가해요.", "detail": "인사이트 페이지, 메뉴, 링크는 지금 그대로 유지됩니다. 주제 페이지는 이후 업데이트에서 제공되며, 이 스위치로 표시 여부를 정합니다.", "savedOn": "주제 미리 보기를 켰습니다.", "savedOff": "주제 미리 보기를 껐습니다." diff --git a/messages/nl.json b/messages/nl.json index 5cf268024..962fed4d2 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "Trainingsroutes", "description": "Het GPS-spoor van nieuwe trainingen uit de iPhone-app en de Apple Gezondheid-export bewaren.", - "detail": "Standaard uit. Een route laat zien waar je woont, werkt en traint, dus hij wordt pas bewaard als je dit aanzet. Trainingen worden altijd bewaard en geen enkele AI-functie leest ooit een route. Uitzetten stopt nieuwe routes; al bewaarde routes blijven tot je de training verwijdert.", + "detail": "Uit voor nieuwe accounts. Een route laat zien waar je woont, werkt en traint, dus hij wordt pas bewaard als je dit aanzet. Trainingen worden altijd bewaard en geen enkele AI-functie leest ooit een route. Uitzetten stopt nieuwe routes; al bewaarde routes blijven tot je de training verwijdert.", "loadError": "De route-instelling kon niet worden geladen." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "Onderwerpen (bèta)", - "description": "Voegt een apart item Onderwerpen toe, met overzichtspagina’s boven de pagina’s per meting.", + "description": "Voegt een apart item Onderwerpen toe, met overzichtspagina’s boven de pagina’s per meting, zodra die klaar zijn.", "detail": "Je inzichtpagina’s, navigatie en links blijven precies zoals ze zijn. De onderwerppagina’s komen met een latere update; deze schakelaar bepaalt of je ze ziet.", "savedOn": "Preview van onderwerpen ingeschakeld.", "savedOff": "Preview van onderwerpen uitgeschakeld." diff --git a/messages/pl.json b/messages/pl.json index 318f838c0..5842eae66 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7222,7 +7222,7 @@ "workoutRoutes": { "title": "Trasy treningów", "description": "Zapisuj ślad GPS nowych treningów z aplikacji na iPhone’a i z eksportu Apple Zdrowie.", - "detail": "Domyślnie wyłączone. Trasa pokazuje, gdzie mieszkasz, pracujesz i trenujesz, więc zapisujemy ją dopiero po włączeniu. Treningi są zapisywane zawsze, a żadna funkcja AI nigdy nie odczytuje trasy. Wyłączenie zatrzymuje nowe trasy; już zapisane zostają do usunięcia treningu.", + "detail": "Wyłączone dla nowych kont. Trasa pokazuje, gdzie mieszkasz, pracujesz i trenujesz, więc zapisujemy ją dopiero po włączeniu. Treningi są zapisywane zawsze, a żadna funkcja AI nigdy nie odczytuje trasy. Wyłączenie zatrzymuje nowe trasy; już zapisane zostają do usunięcia treningu.", "loadError": "Nie udało się wczytać ustawienia tras." }, "retention": { @@ -7539,7 +7539,7 @@ "preview": { "topics": { "title": "Tematy (beta)", - "description": "Dodaje osobną pozycję Tematy ze stronami przeglądowymi nad stronami poszczególnych pomiarów.", + "description": "Doda osobną pozycję Tematy ze stronami przeglądowymi nad stronami poszczególnych pomiarów, gdy będą gotowe.", "detail": "Strony analiz, nawigacja i linki pozostają dokładnie takie, jakie są. Strony tematów pojawią się w późniejszej aktualizacji; ten przełącznik decyduje, czy je zobaczysz.", "savedOn": "Podgląd tematów włączony.", "savedOff": "Podgląd tematów wyłączony." diff --git a/src/__tests__/settings-copy-tense.test.ts b/src/__tests__/settings-copy-tense.test.ts new file mode 100644 index 000000000..31d44ea71 --- /dev/null +++ b/src/__tests__/settings-copy-tense.test.ts @@ -0,0 +1,29 @@ +/** + * Copy that promises something not yet shipped says so. + * + * The Topics preview switch exists before its pages do, so its description is + * in the future tense in every locale's source (English) and in German; and + * the workout-route switch is off for new accounts, which is what its detail + * says rather than "off by default": an account that already held tracks at + * the upgrade started with it on (migration 0434). + */ +import { describe, expect, it } from "vitest"; + +import en from "../../messages/en.json"; +import de from "../../messages/de.json"; + +describe("settings copy tense", () => { + it("describes the Topics preview as something it will add", () => { + expect(en.settings.preview.topics.description).toMatch(/^Will add /); + expect(de.settings.preview.topics.description).toMatch(/sobald/); + }); + + it("says workout routes are off for new accounts", () => { + expect(en.settings.privacy.workoutRoutes.detail).toMatch( + /^Off for new accounts\./, + ); + expect(de.settings.privacy.workoutRoutes.detail).toMatch( + /^Für neue Konten aus\./, + ); + }); +}); diff --git a/src/__tests__/settings-glossary-de.test.ts b/src/__tests__/settings-glossary-de.test.ts index 57f031d5d..f4cc6fb7b 100644 --- a/src/__tests__/settings-glossary-de.test.ts +++ b/src/__tests__/settings-glossary-de.test.ts @@ -53,3 +53,40 @@ describe("German Settings glossary", () => { expect(offenders).toEqual([]); }); }); + +describe("German wording settled in v1.44", () => { + const at = (path: string): string => + path + .split(".") + .reduce( + (node, key) => (node as Record | undefined)?.[key], + de, + ) as string; + + it("names the Tracking group with a German word", () => { + expect(at("settings.shell.groups.tracking")).toBe("Erfassung"); + }); + + it("points to another Settings page with 'Unter …' and returns to the Modules", () => { + expect(at("settings.sections.modules.backToHub")).toBe( + "Zurück zu den Modulen", + ); + expect(at("settings.moduleState.change")).toBe("Unter Module ändern"); + expect(at("settings.sections.modules.pages.openArrangement")).toBe( + "Unter Anordnung öffnen", + ); + expect(at("settings.sections.modules.manageIn")).toMatch(/^Unter /); + }); + + it("calls the Insights area Insights where a string names it", () => { + for (const key of [ + "measurements.openMetricInsights", + "medications.category.custom.bpGateNote", + "admin.aiQuality.title", + "recordSharing.invite.accessManageDetail", + ]) { + expect(at(key), key).not.toMatch(/Auswertung/); + expect(at(key), key).toMatch(/Insights/); + } + }); +}); From 59c3c3837939460c3bf893a11a96ff3570c1989c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:02:55 +0200 Subject: [PATCH 54/98] docs(ui-standards): public wording, one spacing scale, no fragment-redirect claim The UI standards drop working shorthand, state the spacing scale once in section 2 (section 13 had listed an older scale without 2.5 and 3.5) and use no em dashes. The v1.3 to v1.4 migration note no longer claims that /settings#anchor URLs redirect to their section: the fragment never reaches the server. A test holds both documents to this. --- docs/contributing/ui-standards.md | 156 ++++++++++++------------- docs/migration/v1.3-to-v1.4.md | 8 +- src/__tests__/ui-standards-doc.test.ts | 61 ++++++++++ 3 files changed, 144 insertions(+), 81 deletions(-) create mode 100644 src/__tests__/ui-standards-doc.test.ts diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index f2c982788..d0c0f576e 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -6,19 +6,19 @@ The binding reference for building or touching any surface of the web app. The r ## 1. Card anatomy -**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap** — never from slot padding. +**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap**, never from slot padding. - FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: ``. - Two densities, nothing else: - - `default` — Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`. - - `compact` — `` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card). + - `default`: Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`. + - `compact`: `` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card). - No `md:py-5` half-steps. - Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input). -- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile) — never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`). -- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes ``, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`) — the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it. +- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile), never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`). +- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes ``, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`); the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it. - **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card. -### Header patterns — exactly three, pick by surface +### Header patterns; exactly three, pick by surface | Surface | Primitive | Contract | | ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -26,15 +26,15 @@ The binding reference for building or touching any surface of the web app. The r | Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below | | Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot | -**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent — primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node — the primitive owns size and colour. +**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent; primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node; the primitive owns size and colour. -The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them — do not invent a fourth header language. +The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them; do not invent a fourth header language. ## 2. Spacing scale -> **Scroll height is shell-owned (over-scroll class, #154 lineage).** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
` — nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. +> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. -- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication-card rhythm uses them docblock-sanctioned across ~78 sites and the in-between steps earn their keep in dense card interiors — reality wins over a sweep that would only churn settled surfaces. `5` (20 px) and `py-5`, `p-5` are banned in new code, swept on touch. `7` (28 px) is banned with them — it entered as the Settings/Admin `pl-7` body gutter and is gone (§13). +- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13). - **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. - **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`. - **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding. @@ -46,71 +46,71 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm" The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers: - **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted. -- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader) — keep it; do not "fix" it to foreground. -- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `

` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run — it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it. +- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader); keep it; do not "fix" it to foreground. +- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `

` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run; it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it. - **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot. -- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1 — below AA; they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor). +- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1, below AA, and they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor). - **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node. -- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast — never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented. +- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast, never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented. - **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme. ## 4. Colour token rules - **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune. - **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values. -- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot — review for it explicitly until the rule covers it. +- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot; review for it explicitly until the rule covers it. - **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`). - Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`. ## 5. Typography scale -| Tier | Classes | Where | -| ------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------- | -| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader` — weight is **bold**, always | -| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` | -| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` | -| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters — never muted prose | -| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings | -| Body | `text-sm` (foreground) | default content | -| Meta | `text-xs text-muted-foreground` | timestamps, units, hints — the floor | -| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only | +| Tier | Classes | Where | +| ------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------- | +| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader`: weight is **bold**, always | +| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` | +| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` | +| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters: never muted prose | +| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings | +| Body | `text-sm` (foreground) | default content | +| Meta | `text-xs text-muted-foreground` | timestamps, units, hints: the floor | +| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only | No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling). -## 6. Component mapping — when to use which primitive +## 6. Component mapping; when to use which primitive -| Situation | Use | Never | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Page title + description + actions | `PageHeader` — `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) | -| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"` — one real `

`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `

` | -| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections | -| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` | -| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div | -| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box | -| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs | -| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it | -| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) | -| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` | -| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) | -| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset` — keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error | -| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}` — same decision as the other two | either of the above | -| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` | -| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) | -| Honest no-data state | `EmptyState` | reusing error copy — a failed query must never fall through to "no data yet" | -| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) | -| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | -| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`) — tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `

); diff --git a/src/components/settings/import-panel/record-csv-import-card.tsx b/src/components/settings/import-panel/record-csv-import-card.tsx new file mode 100644 index 000000000..2613f2b0a --- /dev/null +++ b/src/components/settings/import-panel/record-csv-import-card.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { Download, FileSpreadsheet, FlaskConical, Pill } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { apiFetch, ApiError } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + RECORD_IMPORT_TEMPLATES, + fieldsFor, + guessFormat, + guessMapping, + readHead, + type FieldKey, + type FileHead, + type RecordImportKind, +} from "@/lib/import/csv-mapping"; +import type { RecordImportResult } from "@/lib/validations/record-import"; + +import { ImportCardShell } from "./import-card-shell"; + +/** The head of the file the mapping screen reads: enough for six rows. */ +const HEAD_BYTES = 64 * 1024; +const MAX_FILE_BYTES = 16 * 1024 * 1024; + +type Delimiter = "comma" | "semicolon" | "tab"; + +function delimiterName(d: string): Delimiter { + return d === ";" ? "semicolon" : d === "\t" ? "tab" : "comma"; +} + +function downloadTemplate(kind: RecordImportKind) { + const blob = new Blob([RECORD_IMPORT_TEMPLATES[kind]], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-${kind}-template.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/** + * Settings > Export & Import: lab results and medications from a spreadsheet + * (#1215). The card opens a sheet that reads the head of the file, suggests + * the column for each field, lets the person correct every choice, checks + * the whole file on the server (nothing written) and then imports it. + */ +export function RecordCsvImportCard() { + const { t } = useTranslations(); + const [open, setOpen] = useState(null); + const [session, setSession] = useState(0); + return ( + +

{t("recordImport.detail")}

+ + {(["labs", "medications"] as const).map((kind) => ( + + ))} + + {open ? ( + !next && setOpen(null)} + /> + ) : null} +
+ ); +} + +function RecordCsvImportSheet({ + kind, + onOpenChange, +}: { + kind: RecordImportKind; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const fileId = useId(); + const [file, setFile] = useState(null); + const [head, setHead] = useState(null); + const [mapping, setMapping] = useState>>({}); + const [delimiter, setDelimiter] = useState("comma"); + const [decimal, setDecimal] = useState<"dot" | "comma">("dot"); + const [dateOrder, setDateOrder] = useState<"iso" | "dmy" | "mdy">("iso"); + const [header, setHeader] = useState(true); + const [reminders, setReminders] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [check, setCheck] = useState(null); + const [done, setDone] = useState(null); + + const fields = fieldsFor(kind); + const columns = useMemo(() => { + if (!head) return [] as string[]; + const width = Math.max(...head.rows.map((r) => r.length), 0); + return Array.from({ length: width }, (_, i) => + header && head.rows[0]?.[i]?.trim() + ? head.rows[0][i].trim() + : t("recordImport.column", { n: i + 1 }), + ); + }, [head, header, t]); + const missing = fields.filter( + (f) => f.required && mapping[f.key] === undefined, + ); + + async function onFile(e: React.ChangeEvent) { + const picked = e.target.files?.[0] ?? null; + e.target.value = ""; + setCheck(null); + setDone(null); + setError(null); + if (!picked) return; + if (picked.size > MAX_FILE_BYTES) { + setError(t("recordImport.error.tooLarge")); + return; + } + const text = await picked.slice(0, HEAD_BYTES).text(); + const parsedHead = readHead(text); + const guessed = guessMapping(parsedHead.rows[0] ?? [], kind); + const format = guessFormat( + parsedHead, + guessed[kind === "labs" ? "date" : "startsOn"], + ); + setFile(picked); + setHead(parsedHead); + setMapping(guessed); + setDelimiter(delimiterName(parsedHead.delimiter)); + setDecimal(format.decimal); + setDateOrder(format.dateOrder); + } + + async function send(dryRun: boolean) { + if (!file) return; + setBusy(true); + setError(null); + const params = new URLSearchParams({ + dryRun: dryRun ? "1" : "0", + header: header ? "1" : "0", + delimiter, + decimal, + dateOrder, + }); + if (kind === "medications") params.set("reminders", reminders ? "1" : "0"); + for (const f of fields) { + const index = mapping[f.key]; + if (index !== undefined) params.set(f.key, String(index)); + } + try { + const result = await apiFetch( + `/api/import/${kind}-csv?${params}`, + { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: file, + // A large file is read and written row by row; give it time. + signal: AbortSignal.timeout(5 * 60_000), + }, + ); + if (dryRun) setCheck(result); + else setDone(result); + } catch (err) { + const status = err instanceof ApiError ? err.status : 0; + setError( + status === 429 + ? t("recordImport.error.rateLimited") + : status === 413 + ? t("recordImport.error.tooLarge") + : status === 422 + ? t("recordImport.error.invalid") + : t("recordImport.error.failed"), + ); + } finally { + setBusy(false); + } + } + + const result = done ?? check; + + return ( + onOpenChange(false)} + > + {t("recordImport.close")} + + ) : ( +
+ + +
+ ) + } + > +
+
+ + + +
+ + {head ? ( + <> +
+ + {t("recordImport.mapping")} + + {fields.map((f) => ( +
+ + { + setCheck(null); + const v = e.target.value; + setMapping((m) => { + const next = { ...m }; + // One column feeds one field: taking it here frees it elsewhere. + for (const k of Object.keys(next) as FieldKey[]) { + if (v !== "" && next[k] === Number(v)) delete next[k]; + } + if (v === "") delete next[f.key]; + else next[f.key] = Number(v); + return next; + }); + }} + > + + {columns.map((name, i) => ( + + ))} + +
+ ))} +
+ +
+
+ + { + setCheck(null); + setDelimiter(e.target.value as Delimiter); + }} + > + {(["comma", "semicolon", "tab"] as const).map((d) => ( + + ))} + +
+
+ + { + setCheck(null); + setDecimal(e.target.value as "dot" | "comma"); + }} + > + + + +
+
+ + { + setCheck(null); + setDateOrder(e.target.value as "iso" | "dmy" | "mdy"); + }} + > + {(["iso", "dmy", "mdy"] as const).map((o) => ( + + ))} + +
+
+ +
+ + { + setCheck(null); + setHeader(v); + }} + /> +
+ {kind === "medications" ? ( +
+ + { + setCheck(null); + setReminders(v); + }} + /> +
+ ) : null} +

+ {t("recordImport.timezoneNote")} +

+ +
+ + + {head.rows.map((row, r) => ( + + {row.map((c, i) => ( + + ))} + + ))} + +
+ {c} +
+
+ + ) : null} + + {missing.length > 0 && head ? ( +

+ {t("recordImport.missing", { + fields: missing + .map((f) => t(`recordImport.field.${f.key}`)) + .join(", "), + })} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {result ? ( +
+

+ {done + ? t("recordImport.summaryDone", { + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + }) + : t("recordImport.summaryCheck", { + rows: fmt.integer(result.rows), + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + })} +

+ {result.markers && + (result.markers.created.length > 0 || + result.markers.matched.length > 0) ? ( +
+ {result.markers.matched.length > 0 ? ( +

+ + {t("recordImport.markersMatched")}{" "} + + {result.markers.matched + .map((m) => + m.name === m.marker + ? m.name + : `${m.name} (${m.marker})`, + ) + .join(", ")} +

+ ) : null} + {result.markers.created.length > 0 ? ( +

+ + {t("recordImport.markersCreated")}{" "} + + {result.markers.created.join(", ")} +

+ ) : null} +
+ ) : null} + {result.errors.length > 0 ? ( +
    + {result.errors.map((e) => ( +
  • + + {t("recordImport.line", { line: fmt.integer(e.line) })} + + + {t(`recordImport.reason.${e.reason}`)} + {e.field + ? ` (${t(`recordImport.field.${e.field}`)})` + : ""} + +
  • + ))} +
+ ) : null} + {result.errorsTruncated ? ( +

+ {t("recordImport.errorsTruncated")} +

+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/lib/import/__tests__/csv-mapping.test.ts b/src/lib/import/__tests__/csv-mapping.test.ts new file mode 100644 index 000000000..11c249c75 --- /dev/null +++ b/src/lib/import/__tests__/csv-mapping.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { + labsCsvQuerySchema, + medicationsCsvQuerySchema, +} from "@/lib/validations/record-import"; + +import { + RECORD_IMPORT_TEMPLATES, + guessFormat, + guessMapping, + readHead, +} from "../csv-mapping"; +import { normaliseLabRow } from "../labs-csv"; +import { normaliseMedicationRow } from "../medications-csv"; + +describe("guessMapping", () => { + it("reads English and German headers, required fields first", () => { + expect( + guessMapping( + ["Analyte", "Result", "Unit", "Date", "Reference range"], + "labs", + ), + ).toEqual({ + analyte: 0, + value: 1, + unit: 2, + date: 3, + referenceRange: 4, + }); + expect( + guessMapping( + ["Datum", "Laborwert", "Wert", "Einheit", "Referenzbereich"], + "labs", + ), + ).toMatchObject({ + date: 0, + analyte: 1, + value: 2, + unit: 3, + referenceRange: 4, + }); + expect( + guessMapping( + ["Medikament", "Dosis", "Uhrzeiten", "Bei Bedarf", "Beginn", "Ende"], + "medications", + ), + ).toEqual({ + name: 0, + dose: 1, + times: 2, + asNeeded: 3, + startsOn: 4, + endsOn: 5, + }); + }); + + it("offers a column to one field only and leaves unknown headers alone", () => { + const m = guessMapping(["name", "foo", "bar"], "labs"); + expect(m).toEqual({ analyte: 0 }); + }); +}); + +describe("guessFormat", () => { + it("sees a German file", () => { + const head = readHead("Analyt;Wert;Datum\nHbA1c;5,4;01.03.2025\n"); + expect(head.delimiter).toBe(";"); + expect(guessFormat(head, 2)).toEqual({ + decimal: "comma", + dateOrder: "dmy", + }); + }); + it("sees a US file by a day above twelve", () => { + const head = readHead("a,b,date\nx,5.4,03/15/2025\n"); + expect(guessFormat(head, 2)).toEqual({ decimal: "dot", dateOrder: "mdy" }); + }); + it("sees ISO", () => { + expect(guessFormat(readHead("a,date\nx,2025-03-01\n"), 1).dateOrder).toBe( + "iso", + ); + }); +}); + +describe("the templates", () => { + it("import cleanly with the mapping the screen suggests", () => { + const labs = readHead(RECORD_IMPORT_TEMPLATES.labs, 10); + const labMap = guessMapping(labs.rows[0], "labs"); + const labQuery = labsCsvQuerySchema.parse( + Object.fromEntries( + Object.entries(labMap).map(([k, v]) => [k, String(v)]), + ), + ); + for (const [i, fields] of labs.rows.slice(1).entries()) { + expect(normaliseLabRow({ line: i + 2, fields }, labQuery, "UTC").ok).toBe( + true, + ); + } + const meds = readHead(RECORD_IMPORT_TEMPLATES.medications, 10); + const medMap = guessMapping(meds.rows[0], "medications"); + const medQuery = medicationsCsvQuerySchema.parse( + Object.fromEntries( + Object.entries(medMap).map(([k, v]) => [k, String(v)]), + ), + ); + for (const [i, fields] of meds.rows.slice(1).entries()) { + expect( + normaliseMedicationRow({ line: i + 2, fields }, medQuery, "UTC").ok, + ).toBe(true); + } + }); +}); diff --git a/src/lib/import/csv-mapping.ts b/src/lib/import/csv-mapping.ts new file mode 100644 index 000000000..e8135729a --- /dev/null +++ b/src/lib/import/csv-mapping.ts @@ -0,0 +1,345 @@ +/** + * The client half of the CSV record imports (#1215): reading the head of a + * file for the mapping screen, suggesting which column feeds which field and + * how the file writes numbers and dates, and the downloadable templates. + * + * Suggestions only. The person sees every choice on the mapping screen and + * the server checks every row against what was chosen; nothing here decides + * a value. + */ +import { CsvTokenizer, type CsvDelimiter } from "@/lib/import/csv-stream"; + +export type RecordImportKind = "labs" | "medications"; + +export const LAB_FIELDS = [ + { key: "analyte", required: true }, + { key: "value", required: true }, + { key: "unit", required: false }, + { key: "date", required: true }, + { key: "time", required: false }, + { key: "referenceRange", required: false }, + { key: "referenceLow", required: false }, + { key: "referenceHigh", required: false }, + { key: "panel", required: false }, + { key: "note", required: false }, +] as const; + +export const MEDICATION_FIELDS = [ + { key: "name", required: true }, + { key: "dose", required: true }, + { key: "times", required: false }, + { key: "asNeeded", required: false }, + { key: "startsOn", required: false }, + { key: "endsOn", required: false }, +] as const; + +export type FieldKey = + | (typeof LAB_FIELDS)[number]["key"] + | (typeof MEDICATION_FIELDS)[number]["key"]; + +export function fieldsFor(kind: RecordImportKind) { + return kind === "labs" ? LAB_FIELDS : MEDICATION_FIELDS; +} + +/** Header words per field, in the languages HealthLog speaks, folded. */ +const SYNONYMS: Record = { + analyte: [ + "analyte", + "marker", + "biomarker", + "test", + "parameter", + "analyt", + "laborwert", + "wert name", + "name", + "prueba", + "analyse", + "esame", + "badanie", + ], + value: [ + "value", + "result", + "wert", + "ergebnis", + "resultado", + "valor", + "valeur", + "resultat", + "valore", + "risultato", + "wynik", + "waarde", + "uitslag", + "결과", + "값", + ], + unit: [ + "unit", + "units", + "einheit", + "unidad", + "unite", + "unita", + "jednostka", + "eenheid", + "단위", + ], + date: [ + "date", + "datum", + "fecha", + "data", + "day", + "tag", + "taken", + "taken at", + "collected", + "abnahme", + "날짜", + ], + time: [ + "time", + "uhrzeit", + "zeit", + "hora", + "heure", + "ora", + "godzina", + "tijd", + "시간", + ], + referenceRange: [ + "reference", + "reference range", + "range", + "referenz", + "referenzbereich", + "normbereich", + "norm", + "rango", + "reference interval", + "intervalle", + "riferimento", + "zakres", + "referentie", + ], + referenceLow: ["low", "min", "lower", "untergrenze", "von", "ref low"], + referenceHigh: ["high", "max", "upper", "obergrenze", "bis", "ref high"], + panel: ["panel", "group", "gruppe", "profil", "category", "kategorie"], + note: [ + "note", + "notes", + "comment", + "kommentar", + "notiz", + "bemerkung", + "nota", + "remarque", + "uwagi", + "opmerking", + ], + name: [ + "name", + "medication", + "medicine", + "drug", + "medikament", + "arznei", + "präparat", + "praparat", + "wirkstoff", + "medicamento", + "medicament", + "farmaco", + "lek", + "medicijn", + "약", + ], + dose: [ + "dose", + "dosage", + "dosis", + "dosierung", + "strength", + "stärke", + "starke", + "dosage", + "dawka", + "용량", + ], + times: [ + "times", + "time", + "schedule", + "uhrzeit", + "uhrzeiten", + "einnahme", + "zeiten", + "horario", + "heures", + "orari", + "godziny", + "tijden", + "시간", + ], + asNeeded: [ + "as needed", + "prn", + "bei bedarf", + "bedarf", + "a demanda", + "si besoin", + "al bisogno", + "w razie potrzeby", + "zo nodig", + "필요시", + ], + startsOn: [ + "start", + "starts", + "start date", + "begin", + "beginn", + "seit", + "from", + "von", + "inicio", + "debut", + "inizio", + "od", + "begin datum", + ], + endsOn: [ + "end", + "ends", + "end date", + "until", + "bis", + "ende", + "fin", + "fine", + "do", + "einddatum", + ], +}; + +function fold(text: string): string { + return text + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[_\-./()]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Suggest a column per field from the header names. Exact synonym first, then + * a header that starts with one; a column is offered to one field at most, + * and the required fields choose first. + */ +export function guessMapping( + headers: readonly string[], + kind: RecordImportKind, +): Partial> { + const folded = headers.map(fold); + const taken = new Set(); + const out: Partial> = {}; + const order = [...fieldsFor(kind)].sort( + (a, b) => Number(b.required) - Number(a.required), + ); + for (const pass of ["exact", "prefix"] as const) { + for (const { key } of order) { + if (out[key] !== undefined) continue; + const words = SYNONYMS[key]; + const index = folded.findIndex( + (h, i) => + !taken.has(i) && + words.some((w) => + pass === "exact" ? h === w : h.startsWith(`${w} `), + ), + ); + if (index >= 0) { + out[key] = index; + taken.add(index); + } + } + } + return out; +} + +export interface FileHead { + delimiter: CsvDelimiter; + rows: string[][]; +} + +/** The first `maxRows` records of a file's head text. */ +export function readHead(text: string, maxRows = 6): FileHead { + const tokenizer = new CsvTokenizer("auto"); + const rows: string[][] = []; + for (const record of [...tokenizer.push(text), ...tokenizer.end()]) { + rows.push(record.fields); + if (rows.length >= maxRows) break; + } + return { delimiter: tokenizer.detectedDelimiter ?? ",", rows }; +} + +/** + * How the sample writes numbers and dates. A semicolon file almost always + * writes a decimal comma; a sample cell like `5,4` says so outright. The date + * order follows the first date-shaped cell: ISO, then a first number above 12 + * (day first) or a second above 12 (month first), then the separator habit + * (`.` is day first, `/` alone is left to the person: it stays ISO-default + * `dmy` only when a dot is seen). + */ +export function guessFormat( + head: FileHead, + dateColumn: number | undefined, +): { decimal: "dot" | "comma"; dateOrder: "iso" | "dmy" | "mdy" } { + const data = head.rows.slice(1); + const cells = data.flat(); + const commaNumber = cells.some((c) => /^-?\d+,\d{1,2}$/.test(c.trim())); + const decimal = head.delimiter === ";" || commaNumber ? "comma" : "dot"; + + let dateOrder: "iso" | "dmy" | "mdy" = "iso"; + const dates = data + .map((r) => (dateColumn === undefined ? "" : (r[dateColumn] ?? "").trim())) + .filter(Boolean); + for (const d of dates) { + if (/^\d{4}-\d{2}-\d{2}/.test(d)) { + dateOrder = "iso"; + break; + } + const m = /^(\d{1,2})([./-])(\d{1,2})\2\d{4}/.exec(d); + if (!m) continue; + if (Number(m[1]) > 12) { + dateOrder = "dmy"; + break; + } + if (Number(m[3]) > 12) { + dateOrder = "mdy"; + break; + } + dateOrder = m[2] === "." ? "dmy" : "mdy"; + } + return { decimal, dateOrder }; +} + +export const RECORD_IMPORT_TEMPLATES: Record = { + labs: [ + "analyte,value,unit,date,time,reference range,panel,note", + "HbA1c,5.4,%,2025-03-01,08:15,4.0-5.6,Diabetes,", + "Ferritin,85,ng/mL,2025-03-01,08:15,30-400,Iron,", + "Urine protein,negative,,2025-03-01,,,Urine,", + "", + ].join("\n"), + medications: [ + "name,dose,times,as needed,start,end", + "Ramipril,5 mg,08:00,,2025-01-01,", + "Metformin,500 mg,08:00 20:00,,,", + "Ibuprofen,400 mg,,yes,,", + "", + ].join("\n"), +}; From 6320c54eddc514cf57aaf6209c6c061e55d21f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:27:20 +0200 Subject: [PATCH 59/98] feat(insights): look for level shifts in thirteen vitals over 180 days The health-status read scanned five vitals over 90 days for a dated, sustained level shift. It now scans every vital with a stable daily level (adding RMSSD, diastolic pressure, pulse, body fat, respiratory rate, oxygen saturation, walking heart rate and VO2 max) over 180 days, reads one value per day per metric, leaves a metric unread when its module is off, and reports how many days stand on each side of the break. The Coach keeps its five-metric, 90-day scan unchanged. --- docs/api/openapi.yaml | 10 ++ messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/nl.json | 3 +- messages/pl.json | 3 +- src/app/api/insights/health-status/route.ts | 11 +- .../insights/health-status-card.tsx | 6 + .../__tests__/changepoint-extended.test.ts | 105 ++++++++++++++++ src/lib/insights/derived/changepoint.ts | 118 ++++++++++++++++-- src/lib/insights/health-status.ts | 11 +- src/lib/openapi/routes/insights-signals.ts | 10 ++ .../integration/changepoint-extended.test.ts | 76 +++++++++++ 16 files changed, 347 insertions(+), 24 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/changepoint-extended.test.ts create mode 100644 tests/integration/changepoint-extended.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45452791c..bb3a494c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -66698,6 +66698,16 @@ components: enum: - up - down + beforeDays: + description: v1.44 — days with a value before the break. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + afterDays: + description: v1.44 — days with a value from the break on. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 required: - metric - breakDate diff --git a/messages/de.json b/messages/de.json index 1862f7fce..8e3f68622 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.", "rangeContext": "Aktuell {value}, sonst {low}–{high}.", "rangeContextTimeOfDay": "Aktuell {value}, zu dieser Tageszeit sonst {low}–{high}.", - "shiftContext": "Etwa {before} davor, {after} seitdem." + "shiftContext": "Etwa {before} davor, {after} seitdem.", + "shiftDays": "Grundlage: {before} Tage davor und {after} seither." }, "breathingScreening": { "sectionTitle": "Atmung im Schlaf", diff --git a/messages/en.json b/messages/en.json index 31c814e13..2b8e8848a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} has shifted down from your recent normal.", "rangeContext": "Now {value}, usually {low}–{high}.", "rangeContextTimeOfDay": "Now {value}, usually {low}–{high} at this time of day.", - "shiftContext": "Around {before} before, {after} since." + "shiftContext": "Around {before} before, {after} since.", + "shiftDays": "Based on {before} days before and {after} since." }, "breathingScreening": { "sectionTitle": "Breathing during sleep", diff --git a/messages/es.json b/messages/es.json index b8f865a17..b605c6b55 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} ha bajado respecto a tu normal reciente.", "rangeContext": "Ahora {value}, normalmente {low}–{high}.", "rangeContextTimeOfDay": "Ahora {value}, normalmente {low}–{high} a esta hora del día.", - "shiftContext": "Cerca de {before} antes, {after} desde entonces." + "shiftContext": "Cerca de {before} antes, {after} desde entonces.", + "shiftDays": "Con {before} días antes y {after} desde entonces." }, "breathingScreening": { "sectionTitle": "Respiración durante el sueño", diff --git a/messages/fr.json b/messages/fr.json index 52a6d0729..1a4ed1d79 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} a diminué par rapport à votre normale récente.", "rangeContext": "Actuellement {value}, habituellement {low}–{high}.", "rangeContextTimeOfDay": "Actuellement {value}, habituellement {low}–{high} à ce moment de la journée.", - "shiftContext": "Environ {before} avant, {after} depuis." + "shiftContext": "Environ {before} avant, {after} depuis.", + "shiftDays": "Sur {before} jours avant et {after} depuis." }, "breathingScreening": { "sectionTitle": "Respiration pendant le sommeil", diff --git a/messages/it.json b/messages/it.json index 69ab3a6be..3ade4fd02 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.", "rangeContext": "Ora {value}, di solito {low}–{high}.", "rangeContextTimeOfDay": "Ora {value}, di solito {low}–{high} a quest'ora del giorno.", - "shiftContext": "Circa {before} prima, {after} da allora." + "shiftContext": "Circa {before} prima, {after} da allora.", + "shiftDays": "Su {before} giorni prima e {after} da allora." }, "breathingScreening": { "sectionTitle": "Respirazione durante il sonno", diff --git a/messages/ko.json b/messages/ko.json index ffaa699a6..8009dce6e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital}: 최근 평소 수준보다 내려갔어요.", "rangeContext": "지금 {value}, 평소 {low}–{high}.", "rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.", - "shiftContext": "이전엔 약 {before}, 이후엔 {after}." + "shiftContext": "이전엔 약 {before}, 이후엔 {after}.", + "shiftDays": "이전 {before}일과 이후 {after}일 기준입니다." }, "breathingScreening": { "sectionTitle": "수면 중 호흡", diff --git a/messages/nl.json b/messages/nl.json index c66ceb99d..ec5cd2dd0 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} is gedaald ten opzichte van wat recent normaal was voor jou.", "rangeContext": "Nu {value}, meestal {low}–{high}.", "rangeContextTimeOfDay": "Nu {value}, op dit moment van de dag meestal {low}–{high}.", - "shiftContext": "Eerst rond {before}, sindsdien {after}." + "shiftContext": "Eerst rond {before}, sindsdien {after}.", + "shiftDays": "Op basis van {before} dagen ervoor en {after} sindsdien." }, "breathingScreening": { "sectionTitle": "Ademhaling tijdens de slaap", diff --git a/messages/pl.json b/messages/pl.json index a7e53374e..190ae53cf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.", "rangeContext": "Teraz {value}, zwykle {low}–{high}.", "rangeContextTimeOfDay": "Teraz {value}, zwykle {low}–{high} o tej porze dnia.", - "shiftContext": "Około {before} wcześniej, {after} od tego czasu." + "shiftContext": "Około {before} wcześniej, {after} od tego czasu.", + "shiftDays": "Na podstawie {before} dni wcześniej i {after} od tego czasu." }, "breathingScreening": { "sectionTitle": "Oddychanie podczas snu", diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts index 2b0bcd0be..601c472ee 100644 --- a/src/app/api/insights/health-status/route.ts +++ b/src/app/api/insights/health-status/route.ts @@ -23,7 +23,8 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; -import { buildChangepointSignals } from "@/lib/insights/derived/changepoint"; +import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { summariseHealthStatus } from "@/lib/insights/health-status"; export const dynamic = "force-dynamic"; @@ -45,7 +46,13 @@ export const GET = apiHandler(async () => { tz: user.timezone, now, }); - const shifts = await buildChangepointSignals(user.id, now); + // v1.44 — the wider scan: every vital with a stable daily level over 180 + // days, a metric whose module is off left unread. + const shifts = await buildExtendedChangepointSignals( + user.id, + now, + await resolveModuleMap(user.id), + ); const vitals = coincident.status === "ok" ? coincident.value.vitals : []; const summary = summariseHealthStatus(vitals, shifts); diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx index 87487f8f1..b598c23be 100644 --- a/src/components/insights/health-status-card.tsx +++ b/src/components/insights/health-status-card.tsx @@ -155,6 +155,12 @@ export function HealthStatusCard({ before: metricValue(s.metric, s.beforeMean), after: metricValue(s.metric, s.afterMean), })} + {s.beforeDays !== undefined && s.afterDays !== undefined + ? ` ${t("insights.healthStatus.shiftDays", { + before: s.beforeDays, + after: s.afterDays, + })}` + : null}

diff --git a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts new file mode 100644 index 000000000..edfcfe85b --- /dev/null +++ b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const readDayMeanSeries = vi.fn(); +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: (...args: unknown[]) => readDayMeanSeries(...args), +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn(async () => new Map()), +})); + +import { + buildChangepointSignals, + buildExtendedChangepointSignals, + CHANGEPOINT_WINDOW_DAYS, + EXTENDED_CHANGEPOINT_METRICS, + EXTENDED_CHANGEPOINT_WINDOW_DAYS, +} from "../changepoint"; +import { shiftDateKey } from "@/lib/tz/format"; + +const NOW = new Date("2026-10-21T08:00:00.000Z"); + +/** 20 days at `a`, then 20 at `b`, a day apart from 2026-06-01. */ +function step(a: number, b: number) { + return Array.from({ length: 40 }, (_, i) => ({ + day: shiftDateKey("2026-06-01", i), + mean: i < 20 ? a : b, + })); +} + +beforeEach(() => { + readDayMeanSeries.mockReset(); +}); + +describe("buildExtendedChangepointSignals", () => { + it("scans every extended metric over 180 days and reports segment sizes", async () => { + readDayMeanSeries.mockImplementation(async (_u: string, metric: string) => + metric === "RESPIRATORY_RATE" + ? { points: step(14, 17), source: "DAY" } + : { points: [], source: "DAY" }, + ); + const shifts = await buildExtendedChangepointSignals("u1", NOW, {}); + const read = readDayMeanSeries.mock.calls.map((c) => c[1]); + expect(read).toEqual([...EXTENDED_CHANGEPOINT_METRICS]); + for (const call of readDayMeanSeries.mock.calls) { + expect(call[2]).toBe(EXTENDED_CHANGEPOINT_WINDOW_DAYS); + } + expect(shifts).toEqual([ + expect.objectContaining({ + metric: "RESPIRATORY_RATE", + breakDate: "2026-06-21", + beforeMean: 14, + afterMean: 17, + direction: "up", + beforeDays: 20, + afterDays: 20, + }), + ]); + }); + + it("leaves a metric unread when its module is off", async () => { + readDayMeanSeries.mockResolvedValue({ points: [], source: "DAY" }); + await buildExtendedChangepointSignals("u1", NOW, { + recovery: false, + glucose: false, + }); + const read = readDayMeanSeries.mock.calls.map((c) => c[1]); + expect(read).not.toContain("HRV_RMSSD"); + expect(read).not.toContain("RESTING_HEART_RATE"); + expect(read).not.toContain("BLOOD_GLUCOSE"); + expect(read).toContain("WEIGHT"); + }); + + it("caps at five, strongest first", async () => { + let k = 0; + readDayMeanSeries.mockImplementation(async () => { + k += 1; + return { points: step(10, 10 + k), source: "DAY" }; + }); + const shifts = await buildExtendedChangepointSignals("u1", NOW, {}); + expect(shifts).toHaveLength(5); + const magnitudes = shifts.map((s) => s.magnitude); + expect([...magnitudes].sort((a, b) => b - a)).toEqual(magnitudes); + }); +}); + +describe("buildChangepointSignals (the Coach's scan)", () => { + it("keeps five metrics, 90 days and its shape", async () => { + readDayMeanSeries.mockResolvedValue({ + points: step(56, 63), + source: "DAY", + }); + const shifts = await buildChangepointSignals("u1", NOW); + expect(readDayMeanSeries).toHaveBeenCalledTimes(5); + expect(readDayMeanSeries.mock.calls[0][2]).toBe(CHANGEPOINT_WINDOW_DAYS); + expect(shifts).toHaveLength(2); + expect(Object.keys(shifts[0]).sort()).toEqual([ + "afterMean", + "beforeMean", + "breakDate", + "direction", + "magnitude", + "metric", + ]); + }); +}); diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index 47fc448f7..d7505c8c0 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -28,6 +28,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import type { SurfaceModuleMap } from "@/lib/modules/surface"; const MAD_TO_SIGMA = 1.4826; @@ -184,24 +186,60 @@ const MAX_CHANGEPOINTS = 2; const round1 = (n: number): number => Math.round(n * 10) / 10; /** - * Read the DAY-mean series for a small set of stable metrics and surface any - * validated level shift. Best-effort + fault-isolated per metric: a read - * failure on one metric never sinks the others. Returns the strongest - * changepoints (by magnitude), capped, or an empty array when nothing fires. + * v1.44 — the metrics the health-status surface scans: the five above plus + * every other vital with a stable daily level and enough readings to carry a + * 180-day window. Activity volumes, audio and sleep stay out: the first two are + * day-to-day behaviour rather than a level, and a sleep "day mean" over stage + * rows is not a night. Wrist temperature stays out for the same privacy reason + * it is not a correlation channel. */ -export async function buildChangepointSignals( - userId: string, - now: Date = new Date(), -): Promise { - const coverage = await probeRollupCoverage(userId); - const found: ChangepointSignal[] = []; +export const EXTENDED_CHANGEPOINT_METRICS: readonly MeasurementType[] = [ + ...CHANGEPOINT_METRICS, + "HRV_RMSSD", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "WALKING_HEART_RATE_AVERAGE", + "VO2_MAX", +]; - for (const metric of CHANGEPOINT_METRICS) { +/** Window of the extended scan (days). */ +export const EXTENDED_CHANGEPOINT_WINDOW_DAYS = 180; +/** At most this many shifts on the health-status surface. */ +const MAX_EXTENDED_CHANGEPOINTS = 5; + +/** A changepoint with the day counts of the two segments it compares. */ +export interface ExtendedChangepointSignal extends ChangepointSignal { + /** Days with a value before the break. */ + beforeDays: number; + /** Days with a value from the break on. */ + afterDays: number; +} + +/** + * Scan a metric set for one validated shift each, fault-isolated per metric, + * strongest first, capped. One per-day read per metric (DAY rollups, or the + * per-day SQL fold for a window past the DAY tier), never raw readings in + * process, so the cost is a row per day per metric whatever the density. + */ +async function scanChangepoints( + userId: string, + now: Date, + metrics: readonly MeasurementType[], + windowDays: number, + max: number, +): Promise { + const coverage = await probeRollupCoverage(userId); + const found: ExtendedChangepointSignal[] = []; + + for (const metric of metrics) { try { const { points } = await readDayMeanSeries( userId, metric, - CHANGEPOINT_WINDOW_DAYS, + windowDays, now, coverage, ); @@ -217,6 +255,8 @@ export async function buildChangepointSignals( afterMean: round1(shift.afterMean), direction: shift.direction, magnitude: round1(shift.magnitude), + beforeDays: shift.breakIndex + 1, + afterDays: points.length - shift.breakIndex - 1, }); } catch { // Fault-isolated — skip this metric, keep scanning. @@ -224,5 +264,57 @@ export async function buildChangepointSignals( } found.sort((a, b) => b.magnitude - a.magnitude); - return found.slice(0, MAX_CHANGEPOINTS); + return found.slice(0, max); +} + +/** + * v1.44 — the health-status scan: {@link EXTENDED_CHANGEPOINT_METRICS} over + * {@link EXTENDED_CHANGEPOINT_WINDOW_DAYS} days, a metric whose module is off + * left unread. The Coach keeps {@link buildChangepointSignals}: what reaches a + * model is not widened by this. + */ +export async function buildExtendedChangepointSignals( + userId: string, + now: Date, + modules: SurfaceModuleMap, +): Promise { + const metrics = EXTENDED_CHANGEPOINT_METRICS.filter((metric) => { + const owner = moduleForMeasurementType(metric); + return owner === null || modules[owner] !== false; + }); + return scanChangepoints( + userId, + now, + metrics, + EXTENDED_CHANGEPOINT_WINDOW_DAYS, + MAX_EXTENDED_CHANGEPOINTS, + ); +} + +/** + * Read the DAY-mean series for a small set of stable metrics and surface any + * validated level shift. Best-effort + fault-isolated per metric: a read + * failure on one metric never sinks the others. Returns the strongest + * changepoints (by magnitude), capped, or an empty array when nothing fires. + */ +export async function buildChangepointSignals( + userId: string, + now: Date = new Date(), +): Promise { + const found = await scanChangepoints( + userId, + now, + CHANGEPOINT_METRICS, + CHANGEPOINT_WINDOW_DAYS, + MAX_CHANGEPOINTS, + ); + // The Coach snapshot's shape is unchanged: no segment day counts. + return found.map((signal) => ({ + metric: signal.metric, + breakDate: signal.breakDate, + beforeMean: signal.beforeMean, + afterMean: signal.afterMean, + direction: signal.direction, + magnitude: signal.magnitude, + })); } diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts index 6cf8e718d..9279de218 100644 --- a/src/lib/insights/health-status.ts +++ b/src/lib/insights/health-status.ts @@ -46,6 +46,10 @@ export interface HealthStatusShift { beforeMean: number; afterMean: number; direction: "up" | "down"; + /** v1.44 — days with a value before the break, when the scan reports it. */ + beforeDays?: number; + /** v1.44 — days with a value from the break on. */ + afterDays?: number; } export interface HealthStatusSummary { @@ -62,7 +66,9 @@ export interface HealthStatusSummary { */ export function summariseHealthStatus( vitals: readonly VitalDeviation[], - shifts: readonly ChangepointSignal[], + shifts: ReadonlyArray< + ChangepointSignal & { beforeDays?: number; afterDays?: number } + >, ): HealthStatusSummary { const deviations: HealthStatusDeviation[] = vitals // Present tense, so present readings only. "Your pulse is above your usual @@ -88,6 +94,9 @@ export function summariseHealthStatus( beforeMean: s.beforeMean, afterMean: s.afterMean, direction: s.direction, + ...(s.beforeDays !== undefined && s.afterDays !== undefined + ? { beforeDays: s.beforeDays, afterDays: s.afterDays } + : {}), })); return { diff --git a/src/lib/openapi/routes/insights-signals.ts b/src/lib/openapi/routes/insights-signals.ts index b08885b3d..de79c40bb 100644 --- a/src/lib/openapi/routes/insights-signals.ts +++ b/src/lib/openapi/routes/insights-signals.ts @@ -44,6 +44,16 @@ const healthStatusResponse = z beforeMean: z.number(), afterMean: z.number(), direction: z.enum(["up", "down"]), + beforeDays: z + .number() + .int() + .optional() + .describe("v1.44 — days with a value before the break."), + afterDays: z + .number() + .int() + .optional() + .describe("v1.44 — days with a value from the break on."), }), ), generatedAt: z.string().describe("ISO-8601 instant the read was computed."), diff --git a/tests/integration/changepoint-extended.test.ts b/tests/integration/changepoint-extended.test.ts new file mode 100644 index 000000000..d369dc6b6 --- /dev/null +++ b/tests/integration/changepoint-extended.test.ts @@ -0,0 +1,76 @@ +/** + * v1.44 — the health-status changepoint scan over 180 days against a real + * Postgres. Past the DAY tier's 90-day reach the read folds per day in SQL + * (`readDayAggregates`); this proves that path finds a step, reports its + * segment sizes, and leaves a switched-off module's metric unread. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; + +function at(daysAgo: number): Date { + const d = new Date(Date.now() - daysAgo * DAY_MS); + d.setUTCHours(12, 0, 0, 0); + return d; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("extended changepoint scan — real per-day fold", () => { + it("finds a step in a metric past the five, over 120 days", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "cp-extended", + email: "cp-extended@example.test", + role: "USER", + timezone: "UTC", + }, + }); + const rows = []; + // 120 days ago to 61 days ago at 14, then 60 days at 17, two readings a day. + for (let d = 120; d >= 1; d -= 1) { + const value = d > 60 ? 14 : 17; + rows.push( + { value, measuredAt: at(d) }, + { value, measuredAt: new Date(at(d).getTime() + 3_600_000) }, + ); + rows.push({ + value: d > 60 ? 140 : 150, + measuredAt: at(d), + type: "BLOOD_GLUCOSE" as const, + unit: "mg/dL", + }); + } + await prisma.measurement.createMany({ + data: rows.map((r) => ({ + userId: user.id, + type: "RESPIRATORY_RATE" as const, + unit: "count/min", + source: "APPLE_HEALTH" as const, + ...r, + })), + }); + + const shifts = await buildExtendedChangepointSignals(user.id, new Date(), { + glucose: false, + }); + expect(shifts.map((s) => s.metric)).toEqual(["RESPIRATORY_RATE"]); + expect(shifts[0]).toMatchObject({ + beforeMean: 14, + afterMean: 17, + direction: "up", + beforeDays: 60, + afterDays: 60, + }); + }); +}); From 5f563623d7303fd32ebfc89088152e12943eb9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:34:26 +0200 Subject: [PATCH 60/98] fix(db): make the activity goals column migration idempotent --- prisma/migrations/0470_user_activity_goals/migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/migrations/0470_user_activity_goals/migration.sql b/prisma/migrations/0470_user_activity_goals/migration.sql index 49d25a9d1..a988680c1 100644 --- a/prisma/migrations/0470_user_activity_goals/migration.sql +++ b/prisma/migrations/0470_user_activity_goals/migration.sql @@ -6,4 +6,4 @@ -- -- Reversibility: dropping the column removes the goals; nothing else reads it. -ALTER TABLE "users" ADD COLUMN "activity_goals_json" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "activity_goals_json" JSONB; From e482f76508e964de4dfbb4596efd4af59839bcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:40:43 +0200 Subject: [PATCH 61/98] fix(labs): stop projecting lab trends from stale or short series A marker whose newest reading is more than a year old is now reported as of that reading (projection "stale"), with no exit date and no already-outside verdict. The projection horizon is capped at the span the readings cover, so a line is never carried further ahead than the data reaches back. The detail read keeps the newest 2000 readings rather than the oldest. --- docs/api/openapi.yaml | 13 ++++-- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/nl.json | 5 +- messages/pl.json | 5 +- src/app/api/biomarkers/[id]/route.ts | 4 +- .../labs/__tests__/lab-trend-card.test.tsx | 22 +++++++++ src/components/labs/lab-trend-card.tsx | 10 +++- src/components/labs/types.ts | 4 +- src/lib/labs/__tests__/lab-trend.test.ts | 46 +++++++++++++++++++ src/lib/labs/lab-trend.ts | 42 ++++++++++++++--- src/lib/openapi/routes/biomarkers.ts | 14 +++++- 16 files changed, 165 insertions(+), 30 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a0f523a40..0452e9bfc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -74758,7 +74758,11 @@ components: description: The line's value on `asOf`. asOf: type: string - description: YYYY-MM-DD the line was evaluated on. + description: "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`." + horizonYears: + type: number + description: "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when + shorter." projection: type: string enum: @@ -74768,6 +74772,7 @@ components: - already_outside - no_clear_trend - no_band + - stale exitDate: description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." type: @@ -74791,6 +74796,7 @@ components: - pValue - fittedNow - asOf + - horizonYears - projection - exitDate - exitBound @@ -74822,8 +74828,9 @@ components: description: "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the - line is inside the band today and reaches a bound within three years. Server-computed; clients render it as - given." + line is inside the band today and reaches a bound within `horizonYears` (three years, or the readings' span when + shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, + with no projection. Server-computed; clients render it as given." UpdateBiomarkerResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index 255887f9c..0bea7d105 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10207,10 +10207,11 @@ "high": "Liefe diese Gerade so weiter, erreichte sie um den {date} das obere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage.", "low": "Liefe diese Gerade so weiter, erreichte sie um den {date} das untere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage." }, - "beyond_horizon": "In diesem Tempo bleibt die Gerade noch mindestens drei Jahre in deinem Referenzbereich.", + "beyond_horizon": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit reichen deine Werte zurück.", "no_exit": "Die Gerade läuft von den Grenzen deines Referenzbereichs weg.", "already_outside": "Die Gerade liegt bereits außerhalb deines Referenzbereichs.", - "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben." + "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben.", + "stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion." }, "interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}." } diff --git a/messages/en.json b/messages/en.json index d5caa0a3c..6647f6d01 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10207,10 +10207,11 @@ "high": "If this straight line continued, it would reach the upper end of your reference range around {date}. That is a projection, not a forecast.", "low": "If this straight line continued, it would reach the lower end of your reference range around {date}. That is a projection, not a forecast." }, - "beyond_horizon": "At this pace the line stays inside your reference range for at least three more years.", + "beyond_horizon": "At this pace the line stays inside your reference range for more than {years} years, the span your readings cover.", "no_exit": "The line heads away from the edges of your reference range.", "already_outside": "The line already sits outside your reference range.", - "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn." + "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn.", + "stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection." }, "interval": "95 % interval of the yearly change: {lo} to {hi} {unit}." } diff --git a/messages/es.json b/messages/es.json index 067c6cc1d..dd7b71ab8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10207,10 +10207,11 @@ "high": "Si esta línea recta continuara, llegaría al límite superior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico.", "low": "Si esta línea recta continuara, llegaría al límite inferior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico." }, - "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia al menos tres años más.", + "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, el periodo que cubren tus valores.", "no_exit": "La línea se aleja de los límites de tu rango de referencia.", "already_outside": "La línea ya está fuera de tu rango de referencia.", - "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada." + "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada.", + "stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección." }, "interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}." } diff --git a/messages/fr.json b/messages/fr.json index 72bbb6ea3..9f408bbe0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10207,10 +10207,11 @@ "high": "Si cette droite se prolongeait, elle atteindrait la limite haute de ta plage de référence vers le {date}. C'est une projection, pas une prévision.", "low": "Si cette droite se prolongeait, elle atteindrait la limite basse de ta plage de référence vers le {date}. C'est une projection, pas une prévision." }, - "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence pendant au moins trois ans encore.", + "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, la durée couverte par tes valeurs.", "no_exit": "La droite s'éloigne des limites de ta plage de référence.", "already_outside": "La droite est déjà hors de ta plage de référence.", - "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée." + "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée.", + "stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection." }, "interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}." } diff --git a/messages/it.json b/messages/it.json index f18c92e05..140376896 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10207,10 +10207,11 @@ "high": "Se questa retta proseguisse, raggiungerebbe il limite superiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione.", "low": "Se questa retta proseguisse, raggiungerebbe il limite inferiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione." }, - "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per almeno altri tre anni.", + "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, il periodo coperto dai tuoi valori.", "no_exit": "La retta si allontana dai limiti del tuo intervallo di riferimento.", "already_outside": "La retta è già fuori dal tuo intervallo di riferimento.", - "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla." + "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla.", + "stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione." }, "interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}." } diff --git a/messages/ko.json b/messages/ko.json index afddfe2e2..b29dd3b6c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10207,10 +10207,11 @@ "high": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 상한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다.", "low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다." }, - "beyond_horizon": "이 속도라면 적어도 3년 동안은 참고 범위 안에 머뭅니다.", + "beyond_horizon": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 측정값이 다루는 기간입니다.", "no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.", "already_outside": "직선이 이미 참고 범위 밖에 있습니다.", - "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다." + "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다.", + "stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다." }, "interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}." } diff --git a/messages/nl.json b/messages/nl.json index 80005fc1f..ebef8b7d0 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10207,10 +10207,11 @@ "high": "Als deze rechte lijn zo doorliep, zou ze rond {date} de bovengrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling.", "low": "Als deze rechte lijn zo doorliep, zou ze rond {date} de ondergrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling." }, - "beyond_horizon": "In dit tempo blijft de lijn nog minstens drie jaar binnen je referentiebereik.", + "beyond_horizon": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, de periode die je waarden beslaan.", "no_exit": "De lijn loopt weg van de grenzen van je referentiebereik.", "already_outside": "De lijn ligt al buiten je referentiebereik.", - "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken." + "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken.", + "stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie." }, "interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}." } diff --git a/messages/pl.json b/messages/pl.json index ce08e9fbd..6a641519c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10207,10 +10207,11 @@ "high": "Gdyby ta prosta biegła dalej, osiągnęłaby górną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza.", "low": "Gdyby ta prosta biegła dalej, osiągnęłaby dolną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza." }, - "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym jeszcze co najmniej trzy lata.", + "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli okres objęty twoimi wynikami.", "no_exit": "Prosta oddala się od granic twojego zakresu referencyjnego.", "already_outside": "Prosta jest już poza twoim zakresem referencyjnym.", - "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja." + "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja.", + "stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji." }, "interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}." } diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index e47454e51..17f4b07ca 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -74,7 +74,9 @@ export const GET = apiHandler( deletedAt: null, value: { not: null }, }, - orderBy: { takenAt: "asc" }, + // Newest first, so a capped read keeps the recent end of the series + // (the staleness check reads the newest reading); the fit sorts. + orderBy: { takenAt: "desc" }, take: LAB_TREND_READ_CAP, select: { takenAt: true, value: true }, }), diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx index d5c3ae554..7757520f1 100644 --- a/src/components/labs/__tests__/lab-trend-card.test.tsx +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -20,6 +20,7 @@ const OK: BiomarkerTrendDto = { pValue: 0.02, fittedNow: 130, asOf: "2026-10-21", + horizonYears: 3, projection: "exits", exitDate: "2027-11-02", exitBound: "high", @@ -71,4 +72,25 @@ describe("", () => { ).toBe(""); expect(render(undefined)).toBe(""); }); + + it("words a stale trend as of its newest reading, and a span-bound horizon", () => { + const stale = render({ + ...OK, + asOf: "2026-01-10", + projection: "stale", + exitDate: null, + exitBound: null, + }); + expect(stale).toContain('data-projection="stale"'); + expect(stale).toContain("more than a year ago"); + expect(stale).not.toContain("forecast"); + const beyond = render({ + ...OK, + horizonYears: 1.5, + projection: "beyond_horizon", + exitDate: null, + exitBound: null, + }); + expect(beyond).toContain("for more than 1.5 years"); + }); }); diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx index 94b81f164..975518443 100644 --- a/src/components/labs/lab-trend-card.tsx +++ b/src/components/labs/lab-trend-card.tsx @@ -48,7 +48,15 @@ export function LabTrendCard({ }) : trend.projection === "no_band" ? null - : t(`labs.trend.projection.${trend.projection}`); + : trend.projection === "stale" + ? t("labs.trend.projection.stale", { + date: fmt.dateShortSmartCalendar(trend.lastAt), + }) + : trend.projection === "beyond_horizon" + ? t("labs.trend.projection.beyond_horizon", { + years: fmt.number(trend.horizonYears, 1), + }) + : t(`labs.trend.projection.${trend.projection}`); return ( diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index f4c976a74..783c3d3c1 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -125,13 +125,15 @@ export type BiomarkerTrendDto = pValue: number; fittedNow: number; asOf: string; + horizonYears: number; projection: | "exits" | "beyond_horizon" | "no_exit" | "already_outside" | "no_clear_trend" - | "no_band"; + | "no_band" + | "stale"; exitDate: string | null; exitBound: "low" | "high" | null; } diff --git a/src/lib/labs/__tests__/lab-trend.test.ts b/src/lib/labs/__tests__/lab-trend.test.ts index b4aeb3dfa..9a7591b63 100644 --- a/src/lib/labs/__tests__/lab-trend.test.ts +++ b/src/lib/labs/__tests__/lab-trend.test.ts @@ -128,4 +128,50 @@ describe("computeLabTrend", () => { computeLabTrend(falling, { low: 90, high: 140 }, atYears(2)), ).toMatchObject({ projection: "exits", exitBound: "low" }); }); + + it("never carries the line further ahead than the readings reach back", () => { + // 10 a year over one year (span 1.0); band top 125 sits 1.5 years past + // the last reading. Inside the three-year cap, outside the one-year span. + const short = readings([ + [0, 100], + [0.5, 105], + [1, 110], + ]); + expect( + computeLabTrend(short, { low: 70, high: 125 }, atYears(1)), + ).toMatchObject({ projection: "beyond_horizon", horizonYears: 1 }); + // The same line with a top of 118 meets it 0.8 years out: projected. + expect( + computeLabTrend(short, { low: 70, high: 118 }, atYears(1)), + ).toMatchObject({ projection: "exits", exitBound: "high" }); + }); + + it("reports a stale series as of its last reading, without a projection", () => { + const line = readings([ + [0, 100], + [1, 110], + [2, 120], + ]); + // Last reading two years before "now": evaluated on that day. + const stale = computeLabTrend(line, { low: 70, high: 125 }, atYears(4)); + expect(stale).toMatchObject({ + status: "ok", + projection: "stale", + exitDate: null, + exitBound: null, + fittedNow: 120, + slopePerYear: 10, + }); + if (stale.status !== "ok") throw new Error("expected ok"); + expect(stale.asOf).toBe(stale.lastAt); + // Carried to today the line would read 140, above the band: a stale + // series never says "already outside". + expect( + computeLabTrend(line, { low: 70, high: 115 }, atYears(4)), + ).toMatchObject({ projection: "stale" }); + // Eleven months after the last reading it is still current. + expect( + computeLabTrend(line, { low: 70, high: 200 }, atYears(2.9)), + ).toMatchObject({ projection: "beyond_horizon" }); + }); }); diff --git a/src/lib/labs/lab-trend.ts b/src/lib/labs/lab-trend.ts index f577c1f3d..c5781b80e 100644 --- a/src/lib/labs/lab-trend.ts +++ b/src/lib/labs/lab-trend.ts @@ -13,7 +13,14 @@ * The slope carries its 95 % confidence interval (Student t at n − 2 degrees * of freedom). A projection is offered only when that interval excludes zero, * the line is inside the band today and heads toward a bound it reaches - * within {@link LAB_PROJECTION_HORIZON_YEARS} years. It is a straight-line + * within the horizon: {@link LAB_PROJECTION_HORIZON_YEARS} years, or the span + * the readings cover when that is shorter, so the line is never carried + * further ahead than the data reaches back. + * + * A marker whose newest reading is older than {@link LAB_TREND_STALE_DAYS} + * days gets no projection at all (`stale`): the line is evaluated on the day + * of that reading and reported as of then, with no exit date and no + * "already outside" verdict about a today the readings say nothing about. It is a straight-line * extrapolation, labelled as one wherever it is shown, never a forecast of * the body and never a risk statement: lab values plateau, respond to * treatment and carry assay noise a line does not know about. With three @@ -29,6 +36,8 @@ import { dateOnlyKey } from "@/lib/tz/date-only"; export const LAB_TREND_MIN_VALUES = 3; export const LAB_TREND_MIN_SPAN_DAYS = 365; export const LAB_PROJECTION_HORIZON_YEARS = 3; +/** Newest reading older than this: the trend is reported as of that reading. */ +export const LAB_TREND_STALE_DAYS = 365; const DAY_MS = 86_400_000; const YEAR_DAYS = 365.25; @@ -45,7 +54,12 @@ export type LabTrendProjection = /** The slope's interval includes zero: no projection is drawn. */ | "no_clear_trend" /** The marker has no reference band to leave. */ - | "no_band"; + | "no_band" + /** + * The newest reading is older than {@link LAB_TREND_STALE_DAYS} days: the + * line is reported as of that reading (`asOf` = `lastAt`), not projected. + */ + | "stale"; export interface LabTrendOk { status: "ok"; @@ -62,7 +76,13 @@ export interface LabTrendOk { pValue: number; /** The line's value on `asOf`. */ fittedNow: number; + /** Today, or the newest reading's day when the trend is `stale`. */ asOf: string; + /** + * How far ahead a bound may be met and still count as `exits`: three years, + * or the readings' span when shorter. + */ + horizonYears: number; projection: LabTrendProjection; /** Set only for `exits`: the day the line meets `exitBound`. */ exitDate: string | null; @@ -136,14 +156,23 @@ export function computeLabTrend( : 0 : twoSidedPFromT(Math.abs(fit.slope / se), df); - const xNow = (now.getTime() - t0) / DAY_MS / YEAR_DAYS; + const tLast = points[n - 1].t; + const stale = now.getTime() - tLast > LAB_TREND_STALE_DAYS * DAY_MS; + // A stale series is read on its own last day, never carried to today. + const tAsOf = stale ? tLast : now.getTime(); + const xNow = (tAsOf - t0) / DAY_MS / YEAR_DAYS; const fittedNow = fit.intercept + fit.slope * xNow; + const horizonYears = Math.min( + LAB_PROJECTION_HORIZON_YEARS, + (tLast - t0) / DAY_MS / YEAR_DAYS, + ); let projection: LabTrendProjection; let exitDate: string | null = null; let exitBound: "low" | "high" | null = null; const hasBand = band.low != null || band.high != null; - if (!hasBand) projection = "no_band"; + if (stale) projection = "stale"; + else if (!hasBand) projection = "no_band"; else if (!(lo > 0 || hi < 0)) projection = "no_clear_trend"; else if ( (band.low != null && fittedNow < band.low) || @@ -156,7 +185,7 @@ export function computeLabTrend( else { const xCross = (bound - fit.intercept) / fit.slope; const yearsAhead = xCross - xNow; - if (yearsAhead > LAB_PROJECTION_HORIZON_YEARS) { + if (yearsAhead > horizonYears) { projection = "beyond_horizon"; } else { projection = "exits"; @@ -176,7 +205,8 @@ export function computeLabTrend( slopeCi95: [round(lo, 4), round(hi, 4)], pValue: round(pValue, 6), fittedNow: round(fittedNow, 4), - asOf: dayOf(now.getTime()), + asOf: dayOf(tAsOf), + horizonYears: round(horizonYears, 2), projection, exitDate, exitBound, diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 8de2be86d..b4568f1fe 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -93,7 +93,16 @@ const labTrend = z .describe("95 % interval of the slope (Student t at n − 2 df)."), pValue: z.number(), fittedNow: z.number().describe("The line's value on `asOf`."), - asOf: z.string().describe("YYYY-MM-DD the line was evaluated on."), + asOf: z + .string() + .describe( + "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`.", + ), + horizonYears: z + .number() + .describe( + "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when shorter.", + ), projection: z.enum([ "exits", "beyond_horizon", @@ -101,6 +110,7 @@ const labTrend = z "already_outside", "no_clear_trend", "no_band", + "stale", ]), exitDate: z .string() @@ -120,7 +130,7 @@ const labTrend = z .meta({ id: "BiomarkerTrend", description: - "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within three years. Server-computed; clients render it as given.", + "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within `horizonYears` (three years, or the readings' span when shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, with no projection. Server-computed; clients render it as given.", }); const biomarkerDetail = biomarkerRow From 89b30ebdee4f44b9ebc9d1efd996c2bc623a8c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:47:07 +0200 Subject: [PATCH 62/98] fix(activity): never rate today's partial day in the activity calendar Today is shown as in_progress instead of being placed against whole days, and a running period is compared over its completed days only: the total through yesterday against the usual rate times the days completed. The period total and goal progress still count today. The comparison gains completedDays and completedValue. --- docs/api/openapi.yaml | 28 +++++++-- messages/de.json | 7 ++- messages/en.json | 7 ++- messages/es.json | 7 ++- messages/fr.json | 7 ++- messages/it.json | 7 ++- messages/ko.json | 7 ++- messages/nl.json | 7 ++- messages/pl.json | 7 ++- .../__tests__/activity-calendar-card.test.tsx | 21 ++++++- .../activity/activity-calendar-card.tsx | 31 +++++++--- .../activity/__tests__/calendar-math.test.ts | 44 ++++++++++++- src/lib/activity/calendar-math.ts | 62 ++++++++++++++----- src/lib/validations/activity-calendar.ts | 25 +++++++- 14 files changed, 218 insertions(+), 49 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0452e9bfc..6a072be7d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -50159,6 +50159,7 @@ components: enum: - none - unrated + - in_progress - low - below - usual @@ -50166,12 +50167,14 @@ components: - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` - between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." activeMinutes: type: string enum: - none - unrated + - in_progress - low - below - usual @@ -50179,12 +50182,14 @@ components: - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` - between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." load: type: string enum: - none - unrated + - in_progress - low - below - usual @@ -50192,7 +50197,8 @@ components: - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` - between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." required: - steps - activeMinutes @@ -50412,6 +50418,17 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + completedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days of the period that have ended (today excluded). + completedValue: + description: "`value` over the completed days only. This, not `value`, is what `usualToDate` is compared with, so a + running period is never judged on today's partial day." + type: + - number + - "null" usual: description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data." @@ -50419,7 +50436,8 @@ components: - number - "null" usualToDate: - description: The usual figure for the days elapsed so far; equals `usual` once the period is over. + description: The usual figure for the completed days (today excluded); equals `usual` once the period is over, null + before the period's first day has ended. type: - number - "null" @@ -50430,6 +50448,8 @@ components: required: - value - daysWithData + - completedDays + - completedValue - usual - usualToDate - referencePeriods diff --git a/messages/de.json b/messages/de.json index 0bea7d105..e1083aa25 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13640,7 +13640,8 @@ "below": "Unter deinem Üblichen", "usual": "Etwa wie üblich", "above": "Über deinem Üblichen", - "high": "Weit über deinem Üblichen" + "high": "Weit über deinem Üblichen", + "in_progress": "Heute, noch nicht vorbei" }, "legend": "Verglichen mit deinen 12 Wochen davor:", "noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.", @@ -13650,7 +13651,9 @@ "workouts": "{value} Trainings", "load": "Durchschnittliche Trainingslast {value}", "usual": "Bis hierher üblich bei dir: {value}", - "noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen." + "noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen.", + "usualThroughYesterday": "Bis gestern {value}, bis dahin üblich bei dir {usual}", + "firstDay": "Der Vergleich mit deinem Üblichen kommt, sobald der erste Tag vorbei ist." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/en.json b/messages/en.json index 6647f6d01..a90ca8022 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13640,7 +13640,8 @@ "below": "Below your usual", "usual": "About your usual", "above": "Above your usual", - "high": "Far above your usual" + "high": "Far above your usual", + "in_progress": "Today, still in progress" }, "legend": "Compared with your 12 weeks before:", "noBaseline": "Your usual range shows after 14 days with data.", @@ -13650,7 +13651,9 @@ "workouts": "{value} workouts", "load": "Average training load {value}", "usual": "Your usual by this point: {value}", - "noUsual": "Not enough earlier weeks or months to compare with yet." + "noUsual": "Not enough earlier weeks or months to compare with yet.", + "usualThroughYesterday": "Through yesterday {value}, your usual by then {usual}", + "firstDay": "Compared with your usual once the first day is over." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/es.json b/messages/es.json index dd7b71ab8..032dbfaca 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13640,7 +13640,8 @@ "below": "Por debajo de lo habitual", "usual": "Como de costumbre", "above": "Por encima de lo habitual", - "high": "Muy por encima de lo habitual" + "high": "Muy por encima de lo habitual", + "in_progress": "Hoy, aún en curso" }, "legend": "Comparado con tus 12 semanas anteriores:", "noBaseline": "Tu rango habitual aparece tras 14 días con datos.", @@ -13650,7 +13651,9 @@ "workouts": "{value} entrenamientos", "load": "Carga de entrenamiento media {value}", "usual": "Lo habitual hasta este punto: {value}", - "noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar." + "noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar.", + "usualThroughYesterday": "Hasta ayer {value}, lo habitual para ti hasta entonces {usual}", + "firstDay": "Se compara con lo habitual cuando termine el primer día." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/fr.json b/messages/fr.json index 9f408bbe0..c41d73f87 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13640,7 +13640,8 @@ "below": "En dessous de ton habitude", "usual": "Comme d'habitude", "above": "Au-dessus de ton habitude", - "high": "Bien au-dessus de ton habitude" + "high": "Bien au-dessus de ton habitude", + "in_progress": "Aujourd’hui, en cours" }, "legend": "Comparé à tes 12 semaines précédentes :", "noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.", @@ -13650,7 +13651,9 @@ "workouts": "{value} entraînements", "load": "Charge d'entraînement moyenne {value}", "usual": "Ton habitude à ce stade : {value}", - "noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer." + "noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer.", + "usualThroughYesterday": "Jusqu’à hier {value}, ton habitude à ce stade {usual}", + "firstDay": "Comparé à ton habitude dès que le premier jour est terminé." }, "sportCount": "{sport} : {count}", "goal": { diff --git a/messages/it.json b/messages/it.json index 140376896..fa3fd0fac 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13640,7 +13640,8 @@ "below": "Sotto il tuo solito", "usual": "Come al solito", "above": "Sopra il tuo solito", - "high": "Molto sopra il tuo solito" + "high": "Molto sopra il tuo solito", + "in_progress": "Oggi, ancora in corso" }, "legend": "Rispetto alle tue 12 settimane precedenti:", "noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.", @@ -13650,7 +13651,9 @@ "workouts": "{value} allenamenti", "load": "Carico di allenamento medio {value}", "usual": "Il tuo solito fino a questo punto: {value}", - "noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto." + "noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto.", + "usualThroughYesterday": "Fino a ieri {value}, il tuo solito fino a quel punto {usual}", + "firstDay": "Il confronto con il tuo solito arriva quando il primo giorno è finito." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/ko.json b/messages/ko.json index b29dd3b6c..0777b5263 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13640,7 +13640,8 @@ "below": "평소보다 적음", "usual": "평소와 비슷함", "above": "평소보다 많음", - "high": "평소보다 훨씬 많음" + "high": "평소보다 훨씬 많음", + "in_progress": "오늘, 아직 진행 중" }, "legend": "이전 12주와 비교:", "noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.", @@ -13650,7 +13651,9 @@ "workouts": "운동 {value}회", "load": "평균 운동 부하 {value}", "usual": "이 시점까지의 평소 값: {value}", - "noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다." + "noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다.", + "usualThroughYesterday": "어제까지 {value}, 그때까지의 평소 {usual}", + "firstDay": "첫날이 끝나면 평소와 비교합니다." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/nl.json b/messages/nl.json index ebef8b7d0..ecd903c04 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13640,7 +13640,8 @@ "below": "Onder je gewone niveau", "usual": "Ongeveer zoals gewoonlijk", "above": "Boven je gewone niveau", - "high": "Ver boven je gewone niveau" + "high": "Ver boven je gewone niveau", + "in_progress": "Vandaag, nog bezig" }, "legend": "Vergeleken met je 12 weken daarvoor:", "noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.", @@ -13650,7 +13651,9 @@ "workouts": "{value} trainingen", "load": "Gemiddelde trainingsbelasting {value}", "usual": "Gebruikelijk tot dit punt: {value}", - "noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken." + "noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken.", + "usualThroughYesterday": "Tot en met gisteren {value}, je gebruikelijke tot dan {usual}", + "firstDay": "Vergeleken met je gebruikelijke zodra de eerste dag voorbij is." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/pl.json b/messages/pl.json index 6a641519c..fd13d2ec0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13640,7 +13640,8 @@ "below": "Poniżej twojej normy", "usual": "Mniej więcej jak zwykle", "above": "Powyżej twojej normy", - "high": "Znacznie powyżej twojej normy" + "high": "Znacznie powyżej twojej normy", + "in_progress": "Dziś, jeszcze trwa" }, "legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:", "noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.", @@ -13650,7 +13651,9 @@ "workouts": "Treningi: {value}", "load": "Średnie obciążenie treningowe {value}", "usual": "Twoja norma do tego momentu: {value}", - "noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania." + "noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania.", + "usualThroughYesterday": "Do wczoraj {value}, twoja zwykła wartość do tego dnia {usual}", + "firstDay": "Porównanie ze zwykłą wartością pojawi się po zakończeniu pierwszego dnia." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/src/components/activity/__tests__/activity-calendar-card.test.tsx b/src/components/activity/__tests__/activity-calendar-card.test.tsx index a005c664f..d9c991492 100644 --- a/src/components/activity/__tests__/activity-calendar-card.test.tsx +++ b/src/components/activity/__tests__/activity-calendar-card.test.tsx @@ -58,6 +58,16 @@ function calendar() { load: null, }); } + // This morning: a few hundred steps so far. + input.push({ + date: today, + steps: 400, + exerciseMinutes: null, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + }); input.push({ date: "2026-10-05", steps: 20000, @@ -113,10 +123,19 @@ describe("ActivityCalendarCard", () => { state.data = calendar(); const html = render(); expect(html).toContain('data-metric="steps"'); - expect(html).toContain("Your usual by this point:"); + // A running month is compared through yesterday, not on today. + expect(html).toContain("Through yesterday"); + expect(html).toContain("your usual by then"); expect(html).toContain("Running: 1"); }); + it("marks today as in progress rather than rating it", () => { + state.data = calendar(); + const html = render(); + expect(html).toMatch(/data-date="2026-10-21" data-level="in_progress"/); + expect(html).toContain("Today, still in progress"); + }); + it("shows goal progress and a week tick column", () => { state.data = calendar(); const html = render(); diff --git a/src/components/activity/activity-calendar-card.tsx b/src/components/activity/activity-calendar-card.tsx index 7e9eef083..f941dcae2 100644 --- a/src/components/activity/activity-calendar-card.tsx +++ b/src/components/activity/activity-calendar-card.tsx @@ -41,6 +41,7 @@ type DayLevel = CalendarDay["level"]["steps"]; const LEVEL_BAR: Record = { none: "bg-transparent", unrated: "bg-muted-foreground/60", + in_progress: "bg-muted-foreground/30", low: "bg-chart-2/20", below: "bg-chart-2/40", usual: "bg-chart-2/60", @@ -412,10 +413,13 @@ export function ActivityCalendarCard() { .filter((m) => data.available[m]) .map((m) => { const s = data.summary[m]; + // Compared over completed days only: today has not ended. const delta = - s.value !== null && s.usualToDate - ? (s.value - s.usualToDate) / s.usualToDate + s.completedValue !== null && s.usualToDate + ? (s.completedValue - s.usualToDate) / s.usualToDate : null; + const running = + data.today >= data.start && data.today <= data.end; return (
  • - {s.usualToDate === null - ? t("activityCalendar.summary.noUsual") - : t("activityCalendar.summary.usual", { - value: formatMetric(m, s.usualToDate), - })} + {s.usual !== null && s.completedDays === 0 + ? t("activityCalendar.summary.firstDay") + : s.usualToDate === null + ? t("activityCalendar.summary.noUsual") + : running + ? t( + "activityCalendar.summary.usualThroughYesterday", + { + value: + s.completedValue === null + ? "–" + : formatMetric(m, s.completedValue), + usual: formatMetric(m, s.usualToDate), + }, + ) + : t("activityCalendar.summary.usual", { + value: formatMetric(m, s.usualToDate), + })}

    {delta !== null ? ( diff --git a/src/lib/activity/__tests__/calendar-math.test.ts b/src/lib/activity/__tests__/calendar-math.test.ts index 816cec6ab..1967237af 100644 --- a/src/lib/activity/__tests__/calendar-math.test.ts +++ b/src/lib/activity/__tests__/calendar-math.test.ts @@ -143,7 +143,7 @@ describe("period against the usual period", () => { expect(result.referencePeriods).toBe(12); }); - it("pro-rates the usual for a period still running", () => { + it("compares a running period over its completed days, never today's partial day", () => { const bounds = periodBounds("week", "2026-10-21"); const values = new Map(); for (let i = 1; i <= 84; i++) @@ -155,9 +155,27 @@ describe("period against the usual period", () => { values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null), mode: "sum", }); + // The total shown counts today; the comparison stops at yesterday. expect(result.value).toBe(6000); expect(result.usual).toBe(7000); - expect(result.usualToDate).toBe(3000); + expect(result.completedDays).toBe(2); + expect(result.completedValue).toBe(4000); + expect(result.usualToDate).toBe(2000); + }); + + it("has nothing to compare on the first day of a period", () => { + const bounds = periodBounds("week", "2026-10-19"); + const result = comparePeriod({ + bounds, + today: "2026-10-19", // Monday + valueOf: (d) => (d <= "2026-10-19" ? 1000 : null), + mode: "sum", + }); + expect(result.value).toBe(1000); + expect(result.usual).toBe(7000); + expect(result.completedDays).toBe(0); + expect(result.completedValue).toBeNull(); + expect(result.usualToDate).toBeNull(); }); it("compares months per day, so February meets March fairly", () => { @@ -258,6 +276,28 @@ describe("the assembled calendar", () => { ).toBe("none"); }); + it("shows today as in progress, never rated against whole days", () => { + const morning = buildActivityCalendar({ + bounds, + today, + input: [...input, day(today, { steps: 800 })], + goals: NO_ACTIVITY_GOALS, + }); + const todayCell = morning.days.find((d) => d.date === today); + expect(todayCell?.steps).toBe(800); + expect(todayCell?.level.steps).toBe("in_progress"); + expect(todayCell?.level.activeMinutes).toBe("in_progress"); + expect(todayCell?.level.load).toBe("none"); + // Yesterday, with the same value, is rated. + const yesterday = buildActivityCalendar({ + bounds, + today, + input: [...input, day("2026-10-20", { steps: 800 })], + goals: NO_ACTIVITY_GOALS, + }).days.find((d) => d.date === "2026-10-20"); + expect(yesterday?.level.steps).toBe("low"); + }); + it("counts workouts by sport in the period", () => { expect(calendar.sports).toEqual([ { sportType: "cycling", count: 1 }, diff --git a/src/lib/activity/calendar-math.ts b/src/lib/activity/calendar-math.ts index 6f4a5e225..6a6f821ab 100644 --- a/src/lib/activity/calendar-math.ts +++ b/src/lib/activity/calendar-math.ts @@ -11,14 +11,17 @@ * above the 90th `high`, everything between `usual`. With fewer than * `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`: * there is not enough history to say what usual is, and the calendar says - * so instead of guessing. + * so instead of guessing. TODAY is `in_progress`, never rated: a day + * that has not ended would read as a low day every morning. * - A PERIOD total is placed against the median total of the same kind of * period before it: the twelve weeks before a week, the six months before * a month. A month is compared per day (months differ in length), so the * usual month is the median daily rate of the earlier months times this - * month's length. A period still running is compared with the usual rate - * times the days elapsed so far, not with a full period it cannot have - * reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data + * month's length. A period still running is compared over its COMPLETED + * days only: the total through yesterday against the usual rate times the + * days completed, never a full period it cannot have reached yet, and + * never today's partial day (a morning would always read as a shortfall). + * The period total shown and the goals still count today. At least `MIN_REFERENCE_PERIODS` earlier periods with data * are needed; fewer gives no usual. * * What a day's value is: @@ -62,7 +65,14 @@ export const MIN_REFERENCE_PERIODS = 3; export type ActivityPeriod = "week" | "month"; export type RatedMetric = "steps" | "activeMinutes" | "load"; export type DayLevel = - "none" | "unrated" | "low" | "below" | "usual" | "above" | "high"; + | "none" + | "unrated" + | "in_progress" + | "low" + | "below" + | "usual" + | "above" + | "high"; /** One local day of raw signals, as the reader assembles it. */ export interface ActivityDayInput { @@ -237,13 +247,17 @@ export function dayRange(from: string, to: string): string[] { } export interface PeriodComparison { - /** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */ + /** The period's total (steps, active minutes, workouts) or mean (load), over the days so far, today included. */ value: number | null; /** Days of the period that carry a value. */ daysWithData: number; + /** Days of the period that have ended (today excluded). */ + completedDays: number; + /** `value` over the completed days only: what `usualToDate` is compared with. */ + completedValue: number | null; /** The usual full period, or null when there is not enough history. */ usual: number | null; - /** The usual figure for the days elapsed so far (equals `usual` for a finished period). */ + /** The usual figure for the completed days (equals `usual` for a finished period); null before the first day has ended. */ usualToDate: number | null; /** Earlier periods the usual was computed from. */ referencePeriods: number; @@ -293,15 +307,21 @@ export function comparePeriod(input: { }): PeriodComparison { const { bounds, today, valueOf, mode } = input; const lastCounted = bounds.end < today ? bounds.end : today; + const yesterday = shiftDateKey(today, -1); + const lastCompleted = bounds.end < yesterday ? bounds.end : yesterday; const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1; - const elapsed = - lastCounted < bounds.start - ? 0 - : daysBetweenDateKeys(bounds.start, lastCounted) + 1; + const daysUpTo = (last: string) => + last < bounds.start ? 0 : daysBetweenDateKeys(bounds.start, last) + 1; + const elapsed = daysUpTo(lastCounted); + const completed = daysUpTo(lastCompleted); const current = elapsed === 0 ? { value: null, n: 0 } : aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode); + const done = + completed === 0 + ? { value: null, n: 0 } + : aggregate(dayRange(bounds.start, lastCompleted).map(valueOf), mode); // Each earlier period becomes a per-day rate (sum) or a mean (mean), so a // 28-day February and a 31-day March compare on the same footing. @@ -319,13 +339,15 @@ export function comparePeriod(input: { usualRate === null ? null : mode === "sum" - ? usualRate * elapsed + ? usualRate * completed : usualRate; return { value: current.value, daysWithData: current.n, + completedDays: completed, + completedValue: done.value, usual, - usualToDate: elapsed === 0 ? null : usualToDate, + usualToDate: completed === 0 ? null : usualToDate, referencePeriods: rates.length, }; } @@ -477,6 +499,14 @@ export function buildActivityCalendar(args: { ? null : activeMinutesOf(day, hasActivityOn(date)); const load = future ? null : day.load; + // Today has not ended: shown, never rated against whole days. + const levelOf = ( + value: number | null, + stats: BaselineStats | null, + ): DayLevel => + date === today && value !== null + ? "in_progress" + : dayLevel(value, stats); return { date, inPeriod: date >= bounds.start && date <= bounds.end, @@ -487,9 +517,9 @@ export function buildActivityCalendar(args: { workoutsBySport: future ? {} : day.workoutsBySport, load, level: { - steps: dayLevel(steps, baseline.steps), - activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes), - load: dayLevel(load, baseline.load), + steps: levelOf(steps, baseline.steps), + activeMinutes: levelOf(activeMinutes, baseline.activeMinutes), + load: levelOf(load, baseline.load), }, }; }, diff --git a/src/lib/validations/activity-calendar.ts b/src/lib/validations/activity-calendar.ts index 75ba90c65..0aa7d8155 100644 --- a/src/lib/validations/activity-calendar.ts +++ b/src/lib/validations/activity-calendar.ts @@ -21,9 +21,18 @@ export type ActivityCalendarQuery = z.infer; const dateKey = z.iso.date(); const dayLevelEnum = z - .enum(["none", "unrated", "low", "below", "usual", "above", "high"]) + .enum([ + "none", + "unrated", + "in_progress", + "low", + "below", + "usual", + "above", + "high", + ]) .describe( - "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.", + "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; `in_progress` = today, which has not ended and is never rated.", ); const baselineStatsSchema = z @@ -50,6 +59,16 @@ const periodComparisonSchema = z "The period so far: a total (steps, active minutes, workouts) or a mean (load).", ), daysWithData: z.number().int(), + completedDays: z + .number() + .int() + .describe("Days of the period that have ended (today excluded)."), + completedValue: z + .number() + .nullable() + .describe( + "`value` over the completed days only. This, not `value`, is what `usualToDate` is compared with, so a running period is never judged on today's partial day.", + ), usual: z .number() .nullable() @@ -60,7 +79,7 @@ const periodComparisonSchema = z .number() .nullable() .describe( - "The usual figure for the days elapsed so far; equals `usual` once the period is over.", + "The usual figure for the completed days (today excluded); equals `usual` once the period is over, null before the period's first day has ended.", ), referencePeriods: z.number().int(), }) From e696d6e5265fedd6bb83488f9feb8061c8796905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:51:06 +0200 Subject: [PATCH 63/98] test(e2e): match the activity calendar on data attributes, not wording The card carries data-period and each goal row and goal field a data-goal-key, so the spec no longer depends on the English labels. --- e2e/activity-calendar.spec.ts | 26 +++++++++---------- .../__tests__/activity-calendar-card.test.tsx | 7 +++++ .../activity/activity-calendar-card.tsx | 8 +++++- .../activity/activity-goals-sheet.tsx | 1 + 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/e2e/activity-calendar.spec.ts b/e2e/activity-calendar.spec.ts index 3cfae6517..ef6b547ce 100644 --- a/e2e/activity-calendar.spec.ts +++ b/e2e/activity-calendar.spec.ts @@ -32,7 +32,9 @@ test.describe("activity calendar", () => { await page.locator('[data-slot="activity-calendar-previous"]').click(); await expect(label).not.toHaveText(before ?? ""); - await card.getByRole("radio", { name: "Week" }).click(); + await expect(card).toHaveAttribute("data-period", "month"); + await page.locator('[data-slot="activity-calendar-period-week"]').click(); + await expect(card).toHaveAttribute("data-period", "week"); if ((await days.count()) > 0) await expect(days).toHaveCount(7); // Nothing in the card pushes the page sideways on a phone. @@ -47,22 +49,20 @@ test.describe("activity calendar", () => { test("saves a goal and shows its progress", async ({ page }) => { await page.goto("/insights/workouts"); await page.locator('[data-slot="activity-calendar-set-goals"]').click(); - await page.getByLabel("Steps per month").fill("150000"); + const field = page.locator( + '[data-slot="activity-goals-form"] [data-goal-key="stepsPerMonth"]', + ); + const goal = page.locator( + '[data-slot="activity-calendar-goal"][data-goal-key="stepsPerMonth"]', + ); + await field.fill("150000"); await page.locator('[data-slot="activity-goals-save"]').click(); - await expect( - page - .locator('[data-slot="activity-calendar-goals"]') - .getByText("Steps per month"), - ).toBeVisible(); + await expect(goal).toBeVisible(); // Clear it again so the account is left as it was found. await page.locator('[data-slot="activity-calendar-set-goals"]').click(); - await page.getByLabel("Steps per month").fill(""); + await field.fill(""); await page.locator('[data-slot="activity-goals-save"]').click(); - await expect( - page - .locator('[data-slot="activity-calendar-goals"]') - .getByText("Steps per month"), - ).toHaveCount(0); + await expect(goal).toHaveCount(0); }); }); diff --git a/src/components/activity/__tests__/activity-calendar-card.test.tsx b/src/components/activity/__tests__/activity-calendar-card.test.tsx index d9c991492..f56ff213b 100644 --- a/src/components/activity/__tests__/activity-calendar-card.test.tsx +++ b/src/components/activity/__tests__/activity-calendar-card.test.tsx @@ -140,6 +140,13 @@ describe("ActivityCalendarCard", () => { state.data = calendar(); const html = render(); expect(html).toContain("Steps per month"); + // Stable hooks for the end-to-end spec, independent of the wording. + expect(html).toContain( + 'data-slot="activity-calendar-goal" data-goal-key="stepsPerMonth"', + ); + expect(html).toMatch( + /data-slot="activity-calendar"[^>]*data-period="month"/, + ); expect(html).toContain( 'data-slot="activity-calendar-week-goal" data-met="true"', ); diff --git a/src/components/activity/activity-calendar-card.tsx b/src/components/activity/activity-calendar-card.tsx index f941dcae2..a07960a0b 100644 --- a/src/components/activity/activity-calendar-card.tsx +++ b/src/components/activity/activity-calendar-card.tsx @@ -173,6 +173,7 @@ export function ActivityCalendarCard() { @@ -483,7 +484,12 @@ export function ActivityCalendarCard() {
    {data.goalProgress.map((g) => ( -
    +
    {t(`activityCalendar.goal.${key}`)} Date: Wed, 21 Oct 2026 23:03:03 +0200 Subject: [PATCH 64/98] fix(insights): keep plateaus and drifts out of the level-shift scan Each scanned vital gets a minimum level change and a spread floor at about its clinical and device resolution, so a VO2 max estimate that steps by 0.3 or a clean 2 bpm change no longer reads as a shift. A series a straight line explains at least as well as a step is a drift, not a dated shift. The magnitude is capped at 10 so the flattest series cannot win the ranking. --- .../__tests__/changepoint-extended.test.ts | 92 +++++++++++++++++ src/lib/insights/derived/changepoint.ts | 98 ++++++++++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts index edfcfe85b..f5314f533 100644 --- a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts +++ b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts @@ -11,7 +11,10 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ import { buildChangepointSignals, buildExtendedChangepointSignals, + CHANGEPOINT_FLOORS, CHANGEPOINT_WINDOW_DAYS, + detectLevelShift, + MAX_MAGNITUDE, EXTENDED_CHANGEPOINT_METRICS, EXTENDED_CHANGEPOINT_WINDOW_DAYS, } from "../changepoint"; @@ -103,3 +106,92 @@ describe("buildChangepointSignals (the Coach's scan)", () => { ]); }); }); + +/** One metric's series, every other metric empty. */ +function only(metric: string, means: number[]) { + readDayMeanSeries.mockImplementation(async (_u: string, m: string) => + m === metric + ? { + points: means.map((mean, i) => ({ + day: shiftDateKey("2026-04-01", i), + mean, + })), + source: "DAY", + } + : { points: [], source: "DAY" }, + ); +} + +describe("false-positive floors", () => { + it("gives every extended metric a floor", () => { + for (const metric of EXTENDED_CHANGEPOINT_METRICS) { + expect(CHANGEPOINT_FLOORS[metric], metric).toBeDefined(); + } + }); + + it("does not call a VO2 max estimate stepping by 0.3 a shift", async () => { + // 60 days at 40.0, 60 at 40.3: each plateau flat, so the raw detector + // sees a near-infinite magnitude. + const plateau = [ + ...Array(60).fill(40), + ...Array(60).fill(40.3), + ]; + expect(detectLevelShift(plateau)).not.toBeNull(); + only("VO2_MAX", plateau); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([]); + // A real change in the estimate still fires. + only("VO2_MAX", [ + ...Array(60).fill(40), + ...Array(60).fill(43), + ]); + // Measured against the 0.5 resolution floor, not a spread of zero: + // 3 / 0.5 = 6 σ, not the cap. + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([ + expect.objectContaining({ + metric: "VO2_MAX", + direction: "up", + magnitude: 6, + }), + ]); + }); + + it("needs a change worth noting, not just a clean one", async () => { + // Resting heart rate 60 → 62: clean, but under the 3 bpm floor. + only("RESTING_HEART_RATE", [ + ...Array(60).fill(60), + ...Array(60).fill(62), + ]); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([]); + only("RESTING_HEART_RATE", [ + ...Array(60).fill(60), + ...Array(60).fill(64), + ]); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toHaveLength( + 1, + ); + }); + + it("reads a slow drift as a drift, not a dated shift", () => { + // Resting heart rate creeping from 55 to 65 over 120 days, ±0.6 noise. + const drift = Array.from({ length: 120 }, (_, i) => { + const jitter = (((i * 7919) % 100) / 100 - 0.5) * 1.2; + return 55 + (10 * i) / 119 + jitter; + }); + expect( + detectLevelShift(drift, { minAbsDelta: 3, spreadFloor: 1 }), + ).toBeNull(); + // The same rise as a step does fire. + const stepped = drift.map((_, i) => (i < 60 ? 55 : 65) + (i % 3) * 0.3); + expect( + detectLevelShift(stepped, { minAbsDelta: 3, spreadFloor: 1 }), + ).not.toBeNull(); + }); + + it("bounds the magnitude, so the flattest series cannot win the ranking", () => { + const shift = detectLevelShift([ + ...Array(30).fill(14), + ...Array(30).fill(17), + ]); + expect(shift?.magnitude).toBe(MAX_MAGNITUDE); + }); +}); diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index d7505c8c0..abbbdb39c 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -16,7 +16,18 @@ * - the post-break level must PERSIST (≥ `minPersistDays` after the break), * - the level change must exceed the metric's own robust spread * (|Δmean| ≥ `magnitudeK` · MAD · 1.4826), the personal yardstick, and - * - the series must be long enough overall. + * - the series must be long enough overall, + * - the step must explain the series better than a straight line does + * (a slow drift splits into two halves with different means, but it is a + * drift, not a shift), and + * - with the per-metric floors the scan passes ({@link CHANGEPOINT_FLOORS}): + * the step must reach a minimum absolute size, and the spread it is + * measured against never drops below the device / clinical resolution, + * so a device that reports in 0.3 steps cannot make a 0.3 plateau change + * look like a many-sigma event. + * + * `magnitude` is capped at {@link MAX_MAGNITUDE} so the cross-metric ranking + * is not won by whichever series happens to be the flattest. * * Below the bar the detector emits `null` and the Coach keeps saying "lately". * One changepoint per metric per window — no multi-break hunting in v1.22. @@ -30,8 +41,11 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import type { SurfaceModuleMap } from "@/lib/modules/surface"; +import { linearFit } from "@/lib/stats/linear-fit"; const MAD_TO_SIGMA = 1.4826; +/** Upper bound of the reported (and ranked) magnitude, in robust-σ units. */ +export const MAX_MAGNITUDE = 10; /** A validated level shift in a numeric series. */ export interface LevelShift { @@ -55,6 +69,10 @@ export interface DetectLevelShiftOptions { minPersistDays?: number; /** |Δmean| floor in robust-σ units. Default 1.4 (high firing bar). */ magnitudeK?: number; + /** |Δmean| floor in the metric's own unit. Default 0 (none). */ + minAbsDelta?: number; + /** Lower bound of the pooled spread, in the metric's unit. Default 0. */ + spreadFloor?: number; } function mean(xs: readonly number[]): number { @@ -94,6 +112,8 @@ export function detectLevelShift( const minSegmentLength = opts.minSegmentLength ?? 5; const minPersistDays = opts.minPersistDays ?? 10; const magnitudeK = opts.magnitudeK ?? 1.4; + const minAbsDelta = opts.minAbsDelta ?? 0; + const spreadFloor = opts.spreadFloor ?? 0; const n = series.length; if (n < minSeriesLength) return null; @@ -128,6 +148,7 @@ export function detectLevelShift( const beforeMean = mean(before); const afterMean = mean(after); const delta = afterMean - beforeMean; + if (Math.abs(delta) < minAbsDelta) return null; // Magnitude bar: the step must exceed the WITHIN-segment robust spread (the // metric's day-to-day noise), NOT the whole-series spread — a clean step @@ -138,21 +159,49 @@ export function detectLevelShift( const pooledSpread = Math.max( robustSpread(before), robustSpread(after), + spreadFloor, 1e-9, ); const magnitude = Math.abs(delta) / pooledSpread; if (magnitude < magnitudeK) return null; + if (isDrift(series, before, after, beforeMean, afterMean)) return null; return { breakIndex, beforeMean, afterMean, delta, - magnitude, + magnitude: Math.min(magnitude, MAX_MAGNITUDE), direction: delta >= 0 ? "up" : "down", }; } +/** + * True when a straight line through the whole series fits at least as well as + * the two-level step (same parameter count, so the residual sums compare + * directly). A drift is then the honest reading, not a dated shift. + */ +function isDrift( + series: readonly number[], + before: readonly number[], + after: readonly number[], + beforeMean: number, + afterMean: number, +): boolean { + let sseStep = 0; + for (const x of before) sseStep += (x - beforeMean) ** 2; + for (const x of after) sseStep += (x - afterMean) ** 2; + + const fit = linearFit( + series.map((_, i) => i), + series, + ); + if (!fit) return false; + // Residual sum of the line from the centred sums: SSR = Syy − Sxy² / Sxx. + const sseLine = Math.max(0, fit.syy - (fit.sxy * fit.sxy) / fit.sxx); + return sseLine <= sseStep; +} + /** One detected changepoint, ready for the snapshot. */ export interface ChangepointSignal { metric: MeasurementType; @@ -178,6 +227,42 @@ const CHANGEPOINT_METRICS: readonly MeasurementType[] = [ "BLOOD_GLUCOSE", ]; +/** + * Per-metric floors, in the metric's stored unit: `minDelta` is the smallest + * level change worth calling a shift (about the smallest change a clinician + * would note for that vital), `spreadFloor` the smallest day-to-day spread the + * step is measured against (about the measurement resolution / repeat error of + * a consumer device). A metric without an entry has neither floor. + * + * - heart rates (bpm): a 3 bpm change, 1 bpm resolution. + * - HRV (ms): 5 ms, 2 ms (beat detection jitter). + * - weight (kg): 1 kg, 0.2 kg (scale repeat error). + * - blood pressure (mmHg): 5 / 3 mmHg, 2 mmHg (cuff repeat error). + * - glucose (mg/dL): 10 mg/dL, 3 mg/dL (meter accuracy). + * - body fat (%): 1 point, 0.3 point (impedance repeat error). + * - respiratory rate (breaths/min): 1, 0.3. + * - SpO₂ (%): 1 point, 0.5 point (reported in whole percent). + * - VO₂ max (ml/kg/min): 1.5, 0.5 (a modelled estimate that moves in 0.1–0.3 + * steps; a plateau stepping by 0.3 is the estimator, not the person). + */ +export const CHANGEPOINT_FLOORS: Partial< + Record +> = { + RESTING_HEART_RATE: { minDelta: 3, spreadFloor: 1 }, + PULSE: { minDelta: 3, spreadFloor: 1 }, + WALKING_HEART_RATE_AVERAGE: { minDelta: 3, spreadFloor: 1 }, + HEART_RATE_VARIABILITY: { minDelta: 5, spreadFloor: 2 }, + HRV_RMSSD: { minDelta: 5, spreadFloor: 2 }, + WEIGHT: { minDelta: 1, spreadFloor: 0.2 }, + BLOOD_PRESSURE_SYS: { minDelta: 5, spreadFloor: 2 }, + BLOOD_PRESSURE_DIA: { minDelta: 3, spreadFloor: 2 }, + BLOOD_GLUCOSE: { minDelta: 10, spreadFloor: 3 }, + BODY_FAT: { minDelta: 1, spreadFloor: 0.3 }, + RESPIRATORY_RATE: { minDelta: 1, spreadFloor: 0.3 }, + OXYGEN_SATURATION: { minDelta: 1, spreadFloor: 0.5 }, + VO2_MAX: { minDelta: 1.5, spreadFloor: 0.5 }, +}; + /** Window the reader looks back over (days). */ export const CHANGEPOINT_WINDOW_DAYS = 90; /** At most this many changepoints in one snapshot (strongest first). */ @@ -244,7 +329,14 @@ async function scanChangepoints( coverage, ); if (points.length === 0) continue; - const shift = detectLevelShift(points.map((p) => p.mean)); + const floors = CHANGEPOINT_FLOORS[metric]; + const shift = detectLevelShift( + points.map((p) => p.mean), + { + minAbsDelta: floors?.minDelta, + spreadFloor: floors?.spreadFloor, + }, + ); if (!shift) continue; const breakDate = points[shift.breakIndex + 1]?.day ?? points[shift.breakIndex].day; From 99dfd68aadbd1b2c6355b0e288ed5149fbcf2dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:09:09 +0200 Subject: [PATCH 65/98] fix(workouts): hold training load through unscored days A day without a stored training impulse was read as a rest day, so a watch left off or a nightly run that had not happened yet pulled fitness and fatigue toward zero. Such a day is now not rated: the averages hold through it, the series marks it, and the card says how many of the last 42 days were rated. A scored day of 0 stays rest. The figures describe the last stored day rather than always yesterday. --- docs/api/openapi.yaml | 25 +++++++-- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/nl.json | 3 +- messages/pl.json | 3 +- .../__tests__/canonical-dedup.test.ts | 2 + .../__tests__/training-load-card.test.tsx | 9 ++++ .../workouts/training-load-card.tsx | 11 ++++ .../insights/__tests__/training-load.test.ts | 43 ++++++++++++---- src/lib/insights/training-load.ts | 51 ++++++++++++++----- src/lib/openapi/routes/workouts.ts | 18 ++++++- 15 files changed, 145 insertions(+), 38 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6a072be7d..9c77d2a05 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -49454,7 +49454,8 @@ components: const: ok asOf: type: string - description: The last scored UTC day the figures describe. + description: "The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never + reads as a rest day." ctl: type: number description: "Fitness: 42-day exponentially weighted TRIMP." @@ -49472,6 +49473,15 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + recentRatedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse. + recentDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 warmingUp: type: boolean description: "True below 42 covered days: both averages start at zero." @@ -49488,11 +49498,16 @@ components: type: number tsb: type: number + rated: + type: boolean + description: "False for a day without a stored impulse (never scored): the averages held through it rather than counting + it as rest." required: - day - ctl - atl - tsb + - rated additionalProperties: false description: The trailing 90 days, oldest first; `tsb` is form going into the day. required: @@ -49503,6 +49518,8 @@ components: - tsb - daysCovered - trainingDays + - recentRatedDays + - recentDays - warmingUp - series additionalProperties: false @@ -49530,9 +49547,9 @@ components: - daysCovered - trainingDays additionalProperties: false - description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day - without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with - the person's own past only." + description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a + scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). + Needs 28 days and three training days. TRIMP units: comparable with the person's own past only." GetWorkoutResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index e1083aa25..47417f3ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3898,7 +3898,8 @@ "fatigue": "Ermüdung", "form": "Form", "explainer": "Fitness ist dein Trainingsimpuls aus der Herzfrequenz, gemittelt über etwa sechs Wochen, Ermüdung über etwa eine Woche, und Form ist der Unterschied. Die Zahlen lassen sich mit deinen eigenen früheren vergleichen, nicht mit anderen Menschen oder Apps.", - "warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist." + "warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist.", + "coverage": "{rated} der letzten {days} Tage haben Herzfrequenzdaten. Tage ohne sie bleiben außen vor, statt als Ruhetag zu zählen." } }, "bloodGlucose": { diff --git a/messages/en.json b/messages/en.json index a90ca8022..8f8fd6fa6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3898,7 +3898,8 @@ "fatigue": "Fatigue", "form": "Form", "explainer": "Fitness is your heart-rate training impulse averaged over about six weeks, fatigue over about one week, and form is the difference. The numbers compare with your own past, not with other people or other apps.", - "warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is." + "warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is.", + "coverage": "{rated} of the last {days} days have heart-rate data. Days without it are left out rather than counted as rest." } }, "bloodGlucose": { diff --git a/messages/es.json b/messages/es.json index 032dbfaca..02f80d63e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3898,7 +3898,8 @@ "fatigue": "Fatiga", "form": "Estado", "explainer": "La forma de base es tu impulso de entrenamiento por frecuencia cardiaca promediado en unas seis semanas, la fatiga en una semana, y el estado es la diferencia. Las cifras se comparan con tu propio pasado, no con otras personas ni otras apps.", - "warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es." + "warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es.", + "coverage": "{rated} de los últimos {days} días tienen datos de frecuencia cardiaca. Los días sin ellos se omiten en lugar de contar como descanso." } }, "bloodGlucose": { diff --git a/messages/fr.json b/messages/fr.json index c41d73f87..70036c66d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3898,7 +3898,8 @@ "fatigue": "Fatigue", "form": "Fraîcheur", "explainer": "La condition est ton impulsion d'entraînement par fréquence cardiaque moyennée sur environ six semaines, la fatigue sur environ une semaine, et la fraîcheur est la différence. Les chiffres se comparent à ton propre passé, pas à d'autres personnes ni à d'autres apps.", - "warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est." + "warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est.", + "coverage": "{rated} des {days} derniers jours ont des données de fréquence cardiaque. Les jours sans elles sont écartés au lieu de compter comme repos." } }, "bloodGlucose": { diff --git a/messages/it.json b/messages/it.json index fa3fd0fac..362937e74 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3898,7 +3898,8 @@ "fatigue": "Affaticamento", "form": "Freschezza", "explainer": "La forma di base è il tuo impulso di allenamento dalla frequenza cardiaca mediato su circa sei settimane, l'affaticamento su circa una settimana, e la freschezza è la differenza. I numeri si confrontano con il tuo passato, non con altre persone o altre app.", - "warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia." + "warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia.", + "coverage": "{rated} degli ultimi {days} giorni hanno dati di frequenza cardiaca. I giorni senza vengono esclusi invece di contare come riposo." } }, "bloodGlucose": { diff --git a/messages/ko.json b/messages/ko.json index 0777b5263..4ce92c2d9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3898,7 +3898,8 @@ "fatigue": "피로", "form": "컨디션", "explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.", - "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다." + "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.", + "coverage": "최근 {days}일 중 {rated}일에 심박수 데이터가 있습니다. 데이터가 없는 날은 휴식일로 세지 않고 제외합니다." } }, "bloodGlucose": { diff --git a/messages/nl.json b/messages/nl.json index ecd903c04..dbe122e64 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -3898,7 +3898,8 @@ "fatigue": "Vermoeidheid", "form": "Vorm", "explainer": "Fitheid is je trainingsprikkel uit de hartslag, gemiddeld over ongeveer zes weken, vermoeidheid over ongeveer een week, en vorm is het verschil. De getallen zijn te vergelijken met je eigen verleden, niet met andere mensen of andere apps.", - "warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is." + "warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is.", + "coverage": "{rated} van de laatste {days} dagen hebben hartslaggegevens. Dagen zonder worden weggelaten in plaats van als rustdag te tellen." } }, "bloodGlucose": { diff --git a/messages/pl.json b/messages/pl.json index fd13d2ec0..3f5960574 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3898,7 +3898,8 @@ "fatigue": "Zmęczenie", "form": "Forma", "explainer": "Wydolność to twój bodziec treningowy z tętna uśredniony z około sześciu tygodni, zmęczenie z około tygodnia, a forma to różnica. Liczby porównuje się z twoją własną przeszłością, nie z innymi osobami ani aplikacjami.", - "warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest." + "warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest.", + "coverage": "{rated} z ostatnich {days} dni ma dane tętna. Dni bez nich są pomijane, a nie liczone jako odpoczynek." } }, "bloodGlucose": { diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 82906eb8e..dd05951ff 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -461,6 +461,8 @@ describe("GET /api/workouts — training load (v1.44)", () => { asOf: yesterday, daysCovered: 50, trainingDays: 50, + recentRatedDays: 42, + recentDays: 42, warmingUp: false, }); const later = await (await GET(makeRequest("?offset=100"))).json(); diff --git a/src/components/workouts/__tests__/training-load-card.test.tsx b/src/components/workouts/__tests__/training-load-card.test.tsx index 27d9dcd62..32f734eb3 100644 --- a/src/components/workouts/__tests__/training-load-card.test.tsx +++ b/src/components/workouts/__tests__/training-load-card.test.tsx @@ -17,6 +17,8 @@ const OK: TrainingLoad = { tsb: -7.6, daysCovered: 30, trainingDays: 18, + recentRatedDays: 30, + recentDays: 30, warmingUp: true, series: [], }; @@ -36,6 +38,13 @@ describe("", () => { expect(html).toMatch(/training-load-fatigue[^]*?>62 { + const html = render({ ...OK, recentRatedDays: 25, recentDays: 42 }); + expect(html).toContain('data-slot="training-load-coverage"'); + expect(html).toContain("25 of the last 42 days have heart-rate data"); }); it("signs a positive form and drops the warm-up note once covered", () => { diff --git a/src/components/workouts/training-load-card.tsx b/src/components/workouts/training-load-card.tsx index 530fec929..08b76f91a 100644 --- a/src/components/workouts/training-load-card.tsx +++ b/src/components/workouts/training-load-card.tsx @@ -47,6 +47,17 @@ export function TrainingLoadCard({ ))}

    {t("insights.workouts.load.explainer")}

    + {trainingLoad.recentRatedDays < trainingLoad.recentDays ? ( +

    + {t("insights.workouts.load.coverage", { + rated: trainingLoad.recentRatedDays, + days: trainingLoad.recentDays, + })} +

    + ) : null} {trainingLoad.warmingUp ? (

    {t("insights.workouts.load.warmingUp", { diff --git a/src/lib/insights/__tests__/training-load.test.ts b/src/lib/insights/__tests__/training-load.test.ts index 2e1e33ae2..bd5994013 100644 --- a/src/lib/insights/__tests__/training-load.test.ts +++ b/src/lib/insights/__tests__/training-load.test.ts @@ -31,23 +31,29 @@ describe("computeTrainingLoad", () => { ctl: 2.4, atl: 14.3, tsb: 0, + rated: true, }); + expect(load.recentRatedDays).toBe(42); + expect(load.recentDays).toBe(42); }); - it("counts a day without a stored row as a rest day", () => { - // Train on day 0 only, then 29 empty days up to day 29. - const load = computeTrainingLoad( - [ - { day: day(0), dayTrimp: 84 }, - { day: day(1), dayTrimp: 42 }, - { day: day(2), dayTrimp: 42 }, - ], - day(29), - ); + it("counts a scored day of 0 as rest, and holds through a day never scored", () => { + // Three training days, then scored rest days 3..29 except a gap of + // unscored days 10..19 (no heart-rate data reached the server). + const rows = [ + { day: day(0), dayTrimp: 84 }, + { day: day(1), dayTrimp: 42 }, + { day: day(2), dayTrimp: 42 }, + ]; + for (let i = 3; i < 30; i += 1) { + if (i < 10 || i > 19) rows.push({ day: day(i), dayTrimp: 0 }); + } + const load = computeTrainingLoad(rows, day(29)); if (load.status !== "ok") throw new Error("expected ok"); let ctl = 0; let atl = 0; for (let i = 0; i < 30; i += 1) { + if (i >= 10 && i <= 19) continue; // held const l = i === 0 ? 84 : i < 3 ? 42 : 0; ctl += (l - ctl) / 42; atl += (l - atl) / 7; @@ -55,9 +61,26 @@ describe("computeTrainingLoad", () => { expect(load.ctl).toBe(Math.round(ctl * 10) / 10); expect(load.atl).toBe(Math.round(atl * 10) / 10); expect(load.daysCovered).toBe(30); + expect(load.recentRatedDays).toBe(20); + expect(load.recentDays).toBe(30); + expect(load.series.find((p) => p.day === day(12))?.rated).toBe(false); + expect(load.series.find((p) => p.day === day(25))?.rated).toBe(true); expect(load.warmingUp).toBe(true); }); + it("describes the last stored day, so a run not yet made never zeroes it", () => { + // Rows through day 39; asked for day 41 (the nightly run for 40 and 41 + // has not happened). The figures are those of day 39. + const rows = Array.from({ length: 40 }, (_, i) => ({ + day: day(i), + dayTrimp: 60, + })); + const late = computeTrainingLoad(rows, day(41)); + const exact = computeTrainingLoad(rows, day(39)); + expect(late).toEqual(exact); + expect(late).toMatchObject({ status: "ok", asOf: day(39) }); + }); + it("answers insufficient without impulse or with too little history", () => { expect(computeTrainingLoad([], day(10))).toMatchObject({ status: "insufficient", diff --git a/src/lib/insights/training-load.ts b/src/lib/insights/training-load.ts index b96eaa865..7c56444fb 100644 --- a/src/lib/insights/training-load.ts +++ b/src/lib/insights/training-load.ts @@ -3,8 +3,13 @@ * training impulse. * * The impulse is the Banister TRIMP the nightly strain run already writes to - * `strain_trimp_cache` (one row per scored UTC day; a day without a row - * carried no impulse). Over it run the two exponentially weighted averages + * `strain_trimp_cache`: one row per scored UTC day, 0 on a scored day without + * training. A day WITHOUT a row was never scored (no heart-rate data reached + * the server, or the run has not happened yet), so it is not rated rather + * than read as rest: the averages hold through it instead of decaying toward + * zero, and the figures report how many recent days were rated. The figures + * describe the last stored day, so a nightly run that has not happened yet + * never turns yesterday into a rest day. Over it run the two exponentially weighted averages * of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the * time-constant form popularised as chronic and acute training load): * @@ -37,6 +42,8 @@ export interface TrainingLoadPoint { ctl: number; atl: number; tsb: number; + /** False for a day without a stored impulse: the averages held through it. */ + rated: boolean; } export type TrainingLoad = @@ -50,6 +57,10 @@ export type TrainingLoad = tsb: number; daysCovered: number; trainingDays: number; + /** Days of the trailing {@link CTL_DAYS} (or fewer, when covered less) with a stored impulse. */ + recentRatedDays: number; + /** The trailing window `recentRatedDays` counts over. */ + recentDays: number; warmingUp: boolean; series: TrainingLoadPoint[]; } @@ -63,16 +74,17 @@ export type TrainingLoad = const round1 = (x: number) => Math.round(x * 10) / 10; /** - * Run the model from the first stored day through `asOf` (inclusive), a - * missing day counting as zero impulse. Rows after `asOf` are ignored. + * Run the model from the first stored day through the last stored day on or + * before `until`. A day without a row is not rated: the averages hold through + * it. Rows after `until` are ignored. */ export function computeTrainingLoad( rows: ReadonlyArray<{ day: string; dayTrimp: number }>, - asOf: string, + until: string, ): TrainingLoad { const byDay = new Map(); for (const row of rows) { - if (row.day > asOf || !Number.isFinite(row.dayTrimp)) continue; + if (row.day > until || !Number.isFinite(row.dayTrimp)) continue; byDay.set(row.day, Math.max(0, row.dayTrimp)); } const days = [...byDay.keys()].sort(); @@ -86,18 +98,23 @@ export function computeTrainingLoad( }; } + const asOf = days[days.length - 1]; const series: TrainingLoadPoint[] = []; let ctl = 0; let atl = 0; let daysCovered = 0; for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) { const tsb = ctl - atl; - const load = byDay.get(day) ?? 0; - ctl += (load - ctl) / CTL_DAYS; - atl += (load - atl) / ATL_DAYS; + const load = byDay.get(day); + if (load !== undefined) { + ctl += (load - ctl) / CTL_DAYS; + atl += (load - atl) / ATL_DAYS; + } daysCovered += 1; - series.push({ day, ctl, atl, tsb }); + series.push({ day, ctl, atl, tsb, rated: load !== undefined }); } + const recent = series.slice(-CTL_DAYS); + const recentRatedDays = recent.filter((p) => p.rated).length; if ( daysCovered < TRAINING_LOAD_MIN_DAYS || @@ -119,17 +136,23 @@ export function computeTrainingLoad( tsb: round1(ctl - atl), daysCovered, trainingDays, + recentRatedDays, + recentDays: recent.length, warmingUp: daysCovered < CTL_DAYS, series: series.slice(-TRAINING_LOAD_SERIES_DAYS).map((p) => ({ day: p.day, ctl: round1(p.ctl), atl: round1(p.atl), tsb: round1(p.tsb), + rated: p.rated, })), }; } -/** Read the stored impulse and run the model up to the last scored day. */ +/** + * Read the stored impulse and run the model up to the last stored day, at + * most the day the strain run scores (yesterday in UTC). + */ export async function readTrainingLoad( userId: string, now: Date = new Date(), @@ -138,15 +161,15 @@ export async function readTrainingLoad( // here rather than imported: `score-row` sits on the score writers' import // graph, and this read stays off it. // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day - const asOf = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); + const until = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); const rows = await prisma.strainTrimpCache.findMany({ where: { userId, - day: { gte: shiftDateKey(asOf, -TRAINING_LOAD_READ_DAYS), lte: asOf }, + day: { gte: shiftDateKey(until, -TRAINING_LOAD_READ_DAYS), lte: until }, }, orderBy: { day: "asc" }, take: TRAINING_LOAD_READ_DAYS + 1, select: { day: true, dayTrimp: true }, }); - return computeTrainingLoad(rows, asOf); + return computeTrainingLoad(rows, until); } diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 217766ab0..a64febd99 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -115,6 +115,11 @@ const trainingLoadPoint = z.object({ ctl: z.number(), atl: z.number(), tsb: z.number(), + rated: z + .boolean() + .describe( + "False for a day without a stored impulse (never scored): the averages held through it rather than counting it as rest.", + ), }); const trainingLoad = z @@ -123,12 +128,21 @@ const trainingLoad = z status: z.literal("ok"), asOf: z .string() - .describe("The last scored UTC day the figures describe."), + .describe( + "The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never reads as a rest day.", + ), ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."), atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."), tsb: z.number().describe("Form: fitness minus fatigue at `asOf`."), daysCovered: z.number().int(), trainingDays: z.number().int(), + recentRatedDays: z + .number() + .int() + .describe( + "Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.", + ), + recentDays: z.number().int(), warmingUp: z .boolean() .describe("True below 42 covered days: both averages start at zero."), @@ -148,7 +162,7 @@ const trainingLoad = z .meta({ id: "WorkoutTrainingLoad", description: - "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.", + "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.", }); const workoutListResponse = z From cfd7c18d48b0a09d15fac0669e659f91f18503c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:20:28 +0200 Subject: [PATCH 66/98] fix(medications): widen the medication-effect intervals to a week of lag The automatic Newey-West rule gives a lag of 3 on 30 to 270 daily values, which leaves most of a physiological series' carry-over out of the intervals. The lag is now at least 7. Reference values are recomputed with statsmodels at the new default, and the lag-3 values stay pinned with an explicit lag. A fit whose residual autocorrelation exceeds 0.5 is flagged, and the card, the clinician view and the PDF say the ranges may still be too narrow. --- docs/api/openapi.yaml | 6 ++ messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/ko.json | 6 +- messages/nl.json | 6 +- messages/pl.json | 6 +- .../clinician/activity-effect-sections.tsx | 5 ++ .../change-at-start-summary.test.tsx | 17 +++++- .../efficacy/change-at-start-summary.tsx | 8 +++ .../activity-effect-sections.test.ts | 31 +++++++++- .../activity-effect-sections.ts | 3 + .../__tests__/segmented-effect.test.ts | 19 +++++++ .../medications/efficacy/segmented-effect.ts | 10 ++++ .../__tests__/segmented-regression.test.ts | 57 +++++++++++++++++-- src/lib/stats/segmented-regression.ts | 23 ++++++-- src/lib/validations/medication-efficacy.ts | 12 +++- 19 files changed, 208 insertions(+), 31 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9c77d2a05..9effbc1d3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -54898,12 +54898,18 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + description: "Newey–West lag applied: the automatic rule, at least 7 (a week of daily carry-over)." + autocorrelationCaveat: + type: boolean + description: "True when the lag-1 residual autocorrelation exceeds 0.5: the carry-over is strong enough that the + intervals may still be too narrow, and the read-out says so." required: - n - df - rSquared - residualAutocorrelation - hacLag + - autocorrelationCaveat additionalProperties: false - type: "null" confounders: diff --git a/messages/de.json b/messages/de.json index 47417f3ab..f3e2a8458 100644 --- a/messages/de.json +++ b/messages/de.json @@ -431,7 +431,8 @@ "notTracked": "Einnahme nicht erfasst, daher keine Tage wegen ausgelassener Dosen entfernt", "endDoseChange": "Zeitraum endet am {date}, vor der ersten Dosisänderung", "days": "{before} Tage vor, {after} Tage nach dem Start, {excluded} Tage ausgelassen", - "method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments." + "method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments.", + "autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "Lege ein Startdatum fest, um die Veränderung zum Start zu sehen.", "needBefore": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage vor dem Start.", "needAfter": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage seit dem Start und an Tagen mit eingenommenen Dosen.", - "fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt." + "fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt.", + "autocorrelation": "Die Tageswerte hängen stark vom Vortag ab, deshalb können selbst diese Bereiche zu eng sein." } }, "import": { diff --git a/messages/en.json b/messages/en.json index 8f8fd6fa6..292300819 100644 --- a/messages/en.json +++ b/messages/en.json @@ -431,7 +431,8 @@ "notTracked": "intake not tracked, so no days left out for missed doses", "endDoseChange": "window ends {date}, before the first dose change", "days": "{before} days before, {after} days after the start, {excluded} days left out", - "method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication." + "method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication.", + "autocorrelation": "strong day-to-day carry-over, so the ranges may still be too narrow" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "Set a start date to see the change at the start.", "needBefore": "The change at the start needs readings on at least {days} days spread over {span} days before the start.", "needAfter": "The change at the start needs readings on at least {days} days spread over {span} days since the start, on days the doses were taken.", - "fitFailed": "These readings could not be fitted, so no change at the start is shown." + "fitFailed": "These readings could not be fitted, so no change at the start is shown.", + "autocorrelation": "The daily values carry over strongly from one day to the next, so even these ranges may be too narrow." } }, "import": { diff --git a/messages/es.json b/messages/es.json index 02f80d63e..2e222ca60 100644 --- a/messages/es.json +++ b/messages/es.json @@ -431,7 +431,8 @@ "notTracked": "toma no registrada, así que no se omitió ningún día por dosis olvidadas", "endDoseChange": "el periodo termina el {date}, antes del primer cambio de dosis", "days": "{before} días antes, {after} días después del inicio, {excluded} días omitidos", - "method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento." + "method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento.", + "autocorrelation": "fuerte dependencia del día anterior, así que los rangos pueden seguir siendo demasiado estrechos" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "Indica una fecha de inicio para ver el cambio al inicio.", "needBefore": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días antes del inicio.", "needAfter": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días desde el inicio, en días con las dosis tomadas.", - "fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio." + "fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio.", + "autocorrelation": "Los valores diarios dependen mucho del día anterior, así que incluso estos rangos pueden ser demasiado estrechos." } }, "import": { diff --git a/messages/fr.json b/messages/fr.json index 70036c66d..ce2b8f4ff 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -431,7 +431,8 @@ "notTracked": "prises non suivies, aucun jour écarté pour des doses manquées", "endDoseChange": "la période s'arrête le {date}, avant le premier changement de dose", "days": "{before} jours avant, {after} jours après le démarrage, {excluded} jours écartés", - "method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament." + "method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament.", + "autocorrelation": "forte dépendance à la veille, les plages peuvent donc rester trop étroites" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "Indique une date de début pour voir le changement au démarrage.", "needBefore": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours avant le démarrage.", "needAfter": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours depuis le démarrage, les jours où les doses ont été prises.", - "fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché." + "fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché.", + "autocorrelation": "Les valeurs quotidiennes dépendent fortement de la veille, donc même ces plages peuvent être trop étroites." } }, "import": { diff --git a/messages/it.json b/messages/it.json index 362937e74..f7a72c126 100644 --- a/messages/it.json +++ b/messages/it.json @@ -431,7 +431,8 @@ "notTracked": "assunzione non registrata, quindi nessun giorno escluso per dosi saltate", "endDoseChange": "il periodo termina il {date}, prima della prima variazione di dose", "days": "{before} giorni prima, {after} giorni dopo l'inizio, {excluded} giorni esclusi", - "method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco." + "method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco.", + "autocorrelation": "forte dipendenza dal giorno precedente, quindi gli intervalli possono restare troppo stretti" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "Imposta una data di inizio per vedere la variazione all'inizio.", "needBefore": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni prima dell'inizio.", "needAfter": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni dall'inizio, nei giorni con le dosi assunte.", - "fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio." + "fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio.", + "autocorrelation": "I valori giornalieri dipendono molto dal giorno precedente, quindi anche questi intervalli possono essere troppo stretti." } }, "import": { diff --git a/messages/ko.json b/messages/ko.json index 4ce92c2d9..dce35a661 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -431,7 +431,8 @@ "notTracked": "복용 미기록으로 놓친 용량에 따른 제외 없음", "endDoseChange": "기간은 첫 용량 변경 전인 {date}에 끝남", "days": "시작 전 {before}일, 시작 후 {after}일, 제외 {excluded}일", - "method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다." + "method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다.", + "autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "시작 시점의 변화를 보려면 시작일을 설정하세요.", "needBefore": "시작 시점의 변화를 보려면 시작 전 {span}일에 걸쳐 최소 {days}일의 측정값이 필요합니다.", "needAfter": "시작 시점의 변화를 보려면 시작 후 {span}일에 걸쳐, 용량을 복용한 날 기준으로 최소 {days}일의 측정값이 필요합니다.", - "fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다." + "fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다.", + "autocorrelation": "하루 값이 전날 값에 크게 좌우되므로 이 범위도 너무 좁을 수 있습니다." } }, "import": { diff --git a/messages/nl.json b/messages/nl.json index dbe122e64..1af10b087 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -431,7 +431,8 @@ "notTracked": "inname niet bijgehouden, dus geen dagen weggelaten om gemiste doses", "endDoseChange": "periode eindigt op {date}, vóór de eerste dosiswijziging", "days": "{before} dagen voor, {after} dagen na de start, {excluded} dagen weggelaten", - "method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn." + "method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn.", + "autocorrelation": "sterke afhankelijkheid van de vorige dag, dus de bereiken kunnen nog te smal zijn" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "Stel een startdatum in om de verandering bij de start te zien.", "needBefore": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen vóór de start.", "needAfter": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen sinds de start en op dagen waarop de doses zijn ingenomen.", - "fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond." + "fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond.", + "autocorrelation": "De dagwaarden hangen sterk af van de vorige dag, dus zelfs deze bereiken kunnen te smal zijn." } }, "import": { diff --git a/messages/pl.json b/messages/pl.json index 3f5960574..ccbf60069 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -431,7 +431,8 @@ "notTracked": "przyjmowanie nie jest rejestrowane, więc nie pominięto dni z powodu opuszczonych dawek", "endDoseChange": "okres kończy się {date}, przed pierwszą zmianą dawki", "days": "{before} dni przed, {after} dni po rozpoczęciu, pominięto {excluded} dni", - "method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku." + "method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku.", + "autocorrelation": "silna zależność od poprzedniego dnia, więc zakresy mogą nadal być zbyt wąskie" } }, "errorBoundary": { @@ -2412,7 +2413,8 @@ "needStart": "Ustaw datę rozpoczęcia, aby zobaczyć zmianę na początku.", "needBefore": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni przed rozpoczęciem.", "needAfter": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni od rozpoczęcia, w dni z przyjętymi dawkami.", - "fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana." + "fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana.", + "autocorrelation": "Wartości dzienne silnie zależą od poprzedniego dnia, więc nawet te zakresy mogą być zbyt wąskie." } }, "import": { diff --git a/src/components/clinician/activity-effect-sections.tsx b/src/components/clinician/activity-effect-sections.tsx index 3f62c9878..a920eb3c7 100644 --- a/src/components/clinician/activity-effect-sections.tsx +++ b/src/components/clinician/activity-effect-sections.tsx @@ -167,6 +167,11 @@ export function MedicationEffectSection({ e.days.excludedLowAdherence + e.days.excludedNotTaking, })}

    + {e.fit?.autocorrelationCaveat ? ( +

    + {t("medications.efficacy.segmented.autocorrelation")} +

    + ) : null} {e.confounders.map((c) => (

    { expect(html).toContain("describes the starting dose"); expect(html).toContain("Example B started on"); expect(html).not.toMatch(/work(s|ing)|effective|successful/i); + expect(html).not.toContain("medication-wirkung-autocorrelation"); + }); + + it("warns when the daily values carry over strongly", () => { + const html = render({ + ...present, + fit: { + ...present.fit!, + residualAutocorrelation: 0.7, + autocorrelationCaveat: true, + }, + }); + expect(html).toContain("medication-wirkung-autocorrelation"); + expect(html).toContain("even these ranges may be too narrow"); }); it("names the missing floor instead of a number", () => { diff --git a/src/components/medications/detail/efficacy/change-at-start-summary.tsx b/src/components/medications/detail/efficacy/change-at-start-summary.tsx index 8f05249c0..af2c132fb 100644 --- a/src/components/medications/detail/efficacy/change-at-start-summary.tsx +++ b/src/components/medications/detail/efficacy/change-at-start-summary.tsx @@ -125,6 +125,14 @@ export function ChangeAtStartSummary({ {t("medications.efficacy.segmented.notTracked")}

    ) : null} + {segmented.fit?.autocorrelationCaveat ? ( +

    + {t("medications.efficacy.segmented.autocorrelation")} +

    + ) : null} {segmented.window.endReason === "dose_change" ? (

    {t("medications.efficacy.segmented.endDoseChange")} diff --git a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts index a2c0ac544..94daa7421 100644 --- a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts +++ b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts @@ -81,7 +81,8 @@ const EFFECT: EfficacySegmented = { df: 141, rSquared: 0.55, residualAutocorrelation: 0.31, - hacLag: 4, + hacLag: 7, + autocorrelationCaveat: false, }, confounders: [ { medicationName: "Other drug", event: "started", date: "2026-02-14" }, @@ -218,6 +219,34 @@ describe("doctor report v1.44 sections", () => { expect(text).toContain("5 days after the start left out"); expect(text).toContain("Other drug started on"); expect(text).toContain("not a proven effect"); + expect(text).not.toContain("carry-over"); + }); + + it("notes strong day-to-day carry-over in the effect notes", async () => { + const text = ( + await textOf( + render( + data({ + medicationEffects: [ + { + medicationName: "Example ACE inhibitor", + startsOn: "2026-03-02", + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + ...EFFECT, + fit: { ...EFFECT.fit!, autocorrelationCaveat: true }, + }, + }, + ], + }, + ], + }), + ), + ) + ).replace(/\s+/g, " "); + expect(text).toContain("strong day-to-day carry-over"); }); it("names the missing floor instead of printing numbers", async () => { diff --git a/src/lib/doctor-report-pdf/activity-effect-sections.ts b/src/lib/doctor-report-pdf/activity-effect-sections.ts index cbf7c0d36..819918bbc 100644 --- a/src/lib/doctor-report-pdf/activity-effect-sections.ts +++ b/src/lib/doctor-report-pdf/activity-effect-sections.ts @@ -232,6 +232,9 @@ export function buildMedicationEffectSection( if (e.adherence.status === "not_tracked") { extras.push(t("doctorReport.medicationEffect.notTracked")); } + if (e.fit?.autocorrelationCaveat) { + extras.push(t("doctorReport.medicationEffect.autocorrelation")); + } if (e.window.endReason === "dose_change") { extras.push( t("doctorReport.medicationEffect.endDoseChange", { diff --git a/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts index 6f099d6bc..ea4191657 100644 --- a/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts +++ b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts @@ -173,6 +173,25 @@ describe("buildSegmentedEffect", () => { expect(r.present).toBe(true); expect(r.levelChange!.includesZero).toBe(true); }); + + it("flags strong residual autocorrelation for a caveat, not a clean fit", () => { + // A slow wave around the line: neighbouring residuals share a sign. + const wavy = series(-60, 59).map((p, i) => ({ + day: p.day, + value: p.value + 4 * Math.sin(i / 6), + })); + const r = buildSegmentedEffect(input({ dayValues: wavy })); + expect(r.fit!.residualAutocorrelation).toBeGreaterThan(0.5); + expect(r.fit!.autocorrelationCaveat).toBe(true); + expect(r.fit!.hacLag).toBe(7); + // Alternating noise: negative carry-over, no caveat. + const alternating = series(-60, 59).map((p, i) => ({ + day: p.day, + value: p.value + (i % 2 === 0 ? 2 : -2), + })); + const clean = buildSegmentedEffect(input({ dayValues: alternating })); + expect(clean.fit!.autocorrelationCaveat).toBe(false); + }); }); describe("notTakingPredicate", () => { diff --git a/src/lib/medications/efficacy/segmented-effect.ts b/src/lib/medications/efficacy/segmented-effect.ts index 028f4a55b..f102a8217 100644 --- a/src/lib/medications/efficacy/segmented-effect.ts +++ b/src/lib/medications/efficacy/segmented-effect.ts @@ -48,6 +48,12 @@ export const ADHERENCE_LOOKBACK_DAYS = 14; export const ADHERENCE_FLOOR = 0.8; /** Confidence level of every interval. */ export const SEGMENTED_LEVEL = 0.95; +/** + * Lag-1 residual autocorrelation above which the read-out carries a caveat: + * the carry-over is then strong enough that even the widened intervals may be + * too narrow. + */ +export const AUTOCORRELATION_CAVEAT = 0.5; /** An estimate in display terms: the unit per day for a level, per week for a slope. */ export interface EfficacyEstimate { @@ -106,6 +112,8 @@ export interface EfficacySegmented { rSquared: number; residualAutocorrelation: number; hacLag: number; + /** True above {@link AUTOCORRELATION_CAVEAT}: renderers add the caveat line. */ + autocorrelationCaveat: boolean; } | null; /** Other medication changes inside the window, oldest first. */ confounders: EfficacyConfounder[]; @@ -311,6 +319,8 @@ export function buildSegmentedEffect( rSquared: fit.rSquared, residualAutocorrelation: fit.residualAutocorrelation, hacLag: fit.hacLag, + autocorrelationCaveat: + fit.residualAutocorrelation > AUTOCORRELATION_CAVEAT, }, confounders, }; diff --git a/src/lib/stats/__tests__/segmented-regression.test.ts b/src/lib/stats/__tests__/segmented-regression.test.ts index 57001444b..2c057825b 100644 --- a/src/lib/stats/__tests__/segmented-regression.test.ts +++ b/src/lib/stats/__tests__/segmented-regression.test.ts @@ -2,16 +2,22 @@ * Exact statistics for the segmented (interrupted time series) regression. * * The reference values for the noisy fixtures were computed independently - * with statsmodels 0.14 (`OLS(...).fit(cov_type="HAC", cov_kwds={"maxlags": - * L, "use_correction": True}, use_t=True)`, L = floor(4·(n/100)^(2/9))) and - * scipy's `stats.t`. The fixture generator below is the same deterministic + * with statsmodels 0.14.4 (`OLS(...).fit(cov_type="HAC", cov_kwds={"maxlags": + * L, "use_correction": True}, use_t=True)`) and scipy's `stats.t`, at the + * default lag L = max(7, floor(4·(n/100)^(2/9))) = 7 and, to pin the HAC meat + * at a second lag, at the automatic L = 3 passed explicitly. To recompute: + * build the fixture below in Python (Python's floored `%`), fit the design + * [1, t, D, t·D] with the call above at each L, and read `params`, `bse` and + * `conf_int(0.05)`; the coefficients and R² do not depend on L. The fixture generator below is the same deterministic * formula the reference script used, so a drift in the estimator, the HAC * meat, the small-sample factor or the t quantile fails here. */ import { describe, expect, it } from "vitest"; import { + defaultHacLag, fitSegmentedRegression, + MIN_HAC_LAG, neweyWestLag, studentTCdf, studentTQuantile, @@ -62,10 +68,27 @@ describe("fitSegmentedRegression", () => { expect(fit!.rSquared).toBeCloseTo(1, 12); }); - it("matches statsmodels HAC on a contiguous 90-day series", () => { + it("matches statsmodels HAC on a contiguous 90-day series at the default lag", () => { const fit = fitSegmentedRegression(fixture(40, 50))!; expect(fit.n).toBe(90); expect(fit.df).toBe(86); + expect(fit.hacLag).toBe(7); + expect(fit.levelChange.estimate).toBeCloseTo(-7.336815685253282, 9); + expect(fit.intercept.standardError).toBeCloseTo(0.3926378134611552, 9); + expect(fit.preSlope.standardError).toBeCloseTo(0.015434960504949637, 10); + expect(fit.levelChange.standardError).toBeCloseTo(0.5544100003043471, 9); + expect(fit.slopeChange.standardError).toBeCloseTo(0.02119259135971675, 10); + expect(fit.levelChange.ciLow).toBeCloseTo(-8.43894628913928, 8); + expect(fit.levelChange.ciHigh).toBeCloseTo(-6.234685081367285, 8); + expect(fit.slopeChange.ciLow).toBeCloseTo(-0.07897452738576954, 9); + expect(fit.slopeChange.ciHigh).toBeCloseTo(0.005284427179883318, 9); + expect(fit.rSquared).toBeCloseTo(0.8683372493940129, 10); + }); + + it("matches statsmodels HAC on a contiguous 90-day series at lag 3", () => { + const fit = fitSegmentedRegression(fixture(40, 50), { hacLag: 3 })!; + expect(fit.n).toBe(90); + expect(fit.df).toBe(86); expect(fit.hacLag).toBe(3); expect(fit.intercept.estimate).toBeCloseTo(139.46950523140714, 9); expect(fit.preSlope.estimate).toBeCloseTo(-0.09725785860840647, 10); @@ -82,9 +105,24 @@ describe("fitSegmentedRegression", () => { expect(fit.rSquared).toBeCloseTo(0.8683372493940129, 10); }); - it("matches statsmodels HAC on a series with gaps", () => { + it("matches statsmodels HAC on a series with gaps at the default lag", () => { const fit = fitSegmentedRegression(fixture(30, 30, { gaps: true }))!; expect(fit.n).toBe(48); + expect(fit.hacLag).toBe(7); + expect(fit.levelChange.estimate).toBeCloseTo(-6.123258149435133, 9); + expect(fit.levelChange.standardError).toBeCloseTo(0.7919225818543464, 9); + expect(fit.levelChange.ciLow).toBeCloseTo(-7.71927324237417, 8); + expect(fit.levelChange.ciHigh).toBeCloseTo(-4.527243056496095, 8); + expect(fit.slopeChange.estimate).toBeCloseTo(-0.11857109294626766, 10); + expect(fit.slopeChange.ciLow).toBeCloseTo(-0.22143394263874167, 9); + expect(fit.slopeChange.ciHigh).toBeCloseTo(-0.01570824325379365, 9); + }); + + it("matches statsmodels HAC on a series with gaps at lag 3", () => { + const fit = fitSegmentedRegression(fixture(30, 30, { gaps: true }), { + hacLag: 3, + })!; + expect(fit.n).toBe(48); expect(fit.hacLag).toBe(3); expect(fit.levelChange.estimate).toBeCloseTo(-6.123258149435133, 9); expect(fit.levelChange.standardError).toBeCloseTo(0.9608527406825199, 9); @@ -106,9 +144,16 @@ describe("fitSegmentedRegression", () => { expect(fitSegmentedRegression(fixture(2, 2, { noise: false }))).toBeNull(); }); - it("uses the automatic Newey–West lag", () => { + it("uses the automatic Newey–West lag, never below a week", () => { expect(neweyWestLag(90)).toBe(3); expect(neweyWestLag(30)).toBe(3); expect(neweyWestLag(10)).toBe(2); + expect(MIN_HAC_LAG).toBe(7); + expect(defaultHacLag(90)).toBe(7); + expect(defaultHacLag(270)).toBe(7); + expect(defaultHacLag(100_000)).toBe(neweyWestLag(100_000)); + // A short series clamps to n − 1. + expect(fitSegmentedRegression(fixture(4, 4))!.hacLag).toBe(7); + expect(fitSegmentedRegression(fixture(3, 3))!.hacLag).toBe(5); }); }); diff --git a/src/lib/stats/segmented-regression.ts b/src/lib/stats/segmented-regression.ts index b0451c5cd..e5e81eb23 100644 --- a/src/lib/stats/segmented-regression.ts +++ b/src/lib/stats/segmented-regression.ts @@ -13,11 +13,14 @@ * Daily health series are autocorrelated: today's blood pressure says a lot * about tomorrow's. Plain OLS standard errors ignore that and come out too * narrow, so the covariance here is the Newey–West estimator (Bartlett - * kernel) with the usual automatic lag `floor(4·(n/100)^(2/9))` and the - * small-sample factor n/(n−k). Intervals use Student's t with n−k degrees of - * freedom. This matches `statsmodels` OLS `cov_type="HAC"` with - * `use_correction=True` and `use_t=True`; the unit tests pin it to values - * computed there. + * kernel) and the small-sample factor n/(n−k). The lag is the usual automatic + * `floor(4·(n/100)^(2/9))`, but never below {@link MIN_HAC_LAG}: on 30 to 270 + * daily values the automatic rule gives 3, while a physiological series + * carries over for about a week (weight, resting heart rate, blood pressure + * under a new medication), and three lags leave most of that out. Intervals + * use Student's t with n−k degrees of freedom. This matches `statsmodels` OLS + * `cov_type="HAC"` with `use_correction=True` and `use_t=True` at the same + * `maxlags`; the unit tests pin it to values computed there. * * Observations are taken in the order given, which for a daily series with * gaps treats a gap as adjacent days. That is a known approximation of the @@ -61,11 +64,19 @@ export interface SegmentedFit { const K = 4; +/** Smallest Newey–West lag applied: a week of daily carry-over. */ +export const MIN_HAC_LAG = 7; + /** Automatic Newey–West lag: floor(4·(n/100)^(2/9)). */ export function neweyWestLag(n: number): number { return Math.floor(4 * Math.pow(n / 100, 2 / 9)); } +/** The lag applied by default: the automatic rule, at least {@link MIN_HAC_LAG}. */ +export function defaultHacLag(n: number): number { + return Math.max(MIN_HAC_LAG, neweyWestLag(n)); +} + /** Invert a small symmetric positive-definite matrix (Gauss–Jordan). */ function invert(matrix: number[][]): number[][] | null { const size = matrix.length; @@ -131,7 +142,7 @@ export function fitSegmentedRegression( ); // Meat: Σ e_t² x_t x_tᵀ + Σ_l w_l Σ_t e_t e_{t−l} (x_t x_{t−l}ᵀ + x_{t−l} x_tᵀ) - const lag = Math.max(0, Math.min(options.hacLag ?? neweyWestLag(n), n - 1)); + const lag = Math.max(0, Math.min(options.hacLag ?? defaultHacLag(n), n - 1)); const meat = Array.from({ length: K }, () => new Array(K).fill(0)); for (let l = 0; l <= lag; l++) { const w = l === 0 ? 1 : 1 - l / (lag + 1); diff --git a/src/lib/validations/medication-efficacy.ts b/src/lib/validations/medication-efficacy.ts index 743e3b42a..ba6b075a1 100644 --- a/src/lib/validations/medication-efficacy.ts +++ b/src/lib/validations/medication-efficacy.ts @@ -137,7 +137,17 @@ const segmentedSchema = z df: z.number().int(), rSquared: z.number(), residualAutocorrelation: z.number(), - hacLag: z.number().int(), + hacLag: z + .number() + .int() + .describe( + "Newey–West lag applied: the automatic rule, at least 7 (a week of daily carry-over).", + ), + autocorrelationCaveat: z + .boolean() + .describe( + "True when the lag-1 residual autocorrelation exceeds 0.5: the carry-over is strong enough that the intervals may still be too narrow, and the read-out says so.", + ), }) .nullable(), confounders: z.array( From 733b00ee1a6aaf8955cc7729399002a594dfbb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:25:45 +0200 Subject: [PATCH 67/98] fix(strain): count one source per session and per day Two rows of one workout slot from sources off the ladder were both kept by the picker and their impulses added. Only one source's rows now count per slot, a source off the ladder ranking below every listed one. The day's active energy, the strain fallback signal, summed every source; it now goes through the activeEnergy source pick and keeps one source's total. --- .../insights/__tests__/strain-score.test.ts | 64 +++++++++++++- src/lib/insights/strain-score.ts | 86 +++++++++++++++++-- 2 files changed, 140 insertions(+), 10 deletions(-) diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index fcf7173fb..03ea6f010 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -33,6 +33,8 @@ import { resolvePersonalReference, persistStrainScore, canonicalWorkoutTrimps, + canonicalDayActiveEnergy, + STRAIN_ACTIVE_ENERGY_REFERENCE, STRAIN_SCORE_EXTERNAL_ID_PREFIX, STRAIN_TRIMP_REFERENCE, STRAIN_MIN_TRAINING_DAYS, @@ -152,7 +154,7 @@ describe("saturateToScore", () => { }); function makePrisma(opts: { - energy?: number[]; + energy?: Array; workouts?: Array<{ id: string; samples: { samples: unknown } | null; @@ -172,8 +174,9 @@ function makePrisma(opts: { trainingDays?: number; }>; }) { - const energyRows = (opts.energy ?? []).map((value, i) => ({ - value, + const energyRows = (opts.energy ?? []).map((entry, i) => ({ + ...(typeof entry === "number" ? { value: entry } : entry), + measuredAt: new Date(Date.UTC(2026, 5, 1, 6 + i)), id: `e${i}`, })); const measurementFindMany = vi.fn().mockResolvedValue(energyRows); @@ -577,6 +580,21 @@ describe("canonicalWorkoutTrimps — one impulse per session", () => { ).toEqual([15, 20, 40]); }); + it("never adds a source off the ladder to another source's session", () => { + // WHOOP and IMPORT are both off the workout ladder, so the picker keeps + // both rows of the slot: one session, the larger impulse. + const rows = [row("w", "WHOOP", 50), row("i", "IMPORT", 45, 1)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([50]); + }); + + it("still adds two sessions of one source in the same slot", () => { + const rows = [ + { ...row("w1", "WHOOP", 30), durationSec: 600 }, + { ...row("w2", "WHOOP", 20, 2), durationSec: 900 }, + ]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([50]); + }); + it("is empty when nothing carries an impulse", () => { expect( canonicalWorkoutTrimps([row("a", "APPLE_HEALTH", 0)], null, () => 0), @@ -584,6 +602,46 @@ describe("canonicalWorkoutTrimps — one impulse per session", () => { }); }); +describe("canonicalDayActiveEnergy — one source per day", () => { + const at = new Date("2026-06-01T09:00:00.000Z"); + const e = (source: string, value: number) => ({ + source: source as "APPLE_HEALTH", + value, + measuredAt: at, + }); + + it("keeps the ladder's first source, not the sum of two", () => { + expect( + canonicalDayActiveEnergy( + [e("APPLE_HEALTH", 200), e("OURA", 280), e("APPLE_HEALTH", 100)], + null, + ), + ).toBe(300); + }); + + it("keeps the larger total when no source is on the ladder", () => { + expect( + canonicalDayActiveEnergy([e("WHOOP", 200), e("IMPORT", 150)], null), + ).toBe(200); + }); + + it("drives the active-energy fallback score", async () => { + const { prisma } = makePrisma({ + rhr: 50, + energy: [ + { value: 300, source: "APPLE_HEALTH" }, + { value: 290, source: "OURA" }, + ], + workouts: [], + }); + const result = await persistStrainScore(prisma, "user-1", NOW); + expect(result.reason).toBe("active_energy_fallback"); + expect(result.score).toBe( + saturateToScore(300, STRAIN_ACTIVE_ENERGY_REFERENCE), + ); + }); +}); + describe("persistStrainScore — duplicate sessions", () => { it("scores a session stored twice the same as the session stored once", async () => { const series = { diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 52e02f51a..ee87a461b 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -75,8 +75,13 @@ import { shiftDateKey } from "@/lib/tz/format"; import { pickCanonicalWorkoutRows, workoutDuplicateSlotKey, + workoutSourceLadder, type WorkoutPickerRow, } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + pickCanonicalSourceRows, + type SourcePickerRow, +} from "@/lib/analytics/source-priority"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -413,10 +418,16 @@ export async function computeStrainScore( deletedAt: null, measuredAt: { gte: dayStart, lt: dayEnd }, }, - select: { value: true }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + select: { + value: true, + source: true, + deviceType: true, + measuredAt: true, + type: true, + }, take: 5000, }); - const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0); // The day's workouts + their HR series, with the session identity the // cross-source picker needs: one run recorded by the watch and synced again @@ -441,6 +452,13 @@ export async function computeStrainScore( }), ]); + // The watch and the phone relay (or a ring) both write the day's active + // energy: one source per day, the same pick the energy chart applies. + const dayActiveEnergy = canonicalDayActiveEnergy( + energyRows, + account?.sourcePriorityJson ?? null, + ); + const hrMax = profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; @@ -529,6 +547,31 @@ export async function computeStrainScore( }; } +/** + * The day's active energy from ONE source. The rows of a single scored day go + * through the cumulative-metric picker on the `activeEnergy` ladder (source, + * then device type); when no source on the ladder is present the picker keeps + * every row, so the sources left are then compared by their day total and the + * largest kept: never two sources' totals added. Pure; exported for tests. + */ +export function canonicalDayActiveEnergy< + T extends SourcePickerRow & { value: number }, +>(rows: readonly T[], priorityJson: unknown): number { + const { canonicalRows } = pickCanonicalSourceRows( + rows, + "activeEnergy", + priorityJson, + () => "day", + ); + const bySource = new Map(); + for (const row of canonicalRows) { + bySource.set(row.source, (bySource.get(row.source) ?? 0) + row.value); + } + let best = 0; + for (const total of bySource.values()) best = Math.max(best, total); + return best; +} + /** * One training impulse per workout SESSION, not per stored row. * @@ -542,6 +585,13 @@ export async function computeStrainScore( * rate: the winning row can be one without a series while its dropped twin * carries one. So the impulse of a slot is the sum over its picked rows, and * only when that is zero the largest impulse among the slot's dropped rows. + * + * The picker keeps every row of a slot none of whose sources is on the + * ladder, so the picked rows of one slot can come from several sources. Only + * one source's rows count: the best-ranked on the ladder, a source off the + * ladder ranking below every listed one, and among sources off the ladder + * the one with the largest impulse. Rows of that one source still add up + * (two distinct sessions of the same sport in one five-minute slot). * Returns the positive impulses, one per session. Pure; exported for tests. */ export function canonicalWorkoutTrimps< @@ -556,20 +606,42 @@ export function canonicalWorkoutTrimps< const picked = new Set( pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), ); - const slots = new Map(); + const ladder = workoutSourceLadder(priorityJson); + const rankOf = (source: string) => { + const index = ladder.indexOf(source as (typeof ladder)[number]); + return index === -1 ? ladder.length : index; + }; + const slots = new Map< + string, + { picked: Map; dropped: number } + >(); for (const row of rows) { const key = workoutDuplicateSlotKey(row); - const slot = slots.get(key) ?? { picked: 0, dropped: 0 }; + const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 }; const trimp = trimpOf(row); if (trimp > 0) { - if (picked.has(row.id)) slot.picked += trimp; - else slot.dropped = Math.max(slot.dropped, trimp); + if (picked.has(row.id)) { + slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp); + } else { + slot.dropped = Math.max(slot.dropped, trimp); + } } slots.set(key, slot); } const out: number[] = []; for (const slot of slots.values()) { - const trimp = slot.picked > 0 ? slot.picked : slot.dropped; + let best: { rank: number; trimp: number } | null = null; + for (const [source, trimp] of slot.picked) { + const rank = rankOf(source); + if ( + best === null || + rank < best.rank || + (rank === best.rank && trimp > best.trimp) + ) { + best = { rank, trimp }; + } + } + const trimp = best !== null ? best.trimp : slot.dropped; if (trimp > 0) out.push(trimp); } return out; From cfe5e2d3c64b3d73b04e3610f5ee66970003f791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:31:58 +0200 Subject: [PATCH 68/98] fix(report): release the medication effect only with the medication list The effect block names every medication it describes and its confounders, so a report or share link that withheld the medication list still carried the names. The aggregator now loads the block only when both leaves are admitted, the clinician view checks the list leaf as well, and the leaf label says it needs the list. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- .../__tests__/clinician-view.test.tsx | 47 +++++++++++++++++++ .../clinician/activity-effect-sections.tsx | 6 ++- src/lib/doctor-report-types.ts | 5 +- .../__tests__/leaf-gating-sweep.test.ts | 27 +++++++++++ src/lib/doctor-report/collect.ts | 6 ++- 13 files changed, 95 insertions(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index f3e2a8458..332273ff2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "Operationen und Eingriffe", "leafImmunizations": "Impfungen", "leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)", - "leafMedicationEffect": "Veränderung rund um den Start jedes Medikaments", + "leafMedicationEffect": "Veränderung rund um den Start jedes Medikaments (braucht die Medikamentenliste)", "leafWorkouts": "Trainings (Übersicht je Sportart)", "leafHrvSdnn": "HRV (SDNN)" }, diff --git a/messages/en.json b/messages/en.json index 292300819..df76fca81 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "Surgical history", "leafImmunizations": "Immunizations", "leafEmergency": "Emergency information (blood type, contacts, directives)", - "leafMedicationEffect": "Change around each medication start", + "leafMedicationEffect": "Change around each medication start (needs the medication list)", "leafWorkouts": "Workouts (summary per sport)", "leafHrvSdnn": "HRV (SDNN)" }, diff --git a/messages/es.json b/messages/es.json index 2e222ca60..a41820b06 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "Antecedentes quirúrgicos", "leafImmunizations": "Vacunas", "leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)", - "leafMedicationEffect": "Cambio en torno al inicio de cada medicamento", + "leafMedicationEffect": "Cambio en torno al inicio de cada medicamento (requiere la lista de medicamentos)", "leafWorkouts": "Entrenamientos (resumen por deporte)", "leafHrvSdnn": "VFC (SDNN)" }, diff --git a/messages/fr.json b/messages/fr.json index ce2b8f4ff..2c7feebcd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "Antécédents chirurgicaux", "leafImmunizations": "Vaccinations", "leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)", - "leafMedicationEffect": "Changement autour du démarrage de chaque médicament", + "leafMedicationEffect": "Changement autour du démarrage de chaque médicament (nécessite la liste des médicaments)", "leafWorkouts": "Entraînements (résumé par sport)", "leafHrvSdnn": "VFC (SDNN)" }, diff --git a/messages/it.json b/messages/it.json index f7a72c126..7d623bd08 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "Anamnesi chirurgica", "leafImmunizations": "Vaccinazioni", "leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)", - "leafMedicationEffect": "Variazione intorno all'inizio di ogni farmaco", + "leafMedicationEffect": "Variazione intorno all'inizio di ogni farmaco (richiede l'elenco dei farmaci)", "leafWorkouts": "Allenamenti (riepilogo per sport)", "leafHrvSdnn": "HRV (SDNN)" }, diff --git a/messages/ko.json b/messages/ko.json index dce35a661..bbfc55376 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "수술 이력", "leafImmunizations": "예방접종", "leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)", - "leafMedicationEffect": "각 약 시작 전후의 변화", + "leafMedicationEffect": "각 약 시작 전후의 변화 (약 목록 필요)", "leafWorkouts": "운동(종목별 요약)", "leafHrvSdnn": "심박변이도 (SDNN)" }, diff --git a/messages/nl.json b/messages/nl.json index 1af10b087..3b16274f8 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "Operatiegeschiedenis", "leafImmunizations": "Vaccinaties", "leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)", - "leafMedicationEffect": "Verandering rond de start van elk medicijn", + "leafMedicationEffect": "Verandering rond de start van elk medicijn (vereist de medicatielijst)", "leafWorkouts": "Trainingen (overzicht per sport)", "leafHrvSdnn": "HRV (SDNN)" }, diff --git a/messages/pl.json b/messages/pl.json index ccbf60069..c1e0aee67 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12427,7 +12427,7 @@ "leafSurgicalHistory": "Historia operacji", "leafImmunizations": "Szczepienia", "leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)", - "leafMedicationEffect": "Zmiana wokół rozpoczęcia każdego leku", + "leafMedicationEffect": "Zmiana wokół rozpoczęcia każdego leku (wymaga listy leków)", "leafWorkouts": "Treningi (podsumowanie według dyscypliny)", "leafHrvSdnn": "HRV (SDNN)" }, diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 2492591bc..7a765a28e 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -163,6 +163,53 @@ describe("", () => { expect(html).not.toContain("Current smoker"); }); + it("shows the medication effect only beside the medication list", () => { + const report = makeReport({ + medicationEffects: [ + { + medicationName: "Named effect drug", + startsOn: "2026-01-05", + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + present: false, + reason: "insufficient_after", + level: 0.95, + window: null, + days: { + before: 40, + after: 3, + excludedLowAdherence: 0, + excludedNotTaking: 0, + }, + adherence: { + status: "tracked", + floorPct: 80, + lookbackDays: 14, + }, + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + confounders: [], + }, + }, + ], + }, + ], + }); + const leaves = ["MEDICATION_EFFECT", "BLOOD_PRESSURE_SYS"] as const; + expect( + render(report, { + selection: selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + }), + ).toContain("Named effect drug"); + expect( + render(report, { selection: selectionFromLeaves([...leaves]) }), + ).not.toContain("Named effect drug"); + }); + it("names both windows when a lab's report and the saved band disagree", () => { // Two windows that disagree about the same number, and a card showing one // of them is a partial answer. The PDF settles this with a footnote under diff --git a/src/components/clinician/activity-effect-sections.tsx b/src/components/clinician/activity-effect-sections.tsx index a920eb3c7..33f07921a 100644 --- a/src/components/clinician/activity-effect-sections.tsx +++ b/src/components/clinician/activity-effect-sections.tsx @@ -113,7 +113,11 @@ export function MedicationEffectSection({ /** A stated calendar day (YYYY-MM-DD) in the owner's date order. */ fmtDay: (day: string) => string; }) { - const effects = report.medicationEffects ?? []; + // The block names medications: without the list leaf it shows nothing, + // whatever the payload carries. + const effects = scope.admits("MEDICATION_LIST") + ? (report.medicationEffects ?? []) + : []; const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); return ( { expect(off.medications.length).toBeGreaterThan(0); }); + it("withholds the medication effect without the medication list", async () => { + // The effect block names every medication it describes, so withholding + // the list must withhold the names here too. + const leaves: ReportLeafId[] = [ + "MEDICATION_EFFECT", + ...MEASUREMENT_LEAF_IDS, + ]; + const withList = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + { moduleMap: moduleMap() }, + ); + expect(withList.medicationEffects?.length ?? 0).toBeGreaterThan(0); + + const withoutList = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(leaves), + { moduleMap: moduleMap() }, + ); + expect(withoutList.medicationEffects ?? null).toBeNull(); + expect(JSON.stringify(withoutList)).not.toContain( + withList.medicationEffects![0].medicationName, + ); + }); + it("withholds the glucose panel while the raw series stays", async () => { const withPanel = await collectDoctorReportData( "u1", diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index a38c3313b..371d49918 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -674,7 +674,11 @@ export async function collectDoctorReportData( : Promise.resolve(null), // The effect around each listed medication's start. Its own leaf AND the // target's measurement leaf must both admit it; the loader asks the gate. - gate.admits("MEDICATION_EFFECT") + // The block names every medication it describes (and the confounders), + // so it also needs the medication list: an owner who withheld the list + // never releases the names through the effect block. Same rule as the + // clinician view's compliance rows, which are named from the list. + gate.admits("MEDICATION_EFFECT") && gate.admits("MEDICATION_LIST") ? loadReportMedicationEffects({ userId, medicationIds: medications.map((m) => m.id), From bb6982aea29e97579c0ab9c7bca0020fda046a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:37:09 +0200 Subject: [PATCH 69/98] feat(report): name the analysed window of each medication effect The effect is fitted on up to 90 days before a start and 180 after it, which can lie outside the report period. The PDF notes and the clinician view now state the fitted window for every effect shown. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/nl.json | 4 +- messages/pl.json | 4 +- .../__tests__/clinician-view.test.tsx | 37 +++++++++++++++++++ .../clinician/activity-effect-sections.tsx | 8 ++++ .../activity-effect-sections.test.ts | 5 +++ .../activity-effect-sections.ts | 14 +++++-- 12 files changed, 84 insertions(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index 332273ff2..f2480fafb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -432,7 +432,9 @@ "endDoseChange": "Zeitraum endet am {date}, vor der ersten Dosisänderung", "days": "{before} Tage vor, {after} Tage nach dem Start, {excluded} Tage ausgelassen", "method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments.", - "autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein" + "autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein", + "window": "ausgewertet {from} bis {to}, das kann über den Berichtszeitraum hinausreichen", + "windowLine": "Ausgewertet {from} bis {to}. Dieser Zeitraum kann über den Berichtszeitraum hinausreichen." } }, "errorBoundary": { diff --git a/messages/en.json b/messages/en.json index df76fca81..4cdf487f5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -432,7 +432,9 @@ "endDoseChange": "window ends {date}, before the first dose change", "days": "{before} days before, {after} days after the start, {excluded} days left out", "method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication.", - "autocorrelation": "strong day-to-day carry-over, so the ranges may still be too narrow" + "autocorrelation": "strong day-to-day carry-over, so the ranges may still be too narrow", + "window": "analysed {from} to {to}, which can reach beyond the report period", + "windowLine": "Analysed {from} to {to}. This window can reach beyond the report period." } }, "errorBoundary": { diff --git a/messages/es.json b/messages/es.json index a41820b06..8d7873a7c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -432,7 +432,9 @@ "endDoseChange": "el periodo termina el {date}, antes del primer cambio de dosis", "days": "{before} días antes, {after} días después del inicio, {excluded} días omitidos", "method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento.", - "autocorrelation": "fuerte dependencia del día anterior, así que los rangos pueden seguir siendo demasiado estrechos" + "autocorrelation": "fuerte dependencia del día anterior, así que los rangos pueden seguir siendo demasiado estrechos", + "window": "analizado del {from} al {to}, puede ir más allá del periodo del informe", + "windowLine": "Analizado del {from} al {to}. Este intervalo puede ir más allá del periodo del informe." } }, "errorBoundary": { diff --git a/messages/fr.json b/messages/fr.json index 2c7feebcd..a689b23bc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -432,7 +432,9 @@ "endDoseChange": "la période s'arrête le {date}, avant le premier changement de dose", "days": "{before} jours avant, {after} jours après le démarrage, {excluded} jours écartés", "method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament.", - "autocorrelation": "forte dépendance à la veille, les plages peuvent donc rester trop étroites" + "autocorrelation": "forte dépendance à la veille, les plages peuvent donc rester trop étroites", + "window": "analysé du {from} au {to}, ce qui peut dépasser la période du rapport", + "windowLine": "Analysé du {from} au {to}. Cette fenêtre peut dépasser la période du rapport." } }, "errorBoundary": { diff --git a/messages/it.json b/messages/it.json index 7d623bd08..276cfd2c3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -432,7 +432,9 @@ "endDoseChange": "il periodo termina il {date}, prima della prima variazione di dose", "days": "{before} giorni prima, {after} giorni dopo l'inizio, {excluded} giorni esclusi", "method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco.", - "autocorrelation": "forte dipendenza dal giorno precedente, quindi gli intervalli possono restare troppo stretti" + "autocorrelation": "forte dipendenza dal giorno precedente, quindi gli intervalli possono restare troppo stretti", + "window": "analizzato dal {from} al {to}, può andare oltre il periodo del report", + "windowLine": "Analizzato dal {from} al {to}. Questo intervallo può andare oltre il periodo del report." } }, "errorBoundary": { diff --git a/messages/ko.json b/messages/ko.json index bbfc55376..de47beccd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -432,7 +432,9 @@ "endDoseChange": "기간은 첫 용량 변경 전인 {date}에 끝남", "days": "시작 전 {before}일, 시작 후 {after}일, 제외 {excluded}일", "method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다.", - "autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음" + "autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음", + "window": "{from}부터 {to}까지 분석, 보고 기간을 넘어설 수 있음", + "windowLine": "{from}부터 {to}까지 분석했습니다. 이 기간은 보고 기간을 넘어설 수 있습니다." } }, "errorBoundary": { diff --git a/messages/nl.json b/messages/nl.json index 3b16274f8..7b94c1ea4 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -432,7 +432,9 @@ "endDoseChange": "periode eindigt op {date}, vóór de eerste dosiswijziging", "days": "{before} dagen voor, {after} dagen na de start, {excluded} dagen weggelaten", "method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn.", - "autocorrelation": "sterke afhankelijkheid van de vorige dag, dus de bereiken kunnen nog te smal zijn" + "autocorrelation": "sterke afhankelijkheid van de vorige dag, dus de bereiken kunnen nog te smal zijn", + "window": "geanalyseerd van {from} tot {to}, dit kan buiten de rapportperiode vallen", + "windowLine": "Geanalyseerd van {from} tot {to}. Deze periode kan buiten de rapportperiode vallen." } }, "errorBoundary": { diff --git a/messages/pl.json b/messages/pl.json index c1e0aee67..7fc928cd2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -432,7 +432,9 @@ "endDoseChange": "okres kończy się {date}, przed pierwszą zmianą dawki", "days": "{before} dni przed, {after} dni po rozpoczęciu, pominięto {excluded} dni", "method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku.", - "autocorrelation": "silna zależność od poprzedniego dnia, więc zakresy mogą nadal być zbyt wąskie" + "autocorrelation": "silna zależność od poprzedniego dnia, więc zakresy mogą nadal być zbyt wąskie", + "window": "analizowano od {from} do {to}, może to wykraczać poza okres raportu", + "windowLine": "Analizowano od {from} do {to}. Ten okres może wykraczać poza okres raportu." } }, "errorBoundary": { diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 7a765a28e..49eb46428 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -200,6 +200,43 @@ describe("", () => { ], }); const leaves = ["MEDICATION_EFFECT", "BLOOD_PRESSURE_SYS"] as const; + const est = { + estimate: -5, + ciLow: -7, + ciHigh: -3, + standardError: 1, + includesZero: false, + }; + const fitted = makeReport({ + medicationEffects: [ + { + ...report.medicationEffects![0], + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + ...report.medicationEffects![0].targets[0].effect, + present: true, + reason: undefined, + levelChange: est, + slopeChangePerWeek: est, + preSlopePerWeek: est, + window: { + from: "2025-10-07", + to: "2026-01-20", + endReason: "today", + }, + }, + }, + ], + }, + ], + }); + expect( + render(fitted, { + selection: selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + }), + ).toContain("This window can reach beyond the report period."); expect( render(report, { selection: selectionFromLeaves([...leaves, "MEDICATION_LIST"]), diff --git a/src/components/clinician/activity-effect-sections.tsx b/src/components/clinician/activity-effect-sections.tsx index 33f07921a..d6a2629f1 100644 --- a/src/components/clinician/activity-effect-sections.tsx +++ b/src/components/clinician/activity-effect-sections.tsx @@ -163,6 +163,14 @@ export function MedicationEffectSection({ unit, })}

    + {e.window ? ( +

    + {t("doctorReport.medicationEffect.windowLine", { + from: fmtDay(e.window.from), + to: fmtDay(e.window.to), + })} +

    + ) : null}

    {t("doctorReport.medicationEffect.days", { before: e.days.before, diff --git a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts index 94daa7421..46aa08183 100644 --- a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts +++ b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts @@ -217,6 +217,11 @@ describe("doctor report v1.44 sections", () => { expect(text).toContain("-0.3 mmHg/week"); expect(text).toContain("84 / 61"); expect(text).toContain("5 days after the start left out"); + // The fitted window (Dec to Apr) reaches outside the Feb to May report + // period; the report says which days the figures rest on. + expect(text).toMatch( + /analysed \S+ to \S+, which can reach beyond the report period/, + ); expect(text).toContain("Other drug started on"); expect(text).toContain("not a proven effect"); expect(text).not.toContain("carry-over"); diff --git a/src/lib/doctor-report-pdf/activity-effect-sections.ts b/src/lib/doctor-report-pdf/activity-effect-sections.ts index 819918bbc..e122c7e8f 100644 --- a/src/lib/doctor-report-pdf/activity-effect-sections.ts +++ b/src/lib/doctor-report-pdf/activity-effect-sections.ts @@ -213,7 +213,15 @@ export function buildMedicationEffectSection( `${slope.value} ${unit}/${t("doctorReport.medicationEffect.week")}\n(${slope.low} ${t("doctorReport.medicationEffect.to")} ${slope.high})`, `${e.days.before} / ${e.days.after}`, ]); - const extras: string[] = []; + // The fitted window reaches up to 90 days before the start and 180 + // after it, so it can lie partly outside the report period: always + // say which days the figures rest on. + const extras: string[] = [ + t("doctorReport.medicationEffect.window", { + from: dayLabel(fmtDate, e.window.from), + to: dayLabel(fmtDate, e.window.to), + }), + ]; if (e.days.excludedLowAdherence > 0) { extras.push( t("doctorReport.medicationEffect.excludedAdherence", { @@ -250,9 +258,7 @@ export function buildMedicationEffectSection( }), ); } - if (extras.length > 0) { - notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`); - } + notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`); } } if (rows.length === 0) return state; From b828870e1b2d8d9e0631bfaa57414860f4da2f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:42:34 +0200 Subject: [PATCH 70/98] fix(import): cut lab CSV duplicate days in the zone the file is read in The rows were parsed in the query's timezone while the duplicate keys used the account's, so two draws on separate days of the file's zone could collapse into one, or one day split in two. --- src/lib/import/labs-csv.ts | 20 +++++++++------ tests/integration/record-csv-import.test.ts | 27 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/lib/import/labs-csv.ts b/src/lib/import/labs-csv.ts index 86e4b202c..3c874dbfe 100644 --- a/src/lib/import/labs-csv.ts +++ b/src/lib/import/labs-csv.ts @@ -275,6 +275,10 @@ export async function importLabsCsv(args: { }): Promise { const { userId, tz, query } = args; const dryRun = query.dryRun; + // One zone for everything: the file's times are read in it, and the + // calendar day a reading is a duplicate on is cut in it too. Cutting the + // days in the account's zone while reading the file in another split one + // draw day in two, or folded two into one. const timezone = query.timezone ?? tz; const report = new RecordImportReport("labs", dryRun); const index: MarkerIndex = await loadMarkerIndex(prisma, userId); @@ -365,7 +369,9 @@ export async function importLabsCsv(args: { } // 2. Duplicates against the record, one query for the chunk. - const days = planned.map((p) => labReadingDay(p.row.takenAt, tz)).sort(); + const days = planned + .map((p) => labReadingDay(p.row.takenAt, timezone)) + .sort(); const existingKeys = new Set(); if (planned.length > 0) { const from = labReadingDaySearchRange(days[0]).gte; @@ -396,10 +402,10 @@ export async function importLabsCsv(args: { }, }); for (const e of existing) { - for (const k of readingKeys(e, tz)) existingKeys.add(k); + for (const k of readingKeys(e, timezone)) existingKeys.add(k); // A reading filed under a marker also answers to its printed name. if (e.biomarkerId) { - for (const k of readingKeys({ ...e, biomarkerId: null }, tz)) + for (const k of readingKeys({ ...e, biomarkerId: null }, timezone)) existingKeys.add(k); } } @@ -418,10 +424,10 @@ export async function importLabsCsv(args: { sourceUnit: row.unit, }; const keys = [ - ...readingKeys(probe, tz), - ...readingKeys({ ...probe, biomarkerId: null }, tz), + ...readingKeys(probe, timezone), + ...readingKeys({ ...probe, biomarkerId: null }, timezone), ...(marker - ? readingKeys({ ...probe, biomarkerId: marker.id }, tz) + ? readingKeys({ ...probe, biomarkerId: marker.id }, timezone) : []), ]; if (keys.some((k) => existingKeys.has(k) || seen.has(k))) { @@ -506,7 +512,7 @@ export async function importLabsCsv(args: { }); for (const k of [ ...keys, - ...readingKeys({ ...probe, biomarkerId: biomarker.id }, tz), + ...readingKeys({ ...probe, biomarkerId: biomarker.id }, timezone), ]) seen.add(k); report.valid += 1; diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index 665fc6da6..7164cab39 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -176,6 +176,33 @@ describe("lab CSV import", () => { }); }); +describe("lab CSV import in a zone of the file's own", () => { + it("cuts the duplicate day in the zone the file is read in", async () => { + // Read in New York, these are draws on two days (1 and 2 March). In the + // account's Berlin they both fall on 2 March, which used to make the + // second a duplicate of the first. + const csv = [ + "Analyt;Wert;Einheit;Datum", + "Zone marker;7;mg/dL;2025-03-01T22:30", + "Zone marker;7;mg/dL;2025-03-02T02:00", + ].join("\n"); + const result = await importLabsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(csv), + maxBytes: 1_000_000, + query: labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + timezone: "America/New_York", + }), + }); + expect(result).toMatchObject({ inserted: 2, duplicates: 0 }); + }); +}); + describe("medication CSV import", () => { const MEDS = [ "name,dose,times,as needed,start,end", From f8c17ee9bb28029518f0a3e160a1d43a2566bbe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:49:23 +0200 Subject: [PATCH 71/98] fix(import): write lab CSV rows one chunk at a time and audit cut-off imports Every accepted row was its own insert, so a 20 000-row file meant 20 000 round trips and an import cut off part way left an arbitrary prefix behind. Each 200-row chunk is now one createMany: it lands whole or not at all, and uploading the same file again skips the written chunks as duplicates. The importers report each committed write, and the audit row is written in a finally block, marked partial with the rows written when the import did not finish. --- .../__tests__/record-import-route.test.ts | 110 ++++++++ src/lib/import/labs-csv.ts | 57 +++-- src/lib/import/medications-csv.ts | 234 +++++++++--------- src/lib/import/record-import-route.ts | 44 ++-- tests/integration/record-csv-import.test.ts | 91 +++++++ 5 files changed, 387 insertions(+), 149 deletions(-) create mode 100644 src/lib/import/__tests__/record-import-route.test.ts diff --git a/src/lib/import/__tests__/record-import-route.test.ts b/src/lib/import/__tests__/record-import-route.test.ts new file mode 100644 index 000000000..33b74f93b --- /dev/null +++ b/src/lib/import/__tests__/record-import-route.test.ts @@ -0,0 +1,110 @@ +/** + * The shared CSV record-import request side: the audit row is written for a + * finished import and for one cut off part way, with how many rows landed. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod/v4"; + +const { auditLog } = vi.hoisted(() => ({ auditLog: vi.fn() })); + +vi.mock("@/lib/api-handler", () => ({ + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "UTC"), +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { handleRecordImport } from "../record-import-route"; +import type { RecordImportResult } from "@/lib/validations/record-import"; + +const schema = z + .object({ dryRun: z.enum(["0", "1"]).transform((v) => v === "1") }) + .strict(); + +function request(dryRun = "0") { + return new Request(`http://localhost/api/import/labs-csv?dryRun=${dryRun}`, { + method: "POST", + headers: { "content-type": "text/csv" }, + body: "a,b\n1,2\n", + }) as never; +} + +const DONE = { + kind: "labs", + dryRun: false, + rows: 400, + valid: 400, + inserted: 400, + duplicates: 0, + failed: 0, +} as unknown as RecordImportResult; + +beforeEach(() => auditLog.mockReset()); + +describe("handleRecordImport audit", () => { + it("audits a finished import as complete", async () => { + await handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async ({ onWritten }) => { + onWritten(200); + onWritten(200); + return DONE; + }, + }); + expect(auditLog).toHaveBeenCalledTimes(1); + expect(auditLog.mock.calls[0][1].details).toMatchObject({ + inserted: 400, + partial: false, + written: 400, + }); + }); + + it("audits an import cut off part way with what it wrote", async () => { + await expect( + handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async ({ onWritten }) => { + onWritten(200); + throw new Error("client went away"); + }, + }), + ).rejects.toThrow("client went away"); + expect(auditLog).toHaveBeenCalledTimes(1); + expect(auditLog.mock.calls[0][1].details).toEqual({ + partial: true, + written: 200, + }); + }); + + it("writes no audit row for a dry run or an abort that wrote nothing", async () => { + await handleRecordImport(request("1"), { + kind: "labs", + module: "labs", + schema, + run: async () => ({ ...DONE, dryRun: true }), + }); + await expect( + handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async () => { + throw new Error("boom"); + }, + }), + ).rejects.toThrow("boom"); + expect(auditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/import/labs-csv.ts b/src/lib/import/labs-csv.ts index 3c874dbfe..e3308503a 100644 --- a/src/lib/import/labs-csv.ts +++ b/src/lib/import/labs-csv.ts @@ -13,7 +13,14 @@ * Memory: the file is read record by record (`readCsvRecords`) and written in * chunks of `CHUNK_ROWS`; what is held across chunks is the marker index, the * duplicate keys of rows already accepted and the bounded report. + * + * Writes: one `createMany` per chunk, so a chunk lands whole or not at all. + * An import cut off part way (the client went away, a proxy timed out) leaves + * whole chunks behind, and uploading the same file again skips them as + * duplicates and writes the rest: a retry is idempotent. `onWritten` reports + * each committed chunk so the caller can audit a partial import. */ +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { readCsvRecords, @@ -272,6 +279,8 @@ export async function importLabsCsv(args: { maxBytes: number; query: LabsCsvQuery; now?: Date; + /** Called after each committed chunk with the rows it wrote. */ + onWritten?: (rows: number) => void; }): Promise { const { userId, tz, query } = args; const dryRun = query.dryRun; @@ -411,7 +420,9 @@ export async function importLabsCsv(args: { } } - // 3. Write (or count) what is new. + // 3. Write (or count) what is new: rows are built here and written in + // one statement for the chunk. + const toWrite: Prisma.LabResultCreateManyInput[] = []; for (const { row, marker } of planned) { const probe = { biomarkerId: @@ -489,26 +500,24 @@ export async function importLabsCsv(args: { } // Field by field, never a spread of the parsed row. - await prisma.labResult.create({ - data: { - userId, - biomarkerId: biomarker.id, - panel: row.panel ?? biomarker.panel, - analyte: biomarker.name, - value: storedValue, - valueText: row.valueText, - unit: biomarker.unit, - sourceValue: convertedFrom?.sourceValue ?? null, - sourceUnit: convertedFrom?.sourceUnit ?? null, - referenceLow: biomarker.lowerBound, - referenceHigh: biomarker.upperBound, - sourceReferenceLow: storedLow, - sourceReferenceHigh: storedHigh, - sourceReferenceText: row.sourceReferenceText, - takenAt: row.takenAt, - source: "IMPORT", - noteEncrypted: row.note ? encryptNoteToBytes(row.note) : null, - }, + toWrite.push({ + userId, + biomarkerId: biomarker.id, + panel: row.panel ?? biomarker.panel, + analyte: biomarker.name, + value: storedValue, + valueText: row.valueText, + unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, + referenceLow: biomarker.lowerBound, + referenceHigh: biomarker.upperBound, + sourceReferenceLow: storedLow, + sourceReferenceHigh: storedHigh, + sourceReferenceText: row.sourceReferenceText, + takenAt: row.takenAt, + source: "IMPORT", + noteEncrypted: row.note ? encryptNoteToBytes(row.note) : null, }); for (const k of [ ...keys, @@ -516,7 +525,11 @@ export async function importLabsCsv(args: { ]) seen.add(k); report.valid += 1; - report.inserted += 1; + } + if (toWrite.length > 0) { + const { count } = await prisma.labResult.createMany({ data: toWrite }); + report.inserted += count; + args.onWritten?.(count); } }; diff --git a/src/lib/import/medications-csv.ts b/src/lib/import/medications-csv.ts index d616ce675..354bfab13 100644 --- a/src/lib/import/medications-csv.ts +++ b/src/lib/import/medications-csv.ts @@ -185,6 +185,8 @@ export async function importMedicationsCsv(args: { maxBytes: number; query: MedicationsCsvQuery; now?: Date; + /** Called after each medication written. */ + onWritten?: (rows: number) => void; }): Promise { const { userId, tz, query } = args; const dryRun = query.dryRun; @@ -207,125 +209,131 @@ export async function importMedicationsCsv(args: { ? "\t" : "auto"; - let first = true; - for await (const record of readCsvRecords(args.stream, { - maxBytes: args.maxBytes, - delimiter, - })) { - if (first && query.header) { + let written = 0; + try { + let first = true; + for await (const record of readCsvRecords(args.stream, { + maxBytes: args.maxBytes, + delimiter, + })) { + if (first && query.header) { + first = false; + continue; + } first = false; - continue; - } - first = false; - report.rows += 1; - if (report.rows > MEDICATIONS_CSV_MAX_ROWS) { - report.fail(record.line, "row_limit"); - continue; - } - if (record.oversized) { - report.fail(record.line, "oversized_row"); - continue; - } - const result = normaliseMedicationRow(record, query, timezone); - if (!result.ok) { - report.fail(record.line, result.reason, result.field); - continue; - } - const row = result.row; - const parsed = createMedicationSchema.safeParse( - medicationCreateBody(row, query.reminders), - ); - if (!parsed.success) { - report.fail(row.line, "invalid_medication"); - continue; - } - // The course the create route would open for this window. - let course: { startsOn: Date; endsOn: Date | null } | null = null; - if (parsed.data.startsOn || parsed.data.endsOn) { - const endKey = parsed.data.endsOn - ? dayKeyOfDate(parsed.data.endsOn) - : null; - course = { - startsOn: - parsed.data.startsOn ?? - dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey), - endsOn: parsed.data.endsOn ?? null, - }; - if (validateCourses([course], todayKey, false)) { - report.fail(row.line, "end_before_start", "endsOn"); + report.rows += 1; + if (report.rows > MEDICATIONS_CSV_MAX_ROWS) { + report.fail(record.line, "row_limit"); + continue; + } + if (record.oversized) { + report.fail(record.line, "oversized_row"); + continue; + } + const result = normaliseMedicationRow(record, query, timezone); + if (!result.ok) { + report.fail(record.line, result.reason, result.field); + continue; + } + const row = result.row; + const parsed = createMedicationSchema.safeParse( + medicationCreateBody(row, query.reminders), + ); + if (!parsed.success) { + report.fail(row.line, "invalid_medication"); + continue; + } + // The course the create route would open for this window. + let course: { startsOn: Date; endsOn: Date | null } | null = null; + if (parsed.data.startsOn || parsed.data.endsOn) { + const endKey = parsed.data.endsOn + ? dayKeyOfDate(parsed.data.endsOn) + : null; + course = { + startsOn: + parsed.data.startsOn ?? + dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey), + endsOn: parsed.data.endsOn ?? null, + }; + if (validateCourses([course], todayKey, false)) { + report.fail(row.line, "end_before_start", "endsOn"); + continue; + } + } + const key = identity(row.name, row.dose); + if (seen.has(key)) { + report.duplicate(row.line); + continue; + } + seen.add(key); + report.valid += 1; + report.inserted += 1; + if (dryRun) { + report.addPreview({ + line: row.line, + name: row.name, + dose: row.dose, + times: row.times.join(" "), + asNeeded: row.asNeeded, + startsOn: row.startsOn, + endsOn: row.endsOn, + }); continue; } - } - const key = identity(row.name, row.dose); - if (seen.has(key)) { - report.duplicate(row.line); - continue; - } - seen.add(key); - report.valid += 1; - report.inserted += 1; - if (dryRun) { - report.addPreview({ - line: row.line, - name: row.name, - dose: row.dose, - times: row.times.join(" "), - asNeeded: row.asNeeded, - startsOn: row.startsOn, - endsOn: row.endsOn, - }); - continue; - } - const data = parsed.data; - // Field by field, with the create route's invariants for the shapes an - // import produces: a daily schedule (FREQ=DAILY, times dual-written), an - // opening course for a dated window, no schedule for as-needed. - const medication = await prisma.medication.create({ - data: { - userId, - name: data.name, - dose: data.dose, - asNeeded: data.asNeeded ?? false, - notificationsEnabled: data.notificationsEnabled ?? false, - ...(course - ? { - startsOn: course.startsOn, - endsOn: course.endsOn, - courses: { - create: { - userId, - startsOn: course.startsOn, - endsOn: course.endsOn, + const data = parsed.data; + // Field by field, with the create route's invariants for the shapes an + // import produces: a daily schedule (FREQ=DAILY, times dual-written), an + // opening course for a dated window, no schedule for as-needed. + const medication = await prisma.medication.create({ + data: { + userId, + name: data.name, + dose: data.dose, + asNeeded: data.asNeeded ?? false, + notificationsEnabled: data.notificationsEnabled ?? false, + ...(course + ? { + startsOn: course.startsOn, + endsOn: course.endsOn, + courses: { + create: { + userId, + startsOn: course.startsOn, + endsOn: course.endsOn, + }, }, - }, - } - : {}), - schedules: { - create: (data.schedules ?? []).map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: null, - dose: null, - daysOfWeek: serializeScheduleRecurrence({ - daysOfWeek: [], - intervalWeeks: 1, - }), - timesOfDay: - s.timesOfDay && s.timesOfDay.length > 0 - ? s.timesOfDay - : [s.windowStart], - rrule: "FREQ=DAILY", - })), + } + : {}), + schedules: { + create: (data.schedules ?? []).map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: null, + dose: null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: [], + intervalWeeks: 1, + }), + timesOfDay: + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart], + rrule: "FREQ=DAILY", + })), + }, }, - }, - select: { id: true }, - }); - await setMedicationCategory(medication.id, undefined); - } - - if (!dryRun && report.inserted > 0) { - invalidateUserMedications(userId, { evict: true }); + select: { id: true }, + }); + await setMedicationCategory(medication.id, undefined); + written += 1; + args.onWritten?.(1); + } + } finally { + // Also after an import cut off part way: what was written is visible. + if (!dryRun && written > 0) { + invalidateUserMedications(userId, { evict: true }); + } } return report.toResult(); } diff --git a/src/lib/import/record-import-route.ts b/src/lib/import/record-import-route.ts index ded29bc6e..8035becd4 100644 --- a/src/lib/import/record-import-route.ts +++ b/src/lib/import/record-import-route.ts @@ -47,6 +47,7 @@ export async function handleRecordImport< stream: ReadableStream; maxBytes: number; query: z.infer; + onWritten: (rows: number) => void; }) => Promise; }, ): Promise { @@ -102,7 +103,8 @@ export async function handleRecordImport< } const tz = await resolveUserTimezone(user.id); - let result: RecordImportResult; + let result: RecordImportResult | null = null; + let written = 0; try { result = await opts.run({ userId: user.id, @@ -110,6 +112,9 @@ export async function handleRecordImport< stream: request.body, maxBytes: RECORD_IMPORT_MAX_BYTES, query, + onWritten: (rows) => { + written += rows; + }, }); } catch (err) { if (err instanceof CsvTooLargeError) { @@ -118,25 +123,36 @@ export async function handleRecordImport< }); } throw err; + } finally { + // The audit row is written whatever happened: an import cut off part way + // (the client went away, a proxy timed out, the cap was hit after some + // chunks) has already written rows, and the trail says how many. + if ( + !query.dryRun && + ((result !== null && result.rows > 0) || written > 0) + ) { + await auditLog(action, { + userId: user.id, + ipAddress: getClientIp(request), + details: + result !== null + ? { + rows: result.rows, + inserted: result.inserted, + duplicates: result.duplicates, + failed: result.failed, + partial: false, + written, + } + : { partial: true, written }, + }); + } } if (result.rows === 0) { return apiError("The CSV file has no data rows", 422, { errorCode: "import.csv.empty", }); } - - if (!query.dryRun) { - await auditLog(action, { - userId: user.id, - ipAddress: getClientIp(request), - details: { - rows: result.rows, - inserted: result.inserted, - duplicates: result.duplicates, - failed: result.failed, - }, - }); - } annotate({ meta: { import_dry_run: query.dryRun, diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index 7164cab39..7e28273ab 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -203,6 +203,97 @@ describe("lab CSV import in a zone of the file's own", () => { }); }); +describe("lab CSV import at the row cap", () => { + const BIG_USER = "csv-import-big-user"; + // 20 000 rows: 40 markers, 500 draw days each, one reading per day. + const lines = ["Analyt;Wert;Einheit;Datum"]; + for (let i = 0; i < 20_000; i++) { + const marker = `Marker ${i % 40}`; + const day = new Date(Date.UTC(2020, 0, 1) + Math.floor(i / 40) * 86_400_000) + .toISOString() + .slice(0, 10); + lines.push(`${marker};${(i % 97) + 1};U/l;${day}`); + } + const BIG = lines.join("\n"); + const query = () => + labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + }); + + /** The file, failing like a dropped connection after `bytes`. */ + function cutAt(bytes: number): ReadableStream { + const all = new TextEncoder().encode(BIG); + let offset = 0; + return new ReadableStream({ + pull(controller) { + if (offset >= bytes) { + controller.error(new Error("connection reset")); + return; + } + controller.enqueue(all.slice(offset, Math.min(offset + 64_000, bytes))); + offset += 64_000; + }, + }); + } + + beforeAll(async () => { + await prisma.user.create({ + data: { id: BIG_USER, username: BIG_USER, timezone: TZ }, + }); + }); + + it("an import cut off part way leaves whole chunks, and a retry writes the rest once", async () => { + let written = 0; + await expect( + importLabsCsv({ + userId: BIG_USER, + tz: TZ, + stream: cutAt(Math.floor(BIG.length / 3)), + maxBytes: 16 * 1024 * 1024, + query: query(), + onWritten: (n) => { + written += n; + }, + }), + ).rejects.toThrow("connection reset"); + const afterCut = await prisma.labResult.count({ + where: { userId: BIG_USER }, + }); + expect(afterCut).toBe(written); + expect(afterCut).toBeGreaterThan(0); + // Whole chunks only: nothing half-written. + expect(afterCut % 200).toBe(0); + + const started = Date.now(); + const result = await importLabsCsv({ + userId: BIG_USER, + tz: TZ, + stream: textToStream(BIG), + maxBytes: 16 * 1024 * 1024, + query: query(), + }); + const elapsed = Date.now() - started; + expect(result).toMatchObject({ + rows: 20_000, + inserted: 20_000 - afterCut, + duplicates: afterCut, + failed: 0, + }); + expect(await prisma.labResult.count({ where: { userId: BIG_USER } })).toBe( + 20_000, + ); + expect(await prisma.biomarker.count({ where: { userId: BIG_USER } })).toBe( + 40, + ); + // Generous: the chunked write takes a few seconds here; the bound only + // catches a return to one statement per row. + expect(elapsed).toBeLessThan(120_000); + }, 300_000); +}); + describe("medication CSV import", () => { const MEDS = [ "name,dose,times,as needed,start,end", From 896ba233da4fc7c13ae22cfce955a2be4a46bbb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:56:16 +0200 Subject: [PATCH 72/98] fix(import): refuse unknown query parameters on the CSV imports The lab, medication and measurement CSV routes ignored parameters they did not know, so a misspelt dryrun=1 fell back to a real import and wrote the file the caller meant only to check. Their query schemas are strict now: an unknown parameter answers 422 before anything is read or a rate bucket is spent. --- docs/api/openapi.yaml | 36 +++++++++++-------- .../__tests__/record-csv-routes.test.ts | 10 ++++++ .../api/import/csv/__tests__/route.test.ts | 17 +++++++++ src/app/api/import/csv/route.ts | 12 +++++-- src/lib/openapi/routes/import.ts | 16 +++------ src/lib/validations/record-import.ts | 14 ++++++++ 6 files changed, 75 insertions(+), 30 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9effbc1d3..ecc92d47d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15308,17 +15308,21 @@ paths: reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews - without writing. Shares the 5/hour `import:` rate bucket." + without writing. Shares the 5/hour `import:` rate bucket. An unknown query parameter is refused with 422, so a + misspelt `dryRun` never writes." parameters: - - name: dryRun - in: query - required: false + - in: query + name: dryRun schema: + description: "`1` validates every row and reports what would happen, writing nothing." + default: "0" type: string enum: + - "0" - "1" - "true" - description: When `1` / `true`, parse + validate + return the per-row envelope without writing. + - "false" + description: "`1` validates every row and reports what would happen, writing nothing." requestBody: required: true content: @@ -15357,11 +15361,12 @@ paths: refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with - `module.disabled` when the module is switched off. Each row's name resolves to the account's marker the way a - scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A - numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed - value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a - qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows." + `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt + `dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do + (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; + one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise + the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date + without a time is stored as that calendar date. At most 20 000 data rows." parameters: - in: query name: dryRun @@ -15562,11 +15567,12 @@ paths: refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with - `module.disabled` when the module is switched off. Maps name, dose (as written), daily intake times, an - as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times - unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a - course. Reminders stay off unless `reminders=1`. A medication the record holds under the same name and dose is a - duplicate. At most 500 data rows." + `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt + `dryRun` never writes. Maps name, dose (as written), daily intake times, an as-needed mark and a start and end + date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each + row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless + `reminders=1`. A medication the record holds under the same name and dose is a duplicate. At most 500 data + rows." parameters: - in: query name: dryRun diff --git a/src/app/api/import/__tests__/record-csv-routes.test.ts b/src/app/api/import/__tests__/record-csv-routes.test.ts index 2c3ec0294..0c1ae9b70 100644 --- a/src/app/api/import/__tests__/record-csv-routes.test.ts +++ b/src/app/api/import/__tests__/record-csv-routes.test.ts @@ -100,6 +100,16 @@ describe("record CSV import routes", () => { expect(mocks.importLabsCsv).not.toHaveBeenCalled(); }); + it("refuses an unknown parameter, so a misspelt dry run never writes", async () => { + for (const typo of ["dryrun=1", "dry_run=1", "dryRun=1&preview=1"]) { + expect((await post(labsPost, `${LABS}&${typo}`)).status).toBe(422); + expect((await post(medsPost, `${MEDS}&${typo}`)).status).toBe(422); + } + expect(mocks.importLabsCsv).not.toHaveBeenCalled(); + expect(mocks.importMedicationsCsv).not.toHaveBeenCalled(); + expect(mocks.checkRateLimit).not.toHaveBeenCalled(); + }); + it("spends the preview bucket on a dry run and the import bucket otherwise", async () => { await post(labsPost, `${LABS}&dryRun=1`); expect(mocks.checkRateLimit).toHaveBeenLastCalledWith( diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index a6d8dcaa9..8ce841f24 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -164,6 +164,23 @@ const mMeasurement = () => updateMany: ReturnType; }; +describe("POST /api/import/csv — strict query", () => { + it("refuses a misspelt dry-run flag instead of importing", async () => { + for (const query of ["dryrun=1", "dry_run=1", "dryRun=yes"]) { + const res = await POST( + new NextRequest(`http://localhost/api/import/csv?${query}`, { + method: "POST", + body: [HEADER, "WEIGHT,80,kg,2026-05-01T08:00:00Z,,,"].join("\n"), + headers: { "content-type": "text/csv" }, + }), + ); + expect(res.status).toBe(422); + } + expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled(); + expect(checkRateLimit).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/import/csv — fatal header error", () => { it("returns 422 when a required column is missing", async () => { const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg")); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index d39ccc3e4..97d1e4239 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -7,7 +7,9 @@ import { apiError, getClientIp, readBodyText, + returnAllZodIssues, } from "@/lib/api-response"; +import { csvImportQuerySchema } from "@/lib/validations/record-import"; import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -109,9 +111,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "import.csv.upload" } }); - const dryRun = - new URL(request.url).searchParams.get("dryRun") === "1" || - new URL(request.url).searchParams.get("dryRun") === "true"; + // Strict: an unknown parameter is refused, so a misspelt `dryrun=1` can + // never fall through to a real import. + const queryParse = csvImportQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!queryParse.success) return returnAllZodIssues(queryParse.error, 422); + const dryRun = queryParse.data.dryRun; // Same blast-radius reasoning as the JSON import — share the bucket so a // CSV upload counts against the same 5/hour quota. diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index 6fee3fa7f..a9fc17f68 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -24,13 +24,14 @@ import { } from "./shared"; import { isoDateTime } from "@/lib/validations/datetime"; import { + csvImportQuerySchema, labsCsvQuerySchema, medicationsCsvQuerySchema, recordImportResultSchema, } from "@/lib/validations/record-import"; const RECORD_IMPORT_COMMON = - "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off."; + "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes."; function recordImportResponses(id: string) { return { @@ -339,17 +340,8 @@ export const importPaths: NonNullable = { tags: ["Import"], summary: "Import measurements from CSV", description: - "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on `(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", - parameters: [ - { - name: "dryRun", - in: "query", - required: false, - schema: { type: "string", enum: ["1", "true"] }, - description: - "When `1` / `true`, parse + validate + return the per-row envelope without writing.", - }, - ], + "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on `(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes.", + requestParams: { query: csvImportQuerySchema }, requestBody: { required: true, content: { diff --git a/src/lib/validations/record-import.ts b/src/lib/validations/record-import.ts index c6c572d1f..b65a5e2fd 100644 --- a/src/lib/validations/record-import.ts +++ b/src/lib/validations/record-import.ts @@ -8,6 +8,10 @@ * order and the zone a time without an offset is read in. The client works * these out with the person on the mapping screen; the server never guesses * a column. + * + * Both query schemas are strict: a parameter they do not know is a 422, not + * ignored. A misspelt `dryrun=1` or `dry_run=1` would otherwise fall back to + * the default `dryRun=0` and write the file the caller only meant to check. */ import { z } from "zod/v4"; @@ -60,6 +64,14 @@ const formatFields = { ), }; +/** + * Query of `POST /api/import/csv` (the measurement CSV). Strict for the same + * reason as the two record imports: a misspelt flag must never write. + */ +export const csvImportQuerySchema = z + .object({ dryRun: formatFields.dryRun }) + .strict(); + function distinctColumns( value: Record, keys: readonly string[], @@ -103,6 +115,7 @@ export const labsCsvQuerySchema = z panel: column.optional(), note: column.optional().describe("Column of a note; stored encrypted."), }) + .strict() .refine((v) => distinctColumns(v, LAB_COLUMN_KEYS), { message: "Each column can feed one field", path: ["analyte"], @@ -144,6 +157,7 @@ export const medicationsCsvQuerySchema = z "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts reminding on its own.", ), }) + .strict() .refine((v) => distinctColumns(v, MEDICATION_COLUMN_KEYS), { message: "Each column can feed one field", path: ["name"], From 176f21ab78d2ac542abe33a9ce5e538f0d017832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:01:27 +0200 Subject: [PATCH 73/98] fix(fhir): bound _offset and answer a larger one with 400 An offset beyond what the database can skip reached Prisma unchecked and answered 500. Every search route now refuses an _offset above 100000 with an invalid OperationOutcome. --- docs/api/openapi.yaml | 12 ++++++ src/app/api/fhir/Coverage/route.ts | 6 ++- .../fhir/MedicationAdministration/route.ts | 6 ++- src/app/api/fhir/MedicationStatement/route.ts | 6 ++- src/app/api/fhir/Observation/route.ts | 6 ++- src/app/api/fhir/Patient/$everything/route.ts | 6 ++- src/app/api/fhir/Patient/route.ts | 6 ++- src/app/api/fhir/__tests__/route.test.ts | 17 ++++++++ src/lib/fhir/rest.ts | 20 +++++++-- src/lib/openapi/routes/health-record.ts | 42 ++++++++++++++++--- 10 files changed, 111 insertions(+), 16 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ecc92d47d..808c289bc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1274,7 +1274,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1310,7 +1312,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. - in: query name: date schema: @@ -1345,7 +1349,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1372,7 +1378,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1400,7 +1408,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1595,7 +1605,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). diff --git a/src/app/api/fhir/Coverage/route.ts b/src/app/api/fhir/Coverage/route.ts index ce45cbc0f..de38c6414 100644 --- a/src/app/api/fhir/Coverage/route.ts +++ b/src/app/api/fhir/Coverage/route.ts @@ -43,7 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = [coverageResource(context.data, context.identity)].filter( diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts index 361d1eed6..639472eba 100644 --- a/src/app/api/fhir/MedicationAdministration/route.ts +++ b/src/app/api/fhir/MedicationAdministration/route.ts @@ -41,7 +41,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = medicationAdministrationsFromReportData(context.data, { diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts index bd205854c..848f6562d 100644 --- a/src/app/api/fhir/MedicationStatement/route.ts +++ b/src/app/api/fhir/MedicationStatement/route.ts @@ -41,7 +41,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = medicationStatementsFromReportData(context.data, { diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index 1988f78c4..8267b28d3 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -57,7 +57,11 @@ export const GET = apiHandler(async (request: NextRequest) => { return operationOutcome(429, "throttled", "Rate limit exceeded"); } const params = request.nextUrl.searchParams; - const { count, offset } = parsePaging(params); + const paging = parsePaging(params); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const codeValues = params.getAll("code"); const range = parseDateParams(params.getAll("date")); if (range && "error" in range) { diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts index 68faf6304..bebc9d12c 100644 --- a/src/app/api/fhir/Patient/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -69,7 +69,11 @@ export const GET = apiHandler(async (request: NextRequest) => { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const { data, identity, germanAtc, records } = await loadFhirContext(user.id); const document = buildFhirDocumentBundle( diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts index dcf03b802..27a8b90f0 100644 --- a/src/app/api/fhir/Patient/route.ts +++ b/src/app/api/fhir/Patient/route.ts @@ -43,7 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = [patientResource(context.data, context.identity)]; diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index af4bdfe81..eaf538a41 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -147,6 +147,23 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(bundle.entry[0].resource.id).toBe("obs-3"); }); + it("refuses an offset past the ceiling with a 400 OperationOutcome", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([obs(1)] as never); + for (const offset of ["100001", "1e20", "Infinity"]) { + const res = await observationGet(req(`?_offset=${offset}`)); + expect(res.status).toBe(400); + const outcome = (await res.json()) as { + resourceType: string; + issue: Array<{ code: string }>; + }; + expect(outcome.resourceType).toBe("OperationOutcome"); + expect(outcome.issue[0].code).toBe("invalid"); + } + // The ceiling itself still pages (an empty page past the end). + const atCeiling = await observationGet(req("?_offset=100000")); + expect(atCeiling.status).toBe(200); + }); + it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => { // No session → Bearer path. requireAuth(FHIR_READ_SCOPE) must reject a // token lacking the scope; here no auth at all yields 401, proving the diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index 75e87ecf2..ebd1c54e3 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -91,21 +91,33 @@ export const DEFAULT_COUNT = 50; /** Hard ceiling on `_count` so a single search can never page the whole store. */ export const MAX_COUNT = 200; +/** + * Ceiling on `_offset`. No record pages that far (the largest search, the + * Observation time series, is bounded by the readings in a date range), and + * an unbounded offset reached the database as a skip it cannot represent and + * answered 500. + */ +export const MAX_OFFSET = 100_000; + /** * Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`; * `offset` floors at 0. A non-numeric / negative input collapses to the * default rather than erroring — FHIR search is forgiving on paging params. + * An offset above {@link MAX_OFFSET} is the one refusal: the caller answers + * it with a 400 `invalid` OperationOutcome. */ -export function parsePaging(searchParams: URLSearchParams): { - count: number; - offset: number; -} { +export function parsePaging( + searchParams: URLSearchParams, +): { count: number; offset: number } | { error: string } { const rawCount = Number(searchParams.get("_count")); const count = Number.isFinite(rawCount) && rawCount > 0 ? Math.min(Math.floor(rawCount), MAX_COUNT) : DEFAULT_COUNT; const rawOffset = Number(searchParams.get("_offset")); + if (rawOffset > MAX_OFFSET) { + return { error: `_offset must be at most ${MAX_OFFSET}` }; + } const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0; return { count, offset }; diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 9673e3aa3..20e0e4042 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -376,7 +376,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -401,7 +406,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), date: z.string().optional(), code: z.string().optional(), }), @@ -428,7 +438,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -453,7 +468,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -478,7 +498,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -653,7 +678,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { From 704822c0108ccef895345137ad32c1c030eae64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:02:24 +0200 Subject: [PATCH 74/98] test(sharing): say which v1.44 routes joined the record list The note named the activity goals among the three new readable routes. They are the calendar read and the two source provenance reads; the goals route stays owner-only. --- src/__tests__/sharing-surface-guard.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 33b0c3305..fe252a19d 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2191,8 +2191,11 @@ const ACTOR_ROUTES: Record = { * v1.44 -- the module list layout and the preview-feature switch are the * person's own presentation and join the actor list. 268 -> 270. * - * v1.44 -- the activity calendar read, the activity goals and the source - * provenance reads join the list. 270 -> 273. + * v1.44 -- three readable routes join the record list: the activity calendar + * read and the two source provenance reads (one day, and two sources side by + * side). The activity goals route does not: setting or changing goals stays + * owner-only, and a delegate sees the goals only as the calendar reports them. + * 270 -> 273. */ const FROZEN_ENTRY_COUNT = 273; From 0918451e02995181c42f188adecb072d771f6a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:09:40 +0200 Subject: [PATCH 75/98] fix(settings): unlist Preview features until the topic pages ship The switch was stored and shown, but nothing reads the topics flag yet, so the page promised a change it could not make. The section, its nav entry, its loader and its copy are gone, so its palette entry goes with them; /settings/preview is no longer a route. The stored flag, /api/settings/preview-features and its backup round-trip stay, and the column's pending-consumer note says why. --- e2e/settings-admin-one-piece.spec.ts | 1 - messages/de.json | 15 --- messages/en.json | 15 --- messages/es.json | 15 --- messages/fr.json | 15 --- messages/it.json | 15 --- messages/ko.json | 15 --- messages/nl.json | 15 --- messages/pl.json | 15 --- prisma/schema.prisma | 6 +- src/__tests__/settings-copy-tense.test.ts | 12 +- src/__tests__/settings-legacy-routes.test.ts | 5 +- .../__tests__/preview-section.test.tsx | 51 -------- .../__tests__/settings-shell.test.tsx | 12 +- src/components/settings/preview-section.tsx | 116 ------------------ src/components/settings/section-slugs.ts | 7 +- .../settings/settings-section-loader.tsx | 5 - src/components/settings/settings-shell.tsx | 7 -- src/lib/query-keys/settings.ts | 3 - src/lib/record-settings/classification.ts | 1 - src/lib/settings/preview-features.ts | 4 +- 21 files changed, 23 insertions(+), 327 deletions(-) delete mode 100644 src/components/settings/__tests__/preview-section.test.tsx delete mode 100644 src/components/settings/preview-section.tsx diff --git a/e2e/settings-admin-one-piece.spec.ts b/e2e/settings-admin-one-piece.spec.ts index 3f10f6540..2b8e70726 100644 --- a/e2e/settings-admin-one-piece.spec.ts +++ b/e2e/settings-admin-one-piece.spec.ts @@ -58,7 +58,6 @@ const SETTINGS_ROUTES = [ "/settings/api", "/settings/mcp", "/settings/advanced", - "/settings/preview", "/settings/about", ] as const; diff --git a/messages/de.json b/messages/de.json index f2480fafb..d78afea07 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6141,10 +6141,6 @@ "access": { "title": "Geteilter Zugriff", "subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst." - }, - "preview": { - "title": "Vorschau-Funktionen", - "subtitle": "Probier aus, was noch nicht fertig ist. Alles hier bleibt aus, bis du es einschaltest." } }, "profile": "Profil", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "Passkey entfernt.", "integrationDisconnected": "{provider} getrennt.", "integrationDisconnectFailed": "{provider} konnte nicht getrennt werden.", - "preview": { - "topics": { - "title": "Themen (Beta)", - "description": "Fügt einen eigenen Eintrag Themen hinzu, sobald die Übersichtsseiten über den einzelnen Insights-Seiten fertig sind.", - "detail": "Deine Insights-Seiten, die Navigation und alle Links bleiben genau wie sie sind. Die Themenseiten kommen mit einem späteren Update; dieser Schalter legt fest, ob du sie siehst.", - "savedOn": "Themen-Vorschau eingeschaltet.", - "savedOff": "Themen-Vorschau ausgeschaltet." - }, - "loadError": "Deine Vorschau-Einstellungen konnten nicht geladen werden.", - "feedbackLink": "Feedback in den GitHub-Diskussionen geben" - }, "timezoneSaved": "Zeitzone gespeichert.", "moduleState": { "on": "An", diff --git a/messages/en.json b/messages/en.json index 4cdf487f5..10a8e34e6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6141,10 +6141,6 @@ "access": { "title": "Shared access", "subtitle": "Who can open your health record, and whose you can open." - }, - "preview": { - "title": "Preview features", - "subtitle": "Try what is not finished yet. Everything here stays off until you switch it on." } }, "profile": "Profile", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "Passkey removed.", "integrationDisconnected": "{provider} disconnected.", "integrationDisconnectFailed": "Could not disconnect {provider}.", - "preview": { - "topics": { - "title": "Topics (beta)", - "description": "Will add a separate Topics entry with overview pages above the single-metric Insights pages once they are ready.", - "detail": "Your Insights pages, navigation and links stay exactly as they are. The topic pages arrive in a later update; this switch decides whether you see them.", - "savedOn": "Topics preview switched on.", - "savedOff": "Topics preview switched off." - }, - "loadError": "Could not load your preview settings.", - "feedbackLink": "Share feedback in the GitHub discussions" - }, "timezoneSaved": "Timezone saved.", "moduleState": { "on": "On", diff --git a/messages/es.json b/messages/es.json index 8d7873a7c..e331d5c2a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6141,10 +6141,6 @@ "access": { "title": "Acceso compartido", "subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú." - }, - "preview": { - "title": "Funciones en vista previa", - "subtitle": "Prueba lo que aún no está terminado. Todo aquí sigue desactivado hasta que lo actives." } }, "profile": "Perfil", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "Passkey eliminada.", "integrationDisconnected": "{provider} desconectado.", "integrationDisconnectFailed": "No se pudo desconectar {provider}.", - "preview": { - "topics": { - "title": "Temas (beta)", - "description": "Añadirá una entrada aparte, Temas, con páginas de resumen por encima de las páginas de cada métrica cuando estén listas.", - "detail": "Tus páginas de análisis, la navegación y los enlaces se quedan exactamente como están. Las páginas de temas llegan en una actualización posterior; este interruptor decide si las ves.", - "savedOn": "Vista previa de Temas activada.", - "savedOff": "Vista previa de Temas desactivada." - }, - "loadError": "No se pudieron cargar tus ajustes de vista previa.", - "feedbackLink": "Comparte tu opinión en las discusiones de GitHub" - }, "timezoneSaved": "Zona horaria guardada.", "moduleState": { "on": "Activado", diff --git a/messages/fr.json b/messages/fr.json index a689b23bc..e4ca44d21 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6141,10 +6141,6 @@ "access": { "title": "Accès partagé", "subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir." - }, - "preview": { - "title": "Fonctions en aperçu", - "subtitle": "Essayez ce qui n’est pas encore terminé. Tout reste désactivé ici tant que vous ne l’activez pas." } }, "profile": "Profil", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "Passkey supprimée.", "integrationDisconnected": "{provider} déconnecté.", "integrationDisconnectFailed": "Impossible de déconnecter {provider}.", - "preview": { - "topics": { - "title": "Thèmes (bêta)", - "description": "Ajoutera une entrée Thèmes distincte, avec des pages de synthèse au-dessus des pages par mesure, dès qu'elles seront prêtes.", - "detail": "Vos pages d’analyse, la navigation et les liens restent exactement tels quels. Les pages de thèmes arrivent dans une mise à jour ultérieure ; cet interrupteur décide si vous les voyez.", - "savedOn": "Aperçu des thèmes activé.", - "savedOff": "Aperçu des thèmes désactivé." - }, - "loadError": "Impossible de charger vos réglages d’aperçu.", - "feedbackLink": "Donner votre avis dans les discussions GitHub" - }, "timezoneSaved": "Fuseau horaire enregistré.", "moduleState": { "on": "Activé", diff --git a/messages/it.json b/messages/it.json index 276cfd2c3..6af2d8b19 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6141,10 +6141,6 @@ "access": { "title": "Accesso condiviso", "subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu." - }, - "preview": { - "title": "Funzioni in anteprima", - "subtitle": "Prova ciò che non è ancora finito. Qui tutto resta spento finché non lo attivi." } }, "profile": "Profilo", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "Passkey rimossa.", "integrationDisconnected": "{provider} disconnesso.", "integrationDisconnectFailed": "Impossibile disconnettere {provider}.", - "preview": { - "topics": { - "title": "Temi (beta)", - "description": "Aggiungerà una voce Temi separata, con pagine di panoramica sopra le pagine delle singole metriche, appena saranno pronte.", - "detail": "Le pagine delle analisi, la navigazione e i link restano esattamente come sono. Le pagine dei temi arrivano con un aggiornamento successivo; questo interruttore decide se le vedi.", - "savedOn": "Anteprima dei temi attivata.", - "savedOff": "Anteprima dei temi disattivata." - }, - "loadError": "Impossibile caricare le impostazioni di anteprima.", - "feedbackLink": "Lascia un parere nelle discussioni su GitHub" - }, "timezoneSaved": "Fuso orario salvato.", "moduleState": { "on": "Attivo", diff --git a/messages/ko.json b/messages/ko.json index de47beccd..665971476 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6141,10 +6141,6 @@ "access": { "title": "공유 접근", "subtitle": "내 건강 기록을 누가 열 수 있고, 내가 누구의 기록을 열 수 있는지예요." - }, - "preview": { - "title": "미리 보기 기능", - "subtitle": "아직 완성되지 않은 기능을 써 보세요. 직접 켜기 전까지는 모두 꺼져 있습니다." } }, "profile": "프로필", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "패스키를 삭제했어요.", "integrationDisconnected": "{provider} 연결을 해제했어요.", "integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요.", - "preview": { - "topics": { - "title": "주제 (베타)", - "description": "준비되면 개별 지표 페이지 위에 개요 페이지가 있는 별도의 주제 항목을 추가해요.", - "detail": "인사이트 페이지, 메뉴, 링크는 지금 그대로 유지됩니다. 주제 페이지는 이후 업데이트에서 제공되며, 이 스위치로 표시 여부를 정합니다.", - "savedOn": "주제 미리 보기를 켰습니다.", - "savedOff": "주제 미리 보기를 껐습니다." - }, - "loadError": "미리 보기 설정을 불러오지 못했습니다.", - "feedbackLink": "GitHub 토론에서 의견 남기기" - }, "timezoneSaved": "시간대를 저장했습니다.", "moduleState": { "on": "켜짐", diff --git a/messages/nl.json b/messages/nl.json index 7b94c1ea4..ab1552351 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6141,10 +6141,6 @@ "access": { "title": "Gedeelde toegang", "subtitle": "Wie je gezondheidsdossier kan openen, en wiens dossier jij kunt openen." - }, - "preview": { - "title": "Previewfuncties", - "subtitle": "Probeer wat nog niet af is. Alles hier blijft uit tot je het aanzet." } }, "profile": "Profiel", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "Passkey verwijderd.", "integrationDisconnected": "{provider} ontkoppeld.", "integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld.", - "preview": { - "topics": { - "title": "Onderwerpen (bèta)", - "description": "Voegt een apart item Onderwerpen toe, met overzichtspagina’s boven de pagina’s per meting, zodra die klaar zijn.", - "detail": "Je inzichtpagina’s, navigatie en links blijven precies zoals ze zijn. De onderwerppagina’s komen met een latere update; deze schakelaar bepaalt of je ze ziet.", - "savedOn": "Preview van onderwerpen ingeschakeld.", - "savedOff": "Preview van onderwerpen uitgeschakeld." - }, - "loadError": "Je preview-instellingen konden niet worden geladen.", - "feedbackLink": "Geef feedback in de GitHub-discussies" - }, "timezoneSaved": "Tijdzone opgeslagen.", "moduleState": { "on": "Aan", diff --git a/messages/pl.json b/messages/pl.json index 7fc928cd2..35fe5b2ca 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6141,10 +6141,6 @@ "access": { "title": "Udostępniony dostęp", "subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty." - }, - "preview": { - "title": "Funkcje w podglądzie", - "subtitle": "Wypróbuj to, co nie jest jeszcze gotowe. Wszystko tutaj pozostaje wyłączone, dopóki tego nie włączysz." } }, "profile": "Profil", @@ -7628,17 +7624,6 @@ "passkeyRemovedToast": "Usunięto passkey.", "integrationDisconnected": "Rozłączono {provider}.", "integrationDisconnectFailed": "Nie udało się rozłączyć {provider}.", - "preview": { - "topics": { - "title": "Tematy (beta)", - "description": "Doda osobną pozycję Tematy ze stronami przeglądowymi nad stronami poszczególnych pomiarów, gdy będą gotowe.", - "detail": "Strony analiz, nawigacja i linki pozostają dokładnie takie, jakie są. Strony tematów pojawią się w późniejszej aktualizacji; ten przełącznik decyduje, czy je zobaczysz.", - "savedOn": "Podgląd tematów włączony.", - "savedOff": "Podgląd tematów wyłączony." - }, - "loadError": "Nie udało się wczytać ustawień podglądu.", - "feedbackLink": "Podziel się opinią w dyskusjach na GitHubie" - }, "timezoneSaved": "Strefa czasowa zapisana.", "moduleState": { "on": "Włączone", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cf97a4241..860778e7a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -388,9 +388,9 @@ model User { // Shape: { "version": 1, "labs": { "view": "list", "order": [""], "sortDir": "manual" }, ... } moduleListLayoutJson Json? @map("module_list_layout_json") /// Per-user opt-in to preview features, one boolean per flag, all off by - /// default (`src/lib/settings/preview-features.ts`). Read and written by - /// Settings > System through `/api/settings/preview-features`. - /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will. + /// default (`src/lib/settings/preview-features.ts`). Read and written + /// through `/api/settings/preview-features` and carried by the backup. + /// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship. previewFeaturesJson Json? @map("preview_features_json") /// Optional activity goals the person set for themselves: workouts, active /// minutes and steps per week, steps per month. Null or an absent key = no diff --git a/src/__tests__/settings-copy-tense.test.ts b/src/__tests__/settings-copy-tense.test.ts index 31d44ea71..f6298b988 100644 --- a/src/__tests__/settings-copy-tense.test.ts +++ b/src/__tests__/settings-copy-tense.test.ts @@ -1,11 +1,10 @@ /** * Copy that promises something not yet shipped says so. * - * The Topics preview switch exists before its pages do, so its description is - * in the future tense in every locale's source (English) and in German; and - * the workout-route switch is off for new accounts, which is what its detail + * The workout-route switch is off for new accounts, which is what its detail * says rather than "off by default": an account that already held tracks at - * the upgrade started with it on (migration 0434). + * the upgrade started with it on (migration 0434). (The Topics preview switch + * this file also held is unlisted until its pages ship, and its copy with it.) */ import { describe, expect, it } from "vitest"; @@ -13,11 +12,6 @@ import en from "../../messages/en.json"; import de from "../../messages/de.json"; describe("settings copy tense", () => { - it("describes the Topics preview as something it will add", () => { - expect(en.settings.preview.topics.description).toMatch(/^Will add /); - expect(de.settings.preview.topics.description).toMatch(/sobald/); - }); - it("says workout routes are off for new accounts", () => { expect(en.settings.privacy.workoutRoutes.detail).toMatch( /^Off for new accounts\./, diff --git a/src/__tests__/settings-legacy-routes.test.ts b/src/__tests__/settings-legacy-routes.test.ts index db699d3bc..c04cbe5ad 100644 --- a/src/__tests__/settings-legacy-routes.test.ts +++ b/src/__tests__/settings-legacy-routes.test.ts @@ -146,10 +146,7 @@ describe("historical Settings addresses", () => { // one silently. Module pages are new in v1.44 and are allowed here. const listed = new Set(HISTORICAL_SETTINGS_PATHS); const unlisted = routable.filter( - (path) => - !listed.has(path) && - !path.startsWith("/settings/modules/") && - path !== "/settings/preview", + (path) => !listed.has(path) && !path.startsWith("/settings/modules/"), ); expect(unlisted).toEqual([]); }); diff --git a/src/components/settings/__tests__/preview-section.test.tsx b/src/components/settings/__tests__/preview-section.test.tsx deleted file mode 100644 index 7dc3e11ba..000000000 --- a/src/components/settings/__tests__/preview-section.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { PreviewSection } from "../preview-section"; -import { primeLocales } from "@/__tests__/helpers/prime-locales"; - -primeLocales("de"); - -function render(topics: boolean | undefined, locale: "en" | "de" = "en") { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, refetchOnMount: false } }, - }); - if (topics !== undefined) { - client.setQueryData(queryKeys.previewFeatures(), { topics }); - } - return renderToStaticMarkup( - - - - - , - ); -} - -describe("", () => { - it("shows the Topics switch off by default, saved on change", () => { - const html = render(false); - expect(html).toContain('data-slot="settings-preview-topics-switch"'); - expect(html).toMatch( - /data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/, - ); - expect(html).toContain("Topics (beta)"); - // A switch, no Save: one save model per card. - expect(html).not.toContain('type="submit"'); - }); - - it("reads on once the person switched it on", () => { - expect(render(true)).toMatch( - /data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/, - ); - }); - - it("says the existing pages stay as they are and nothing raw leaks", () => { - const html = render(false, "de"); - expect(html).toContain("Themen (Beta)"); - expect(html).not.toContain("settings.preview."); - }); -}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index dfeed9e79..117aa294f 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -170,12 +170,19 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.23 — "Data & Privacy" assembles the export / deletion / retention / // encryption / session / activity pieces into one pane, after `advanced`. "privacy", - // v1.44 — per-user preview opt-ins, in the System group before About. - "preview", "about", ]); }); + it("lists no Preview features page until the topic pages ship", () => { + // The stored flag and its API stay; the page and its nav entry return + // with the first preview the switch actually turns on. + expect(isSettingsSectionSlug("preview")).toBe(false); + expect(SETTINGS_SECTIONS.some((s) => s.slug === ("preview" as never))).toBe( + false, + ); + }); + it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => { for (const slug of SETTINGS_SECTION_SLUGS) { expect(isSettingsSectionSlug(slug)).toBe(true); @@ -242,7 +249,6 @@ describe("", () => { gesundheitsakte: "data", privacy: "data", advanced: "data", - preview: "system", about: "system", }); // Every routable section has a nav entry, and nothing else does. diff --git a/src/components/settings/preview-section.tsx b/src/components/settings/preview-section.tsx deleted file mode 100644 index 9df4789e5..000000000 --- a/src/components/settings/preview-section.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -/** - * Settings > System > Preview features. - * - * One switch per preview feature, each off until the person turns it on, each - * saved the moment it changes (`PATCH /api/settings/preview-features`). A - * preview adds something beside what is there and never replaces a page. - * - * The `topics` flag is stored and shown here; the topic overview pages that - * read it arrive in a later release, and the card says so rather than - * promising a change the switch does not make yet. - */ - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; -import { LayoutDashboard } from "lucide-react"; - -import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { InlineAction } from "@/components/ui/inline-action"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { Switch } from "@/components/ui/switch"; -import { apiGet, apiPatch } from "@/lib/api/api-fetch"; -import { showErrorToast } from "@/lib/errors/error-toast"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { - DEFAULT_PREVIEW_FEATURES, - type PreviewFeatures, -} from "@/lib/settings/preview-features"; - -const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions"; - -export function PreviewSection() { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const key = queryKeys.previewFeatures(); - - const query = useQuery({ - queryKey: key, - queryFn: () => apiGet("/api/settings/preview-features"), - }); - const features = query.data ?? DEFAULT_PREVIEW_FEATURES; - - const toggle = useMutation({ - mutationKey: key, - mutationFn: (topics: boolean) => - apiPatch("/api/settings/preview-features", { topics }), - onMutate: async (topics) => { - await queryClient.cancelQueries({ queryKey: key }); - const previous = queryClient.getQueryData(key); - queryClient.setQueryData(key, { - ...(previous ?? DEFAULT_PREVIEW_FEATURES), - topics, - }); - return { previous }; - }, - onSuccess: (saved, topics) => { - if (saved) queryClient.setQueryData(key, saved); - toastWrittenOutcome( - "success", - topics - ? t("settings.preview.topics.savedOn") - : t("settings.preview.topics.savedOff"), - ); - }, - onError: (failure, _topics, context) => { - queryClient.setQueryData(key, context?.previous); - showErrorToast(t("settings.savingError"), { cause: failure }); - }, - }); - - return ( - - toggle.mutate(next)} - aria-labelledby="settings-preview-topics-title" - /> - } - /> - {query.isError ? ( - void query.refetch()} - /> - ) : null} -

    {t("settings.preview.topics.detail")}

    -

    - - - {t("settings.preview.feedbackLink")} - - -

    - - ); -} diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 89d05e243..b6f69ed40 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -114,9 +114,10 @@ export const SETTINGS_SECTION_SLUGS = [ // pieces into one coherent privacy pane. Sits next to `advanced` (the // destructive-action home) and before `about`. "privacy", - // v1.44 — "Preview features": per-user opt-in to things finished enough to - // try and not yet the default. Sits in the System group before About. - "preview", + // v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not + // listed until the first preview it switches, the topic pages, ships: a + // switch that changes nothing is a promise the page cannot keep. The stored + // flag, `/api/settings/preview-features` and its backup round-trip stay. "about", ] as const; diff --git a/src/components/settings/settings-section-loader.tsx b/src/components/settings/settings-section-loader.tsx index 252c75905..41aecb597 100644 --- a/src/components/settings/settings-section-loader.tsx +++ b/src/components/settings/settings-section-loader.tsx @@ -124,10 +124,6 @@ const LOADERS: Record< import("@/components/settings/privacy-section").then( (m) => m.PrivacySection, ), - preview: () => - import("@/components/settings/preview-section").then( - (m) => m.PreviewSection, - ), }; const SECTIONS: Record> = { @@ -162,7 +158,6 @@ const SECTIONS: Record> = { export: dynamic(LOADERS.export, { loading: () => }), advanced: dynamic(LOADERS.advanced, { loading: () => }), privacy: dynamic(LOADERS.privacy, { loading: () => }), - preview: dynamic(LOADERS.preview, { loading: () => }), }; /** diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index b260fa6d6..29c8b1af2 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -26,7 +26,6 @@ import { CloudSun, Download, FileHeart, - FlaskConical, Gauge, Info, KeyRound, @@ -275,12 +274,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ group: "data", }, // ── System ── - { - slug: "preview", - titleKey: "settings.sections.preview.title", - icon: FlaskConical, - group: "system", - }, { slug: "about", titleKey: "settings.sections.about.title", diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 95c1a4f13..03673daa1 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -44,9 +44,6 @@ export const settingsKeys = { */ moduleListLayout: () => ["settings", "module-list-layout"] as const, - /** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */ - previewFeatures: () => ["settings", "preview-features"] as const, - /** * v1.35.0 — which pillars count toward the Health Score, behind * `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index 8d54e3eb1..fbeda64b2 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -98,7 +98,6 @@ export const SETTINGS_DESTINATION_INVENTORY = { export: PERSONAL, advanced: PERSONAL, privacy: PERSONAL, - preview: PERSONAL, about: PERSONAL, } as const satisfies Record< SettingsSectionSlug, diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts index cd184520d..514b55fd1 100644 --- a/src/lib/settings/preview-features.ts +++ b/src/lib/settings/preview-features.ts @@ -7,7 +7,9 @@ * * Flags: * - `topics`: topic overview pages above the single-metric Insights pages. - * Stored and shown in Settings > System; no surface reads it yet. + * Stored, served by `/api/settings/preview-features` and carried by the + * backup. The Settings > System switch is not listed until the topic pages + * ship, because nothing reads the flag yet. * * Unknown keys in a stored blob are dropped on read, so a flag retired later * disappears cleanly and a malformed blob reads as everything off. From 29d6029999cb6042f0e1f2ef3f20190e4d028bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:25:29 +0200 Subject: [PATCH 76/98] fix(workouts): read a day with activity data and no workout as rest The strain run writes no impulse row on a day without a scorable workout or active energy, so a rest day for someone without daily active energy was never rated: fitness and fatigue held for months instead of decaying, and the figures named the last stored day while the coverage count ended there too. A day without a stored impulse is now classified from the underlying data: a workout on the day leaves it unrated, steps, heart rate or active energy without a workout make it rest, and no data at all leaves it unrated. The series runs to the day the strain run scores, so a stretch without data after the last rated day shows in the coverage count, and the card names the last rated day when it lies further back than the day before. --- docs/api/openapi.yaml | 19 ++- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/nl.json | 3 +- messages/pl.json | 3 +- .../__tests__/canonical-dedup.test.ts | 2 + .../__tests__/training-load-card.test.tsx | 14 +- .../workouts/training-load-card.tsx | 19 ++- .../insights/__tests__/training-load.test.ts | 109 ++++++++++-- src/lib/insights/training-load.ts | 157 ++++++++++++++---- src/lib/openapi/routes/workouts.ts | 8 +- 15 files changed, 286 insertions(+), 66 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 808c289bc..0adfaa593 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -49472,8 +49472,8 @@ components: const: ok asOf: type: string - description: "The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never - reads as a rest day." + description: The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to + yesterday, so a later stretch without data shows as unrated days. ctl: type: number description: "Fitness: 42-day exponentially weighted TRIMP." @@ -49495,7 +49495,8 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 - description: Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse. + description: "Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored + impulse, or activity data without a workout." recentDays: type: integer minimum: -9007199254740991 @@ -49518,8 +49519,9 @@ components: type: number rated: type: boolean - description: "False for a day without a stored impulse (never scored): the averages held through it rather than counting - it as rest." + description: "False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active + energy), or a workout that was never scored. The averages held through it rather than counting it + as rest. A day with activity data and no workout is rest (impulse 0)." required: - day - ctl @@ -49565,9 +49567,10 @@ components: - daysCovered - trainingDays additionalProperties: false - description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a - scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). - Needs 28 days and three training days. TRIMP units: comparable with the person's own past only." + description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day + with activity data but no training is 0, a day with no data or an unscored workout is not rated and the averages + hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past + only." GetWorkoutResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index d78afea07..5a5de7ff2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3903,7 +3903,8 @@ "form": "Form", "explainer": "Fitness ist dein Trainingsimpuls aus der Herzfrequenz, gemittelt über etwa sechs Wochen, Ermüdung über etwa eine Woche, und Form ist der Unterschied. Die Zahlen lassen sich mit deinen eigenen früheren vergleichen, nicht mit anderen Menschen oder Apps.", "warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist.", - "coverage": "{rated} der letzten {days} Tage haben Herzfrequenzdaten. Tage ohne sie bleiben außen vor, statt als Ruhetag zu zählen." + "coverage": "{rated} der letzten {days} Tage ließen sich bewerten. Ein Tag ohne Aktivitätsdaten oder mit einem Training, das sich nicht auswerten ließ, bleibt außen vor, statt als Ruhetag zu zählen.", + "asOf": "Stand {date}, der letzte Tag, der sich bewerten ließ." } }, "bloodGlucose": { diff --git a/messages/en.json b/messages/en.json index 10a8e34e6..6d0e9d027 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3903,7 +3903,8 @@ "form": "Form", "explainer": "Fitness is your heart-rate training impulse averaged over about six weeks, fatigue over about one week, and form is the difference. The numbers compare with your own past, not with other people or other apps.", "warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is.", - "coverage": "{rated} of the last {days} days have heart-rate data. Days without it are left out rather than counted as rest." + "coverage": "{rated} of the last {days} days could be rated. A day with no activity data, or with a workout that could not be scored, is left out rather than counted as rest.", + "asOf": "As of {date}, the last day that could be rated." } }, "bloodGlucose": { diff --git a/messages/es.json b/messages/es.json index e331d5c2a..b912c397c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3903,7 +3903,8 @@ "form": "Estado", "explainer": "La forma de base es tu impulso de entrenamiento por frecuencia cardiaca promediado en unas seis semanas, la fatiga en una semana, y el estado es la diferencia. Las cifras se comparan con tu propio pasado, no con otras personas ni otras apps.", "warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es.", - "coverage": "{rated} de los últimos {days} días tienen datos de frecuencia cardiaca. Los días sin ellos se omiten en lugar de contar como descanso." + "coverage": "Se pudieron valorar {rated} de los últimos {days} días. Un día sin datos de actividad, o con un entrenamiento que no se pudo puntuar, se omite en lugar de contar como descanso.", + "asOf": "A fecha de {date}, el último día que se pudo valorar." } }, "bloodGlucose": { diff --git a/messages/fr.json b/messages/fr.json index e4ca44d21..8df07c896 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3903,7 +3903,8 @@ "form": "Fraîcheur", "explainer": "La condition est ton impulsion d'entraînement par fréquence cardiaque moyennée sur environ six semaines, la fatigue sur environ une semaine, et la fraîcheur est la différence. Les chiffres se comparent à ton propre passé, pas à d'autres personnes ni à d'autres apps.", "warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est.", - "coverage": "{rated} des {days} derniers jours ont des données de fréquence cardiaque. Les jours sans elles sont écartés au lieu de compter comme repos." + "coverage": "{rated} des {days} derniers jours ont pu être évalués. Un jour sans données d'activité, ou avec un entraînement qui n'a pas pu être évalué, est écarté au lieu de compter comme repos.", + "asOf": "Au {date}, le dernier jour qui a pu être évalué." } }, "bloodGlucose": { diff --git a/messages/it.json b/messages/it.json index 6af2d8b19..e5115f814 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3903,7 +3903,8 @@ "form": "Freschezza", "explainer": "La forma di base è il tuo impulso di allenamento dalla frequenza cardiaca mediato su circa sei settimane, l'affaticamento su circa una settimana, e la freschezza è la differenza. I numeri si confrontano con il tuo passato, non con altre persone o altre app.", "warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia.", - "coverage": "{rated} degli ultimi {days} giorni hanno dati di frequenza cardiaca. I giorni senza vengono esclusi invece di contare come riposo." + "coverage": "{rated} degli ultimi {days} giorni sono stati valutati. Un giorno senza dati di attività, o con un allenamento che non è stato possibile valutare, viene escluso invece di contare come riposo.", + "asOf": "Al {date}, l'ultimo giorno che è stato possibile valutare." } }, "bloodGlucose": { diff --git a/messages/ko.json b/messages/ko.json index 665971476..cad8527fb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3903,7 +3903,8 @@ "form": "컨디션", "explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.", "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.", - "coverage": "최근 {days}일 중 {rated}일에 심박수 데이터가 있습니다. 데이터가 없는 날은 휴식일로 세지 않고 제외합니다." + "coverage": "최근 {days}일 중 {rated}일을 평가할 수 있었습니다. 활동 데이터가 없는 날이나 평가할 수 없는 운동이 있는 날은 휴식일로 세지 않고 제외합니다.", + "asOf": "{date} 기준, 평가할 수 있었던 마지막 날입니다." } }, "bloodGlucose": { diff --git a/messages/nl.json b/messages/nl.json index ab1552351..76e8e3d6c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -3903,7 +3903,8 @@ "form": "Vorm", "explainer": "Fitheid is je trainingsprikkel uit de hartslag, gemiddeld over ongeveer zes weken, vermoeidheid over ongeveer een week, en vorm is het verschil. De getallen zijn te vergelijken met je eigen verleden, niet met andere mensen of andere apps.", "warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is.", - "coverage": "{rated} van de laatste {days} dagen hebben hartslaggegevens. Dagen zonder worden weggelaten in plaats van als rustdag te tellen." + "coverage": "{rated} van de laatste {days} dagen konden worden beoordeeld. Een dag zonder activiteitsgegevens, of met een training die niet kon worden beoordeeld, wordt weggelaten in plaats van als rustdag te tellen.", + "asOf": "Stand van {date}, de laatste dag die kon worden beoordeeld." } }, "bloodGlucose": { diff --git a/messages/pl.json b/messages/pl.json index 35fe5b2ca..5bd631a54 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3903,7 +3903,8 @@ "form": "Forma", "explainer": "Wydolność to twój bodziec treningowy z tętna uśredniony z około sześciu tygodni, zmęczenie z około tygodnia, a forma to różnica. Liczby porównuje się z twoją własną przeszłością, nie z innymi osobami ani aplikacjami.", "warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest.", - "coverage": "{rated} z ostatnich {days} dni ma dane tętna. Dni bez nich są pomijane, a nie liczone jako odpoczynek." + "coverage": "Udało się ocenić {rated} z ostatnich {days} dni. Dzień bez danych o aktywności lub z treningiem, którego nie dało się ocenić, jest pomijany, a nie liczony jako odpoczynek.", + "asOf": "Stan na {date}, ostatni dzień, który dało się ocenić." } }, "bloodGlucose": { diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index dd05951ff..1386b5d61 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({ }, // v1.44 — the first page carries the training load. strainTrimpCache: { findMany: vi.fn(async () => []) }, + measurementRollup: { findMany: vi.fn(async () => []) }, + $queryRaw: vi.fn(async () => []), }, })); diff --git a/src/components/workouts/__tests__/training-load-card.test.tsx b/src/components/workouts/__tests__/training-load-card.test.tsx index 32f734eb3..d78b1e87e 100644 --- a/src/components/workouts/__tests__/training-load-card.test.tsx +++ b/src/components/workouts/__tests__/training-load-card.test.tsx @@ -44,7 +44,19 @@ describe("", () => { it("says how many recent days were rated when some were not", () => { const html = render({ ...OK, recentRatedDays: 25, recentDays: 42 }); expect(html).toContain('data-slot="training-load-coverage"'); - expect(html).toContain("25 of the last 42 days have heart-rate data"); + expect(html).toContain("25 of the last 42 days could be rated"); + }); + + it("names the last rated day when it lies behind the series end", () => { + const series = [ + { day: "2026-10-19", ctl: 54, atl: 61, tsb: -7, rated: false }, + { day: "2026-10-20", ctl: 54, atl: 61, tsb: -7, rated: false }, + ]; + const stale = render({ ...OK, asOf: "2026-09-02", series }); + expect(stale).toContain('data-slot="training-load-as-of"'); + expect(stale).toMatch(/As of 0?9\/0?2|As of 02\/09|As of Sep/); + const fresh = render({ ...OK, asOf: "2026-10-19", series }); + expect(fresh).not.toContain('data-slot="training-load-as-of"'); }); it("signs a positive form and drops the warm-up note once covered", () => { diff --git a/src/components/workouts/training-load-card.tsx b/src/components/workouts/training-load-card.tsx index 08b76f91a..30f1863a4 100644 --- a/src/components/workouts/training-load-card.tsx +++ b/src/components/workouts/training-load-card.tsx @@ -6,11 +6,14 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { TrainingLoad } from "@/lib/insights/training-load"; +import { shiftDateKey } from "@/lib/tz/format"; /** * v1.44 — fitness, fatigue and form from the stored training impulse, the * server's figures as given. Renders nothing until the model has 28 days and - * three training days to stand on. + * three training days to stand on. The series ends on the day the strain run + * scores; when the last rated day lies more than a day before that, the card + * names it, so figures held through weeks without data never pass as fresh. */ export function TrainingLoadCard({ trainingLoad, @@ -21,6 +24,10 @@ export function TrainingLoadCard({ const fmt = useFormatters(); if (!trainingLoad || trainingLoad.status !== "ok") return null; + const seriesEnd = trainingLoad.series.at(-1)?.day; + const stale = + seriesEnd !== undefined && trainingLoad.asOf < shiftDateKey(seriesEnd, -1); + const stats = [ { key: "fitness", value: trainingLoad.ctl }, { key: "fatigue", value: trainingLoad.atl }, @@ -47,6 +54,16 @@ export function TrainingLoadCard({ ))}

    {t("insights.workouts.load.explainer")}

    + {stale ? ( +

    + {t("insights.workouts.load.asOf", { + date: fmt.dateShortSmartCalendar(trainingLoad.asOf), + })} +

    + ) : null} {trainingLoad.recentRatedDays < trainingLoad.recentDays ? (
    -
      +
        {entries.map((entry) => (
      • Date: Thu, 22 Oct 2026 02:31:56 +0200 Subject: [PATCH 97/98] test(e2e): assert the guardian's connect controls on a managed profile A MANAGE grant on a managed profile now connects its OAuth providers: each of the six cards offers one connect and says whose record it is for, while Fitbit and Nightscout stay status-only. A view or view-and-add grant still gets no integration surface and a 403 from the connect route. --- e2e/v137-sharing-managed-profiles.spec.ts | 72 +++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index 59928b471..5be444f83 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -1,5 +1,7 @@ import type { Page } from "@playwright/test"; +import { MANAGED_OAUTH_PROVIDERS } from "@/lib/integrations/oauth-kit/managed-provider"; + import { expect, test } from "./setup/test"; import { E2E_LEVEL_RECORDS, @@ -581,7 +583,7 @@ test.describe.serial("scoped sharing browser journeys", () => { await leaveRecord(page); }); - test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and integrations status-only", async ({ + test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and connects integrations for it", async ({ page, }) => { const record = E2E_LEVEL_RECORDS.find( @@ -628,22 +630,40 @@ test.describe.serial("scoped sharing browser journeys", () => { ); await expect(integrations).toBeVisible(); await expect(integrations).toHaveAttribute("data-record-id", accountId); - // Read-only: the shared integration cards render, but nothing on them - // acts on a provider. The only buttons are each card's own "details for a - // bug report" toggle and its copy button. + // A guardian holds MANAGE on the profile, so each OAuth provider's card + // carries one control, connect, and says whose record it connects for. + // Fitbit takes no new connections and Nightscout is not an OAuth flow, so + // those two stay status-only. No card takes typed input: provider + // credentials and sync settings stay with the guardian's own Settings. await expect( integrations.locator('[data-slot="integration-card"]'), ).toHaveCount(8); - await expect( - integrations.locator( - '[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea', - ), - ).toHaveCount(0); + for (const provider of MANAGED_OAUTH_PROVIDERS) { + const card = integrations.getByTestId(`managed-integration-${provider}`); + await expect( + card.getByTestId(`managed-integration-${provider}-connect`), + ).toHaveText(`Connect for ${record.username}`); + await expect( + card.locator('[data-slot="managed-integration-for"]'), + ).toHaveText(`Connects for ${record.username}`); + } await expect( integrations.locator( 'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])', ), - ).toHaveCount(0); + ).toHaveCount(MANAGED_OAUTH_PROVIDERS.length); + for (const provider of ["fitbit", "nightscout"]) { + await expect( + integrations + .getByTestId(`managed-integration-${provider}`) + .locator( + 'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])', + ), + ).toHaveCount(0); + } + await expect(integrations.locator("input, select, textarea")).toHaveCount( + 0, + ); await page.goBack(); await expect(profile).toHaveAttribute("data-record-id", accountId); @@ -656,6 +676,38 @@ test.describe.serial("scoped sharing browser journeys", () => { await expect(banner).toHaveCount(0); }); + test("[J2-manage-edits-without-settings] keeps integrations shut for a view or add grant", async ({ + page, + }) => { + // The connect controls above are MANAGE-only. A view or view-and-add grant + // gets no integration surface at all, and the connect route refuses it. + for (const access of ["READ", "WRITE"] as const) { + const fenceHeaders = trackFenceAssertion(page); + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.access === access, + ); + if (!record) throw new Error(`${access} fixture is missing`); + await openRecord(page, record.username); + + await page.goto("/settings/integrations"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await expect(page.locator('[data-slot="integration-card"]')).toHaveCount( + 0, + ); + const connectStatus = await page.evaluate(async (headers) => { + const response = await fetch( + "/api/record-settings/integrations/withings/connect", + { method: "POST", headers }, + ); + return response.status; + }, fenceHeaders()); + expect(connectStatus).toBe(403); + await leaveRecord(page); + } + }); + test("[J1-adult-levels-lifecycle] refuses direct ungranted routes before coach reads start", async ({ page, }) => { From 28abde334e90529e0979189821c6d3f1a1f0e26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 02:31:56 +0200 Subject: [PATCH 98/98] fix(insights): keep the source comparison range on a 390 px phone Three spelled-out range labels beside the card title pushed /insights/sources past the viewport. Use the charts' short day labels and keep the long form as the segment title. The route sweep resolves the new module pages and drops mood from the arrangement pages, whose old address now redirects to them. --- e2e/mobile-route-overflow.spec.ts | 9 ++++++-- .../__tests__/source-provenance-view.test.tsx | 15 +++++++++++++ .../provenance/source-provenance-view.tsx | 22 +++++++------------ 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/e2e/mobile-route-overflow.spec.ts b/e2e/mobile-route-overflow.spec.ts index 0972d705c..a0d0397b8 100644 --- a/e2e/mobile-route-overflow.spec.ts +++ b/e2e/mobile-route-overflow.spec.ts @@ -4,6 +4,7 @@ import { join, relative, sep } from "node:path"; import type { APIRequestContext } from "@playwright/test"; import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs"; +import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids"; import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs"; import { expect, test } from "./setup/test"; @@ -80,17 +81,21 @@ const DYNAMIC_ROUTES: Record = { ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`), // `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot // load; `settings/layout/[module]` 404s an unknown group, so a stale entry - // here fails on the status check below rather than passing quietly. + // here fails on the status check below rather than passing quietly. Mood + // has no arrangement page since v1.44: its old address redirects to + // `/settings/modules/mood`, which the entry below sweeps. "/settings/layout/[module]": async () => [ "dashboard", "insights", "medications", - "mood", "labs", "illness", + "documents", "vorsorge", ].map((group) => `/settings/layout/${group}`), + "/settings/modules/[module]": async () => + MODULE_SETTINGS_PAGE_IDS.map((module) => `/settings/modules/${module}`), "/insights/scores/[metric]": async () => ["sleep", "readiness", "recovery", "stress", "strain"].map( (metric) => `/insights/scores/${metric}`, diff --git a/src/components/provenance/__tests__/source-provenance-view.test.tsx b/src/components/provenance/__tests__/source-provenance-view.test.tsx index a951201f5..0ec141b7b 100644 --- a/src/components/provenance/__tests__/source-provenance-view.test.tsx +++ b/src/components/provenance/__tests__/source-provenance-view.test.tsx @@ -164,6 +164,21 @@ describe("SourceProvenanceView", () => { expect(html).toContain('data-slot="provenance-day-empty"'); expect(html).toContain('data-slot="provenance-compare-empty"'); }); + + it("labels the range with the charts' short day labels and spells them out", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + for (const days of ["30", "90", "365"]) { + expect(html).toMatch( + new RegExp( + `data-slot="provenance-range-${days}"[^>]*title="${days} days"[^>]*>${days}d<|title="${days} days"[^>]*data-slot="provenance-range-${days}"[^>]*>${days}d<`, + ), + ); + } + }); }); describe("SourceProvenanceLink", () => { diff --git a/src/components/provenance/source-provenance-view.tsx b/src/components/provenance/source-provenance-view.tsx index e5a1dffb0..f3bd93d5f 100644 --- a/src/components/provenance/source-provenance-view.tsx +++ b/src/components/provenance/source-provenance-view.tsx @@ -224,20 +224,14 @@ export function SourceProvenanceView({ slot="provenance-range" value={range} onChange={setRange} - options={[ - { - value: "30", - label: t("sourceProvenance.rangeDays", { days: 30 }), - }, - { - value: "90", - label: t("sourceProvenance.rangeDays", { days: 90 }), - }, - { - value: "365", - label: t("sourceProvenance.rangeDays", { days: 365 }), - }, - ]} + // The charts' short range labels ("30d"), spelled out in + // the title: three long ones beside the title push the card + // past a 390 px phone. + options={(["30", "90", "365"] as const).map((days) => ({ + value: days, + label: t("charts.daysNLabel", { days }), + title: t("sourceProvenance.rangeDays", { days }), + }))} /> } />

    { expect(load.warmingUp).toBe(true); }); - it("describes the last stored day, so a run not yet made never zeroes it", () => { + it("holds through days not yet scored and names the last rated day", () => { // Rows through day 39; asked for day 41 (the nightly run for 40 and 41 - // has not happened). The figures are those of day 39. + // has not happened, and nothing else is known about them). The figures + // are those of day 39 and the series still runs to day 41. const rows = Array.from({ length: 40 }, (_, i) => ({ day: day(i), dayTrimp: 60, })); const late = computeTrainingLoad(rows, day(41)); const exact = computeTrainingLoad(rows, day(39)); - expect(late).toEqual(exact); - expect(late).toMatchObject({ status: "ok", asOf: day(39) }); + if (late.status !== "ok" || exact.status !== "ok") { + throw new Error("expected ok"); + } + expect(late.asOf).toBe(day(39)); + expect(late.ctl).toBe(exact.ctl); + expect(late.atl).toBe(exact.atl); + expect(late.tsb).toBe(exact.tsb); + expect(late.series.at(-1)).toMatchObject({ day: day(41), rated: false }); + expect(late.recentRatedDays).toBe(40); + expect(late.recentDays).toBe(42); + }); + + it("reads a day with activity data and no workout as rest", () => { + // Training days 0..2, stored rest days 3..9, then no rows at all from + // day 10 on: the strain run writes none on a day without active energy. + // Days 10..59 carry steps or heart rate, except a workout on day 20 that + // could not be scored and an empty day 30. + const rows = [ + { day: day(0), dayTrimp: 84 }, + { day: day(1), dayTrimp: 42 }, + { day: day(2), dayTrimp: 42 }, + ]; + for (let i = 3; i < 10; i += 1) rows.push({ day: day(i), dayTrimp: 0 }); + const dataDays = new Set(); + for (let i = 10; i < 60; i += 1) if (i !== 30) dataDays.add(day(i)); + const load = computeTrainingLoad(rows, day(59), { + dataDays, + workoutDays: new Set([day(20)]), + }); + if (load.status !== "ok") throw new Error("expected ok"); + let ctl = 0; + let atl = 0; + for (let i = 0; i < 60; i += 1) { + if (i === 20 || i === 30) continue; // held + const l = i === 0 ? 84 : i < 3 ? 42 : 0; + ctl += (l - ctl) / 42; + atl += (l - atl) / 7; + } + expect(load.asOf).toBe(day(59)); + expect(load.ctl).toBe(Math.round(ctl * 10) / 10); + expect(load.atl).toBe(Math.round(atl * 10) / 10); + expect(load.series.find((p) => p.day === day(15))?.rated).toBe(true); + expect(load.series.find((p) => p.day === day(20))?.rated).toBe(false); + expect(load.series.find((p) => p.day === day(30))?.rated).toBe(false); + expect(load.recentRatedDays).toBe(40); + expect(load.recentDays).toBe(42); + // Without the activity data the same rows would hold from day 10 on. + const blind = computeTrainingLoad(rows, day(59)); + if (blind.status !== "ok") throw new Error("expected ok"); + expect(blind.asOf).toBe(day(9)); + expect(load.ctl).toBeLessThan(blind.ctl); + }); + + it("runs the series to the end and counts a trailing gap as unrated", () => { + const rows = Array.from({ length: 40 }, (_, i) => ({ + day: day(i), + dayTrimp: 60, + })); + const load = computeTrainingLoad(rows, day(119)); + if (load.status !== "ok") throw new Error("expected ok"); + expect(load.asOf).toBe(day(39)); + expect(load.series.at(-1)?.day).toBe(day(119)); + expect(load.recentRatedDays).toBe(0); + expect(load.recentDays).toBe(42); + expect(load.daysCovered).toBe(120); }); it("answers insufficient without impulse or with too little history", () => { @@ -113,21 +177,42 @@ describe("computeTrainingLoad", () => { }); describe("readTrainingLoad", () => { - it("describes the day the strain run scored", async () => { + it("describes the day the strain run scored and reads activity days as rest", async () => { const { vi } = await import("vitest"); vi.resetModules(); - const findMany = vi.fn(async () => []); + const now = new Date("2026-10-21T01:30:00.000Z"); + const { scoreDayKey } = await import("@/lib/insights/score-row"); + const until = scoreDayKey(now); + const first = shiftDateKey(until, -59); + const cache = Array.from({ length: 10 }, (_, i) => ({ + day: shiftDateKey(first, i), + dayTrimp: i < 3 ? 80 : 0, + })); + const strainFindMany = vi.fn(async () => cache); + // Steps on every day after the stored rows; one workout on the last day. + const rollupFindMany = vi.fn(async () => + Array.from({ length: 50 }, (_, i) => ({ + bucketStart: new Date(`${shiftDateKey(first, 10 + i)}T00:00:00.000Z`), + })), + ); + const queryRaw = vi.fn(async () => [{ day: until }]); vi.doMock("@/lib/db", () => ({ - prisma: { strainTrimpCache: { findMany } }, + prisma: { + strainTrimpCache: { findMany: strainFindMany }, + measurementRollup: { findMany: rollupFindMany }, + $queryRaw: queryRaw, + }, })); const { readTrainingLoad } = await import("../training-load"); - const { scoreDayKey } = await import("@/lib/insights/score-row"); - const now = new Date("2026-10-21T01:30:00.000Z"); - await readTrainingLoad("u1", now); - const args = (findMany.mock.calls[0] as unknown[])[0] as { + const load = await readTrainingLoad("u1", now); + const args = (strainFindMany.mock.calls[0] as unknown[])[0] as { where: { day: { lte: string } }; }; - expect(args.where.day.lte).toBe(scoreDayKey(now)); + expect(args.where.day.lte).toBe(until); + if (load.status !== "ok") throw new Error("expected ok"); + expect(load.series.at(-1)).toMatchObject({ day: until, rated: false }); + expect(load.asOf).toBe(shiftDateKey(until, -1)); + expect(load.recentRatedDays).toBe(41); vi.doUnmock("@/lib/db"); }); }); diff --git a/src/lib/insights/training-load.ts b/src/lib/insights/training-load.ts index 7c56444fb..3bf7b8fa4 100644 --- a/src/lib/insights/training-load.ts +++ b/src/lib/insights/training-load.ts @@ -4,14 +4,23 @@ * * The impulse is the Banister TRIMP the nightly strain run already writes to * `strain_trimp_cache`: one row per scored UTC day, 0 on a scored day without - * training. A day WITHOUT a row was never scored (no heart-rate data reached - * the server, or the run has not happened yet), so it is not rated rather - * than read as rest: the averages hold through it instead of decaying toward - * zero, and the figures report how many recent days were rated. The figures - * describe the last stored day, so a nightly run that has not happened yet - * never turns yesterday into a rest day. Over it run the two exponentially weighted averages - * of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the - * time-constant form popularised as chronic and acute training load): + * training. The strain run writes no row on a day with neither a scorable + * workout nor active energy, so a missing row alone does not tell rest from + * absence. A day without a row is therefore classified from the underlying + * data: + * + * - a workout on the day: training that could not be scored, not rated; + * - steps, heart rate or active energy on the day: the person wore or + * carried a device and did not train, rest (impulse 0); + * - none of these: nothing reached the server, not rated. + * + * An unrated day holds the averages instead of pulling them toward zero. + * The series runs to the day the strain run scores (yesterday in UTC), so a + * stretch without data after the last rated day shows as unrated days and + * in `recentRatedDays`; `asOf` names the last rated day. Over the impulse run + * the two exponentially weighted averages of the fitness-fatigue model + * (Banister, Calvert, Savage & Bach 1975; in the time-constant form + * popularised as chronic and acute training load): * * fitness CTL_d = CTL_{d−1} + (L_d − CTL_{d−1}) / 42 * fatigue ATL_d = ATL_{d−1} + (L_d − ATL_{d−1}) / 7 @@ -21,8 +30,9 @@ * flagged `warmingUp` until {@link CTL_DAYS} days are covered. The values are * in TRIMP units: comparable with the person's own past, not with anyone * else's and not with another app's training-stress score. Descriptive only: - * no performance prediction and no advice. Pure apart from the one read. + * no performance prediction and no advice. Pure apart from the reads. */ +import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { shiftDateKey } from "@/lib/tz/format"; @@ -42,14 +52,17 @@ export interface TrainingLoadPoint { ctl: number; atl: number; tsb: number; - /** False for a day without a stored impulse: the averages held through it. */ + /** + * False for a day with neither a stored impulse nor any activity data, or + * with a workout that could not be scored: the averages held through it. + */ rated: boolean; } export type TrainingLoad = | { status: "ok"; - /** The last scored day the figures describe (UTC day key). */ + /** The last rated day the figures describe (UTC day key). */ asOf: string; ctl: number; atl: number; @@ -57,7 +70,11 @@ export type TrainingLoad = tsb: number; daysCovered: number; trainingDays: number; - /** Days of the trailing {@link CTL_DAYS} (or fewer, when covered less) with a stored impulse. */ + /** + * Rated days (a stored impulse, or activity data without a workout) of + * the trailing {@link CTL_DAYS} days up to yesterday, or fewer when + * covered less. + */ recentRatedDays: number; /** The trailing window `recentRatedDays` counts over. */ recentDays: number; @@ -74,13 +91,46 @@ export type TrainingLoad = const round1 = (x: number) => Math.round(x * 10) / 10; /** - * Run the model from the first stored day through the last stored day on or - * before `until`. A day without a row is not rated: the averages hold through - * it. Rows after `until` are ignored. + * What the read knows about the days without a stored impulse: the UTC days + * with any activity data (steps, heart rate, active energy) and the UTC days + * with a workout. + */ +export interface TrainingLoadActivity { + dataDays: ReadonlySet; + workoutDays: ReadonlySet; +} + +const NO_ACTIVITY: TrainingLoadActivity = { + dataDays: new Set(), + workoutDays: new Set(), +}; + +/** + * The impulse of one day, or `undefined` when the day is not rated: the + * stored impulse when there is one; otherwise rest (0) on a day with + * activity data and no workout, and not rated on a day with a workout that + * was never scored or with no data at all. + */ +function dayLoad( + day: string, + byDay: ReadonlyMap, + activity: TrainingLoadActivity, +): number | undefined { + const stored = byDay.get(day); + if (stored !== undefined) return stored; + if (activity.workoutDays.has(day)) return undefined; + return activity.dataDays.has(day) ? 0 : undefined; +} + +/** + * Run the model from the first stored day through `until`. A day is rated + * as {@link dayLoad} says; an unrated day holds the averages. Rows after + * `until` are ignored. */ export function computeTrainingLoad( rows: ReadonlyArray<{ day: string; dayTrimp: number }>, until: string, + activity: TrainingLoadActivity = NO_ACTIVITY, ): TrainingLoad { const byDay = new Map(); for (const row of rows) { @@ -98,17 +148,22 @@ export function computeTrainingLoad( }; } - const asOf = days[days.length - 1]; const series: TrainingLoadPoint[] = []; let ctl = 0; let atl = 0; let daysCovered = 0; - for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) { + let asOf = days[0]; + let asOfCtl = 0; + let asOfAtl = 0; + for (let day = days[0]; day <= until; day = shiftDateKey(day, 1)) { const tsb = ctl - atl; - const load = byDay.get(day); + const load = dayLoad(day, byDay, activity); if (load !== undefined) { ctl += (load - ctl) / CTL_DAYS; atl += (load - atl) / ATL_DAYS; + asOf = day; + asOfCtl = ctl; + asOfAtl = atl; } daysCovered += 1; series.push({ day, ctl, atl, tsb, rated: load !== undefined }); @@ -131,9 +186,9 @@ export function computeTrainingLoad( return { status: "ok", asOf, - ctl: round1(ctl), - atl: round1(atl), - tsb: round1(ctl - atl), + ctl: round1(asOfCtl), + atl: round1(asOfAtl), + tsb: round1(asOfCtl - asOfAtl), daysCovered, trainingDays, recentRatedDays, @@ -149,9 +204,17 @@ export function computeTrainingLoad( }; } +/** The measurement types whose presence on a day says a device was worn or carried. */ +export const TRAINING_LOAD_PRESENCE_TYPES: MeasurementType[] = [ + "ACTIVITY_STEPS", + "PULSE", + "ACTIVE_ENERGY_BURNED", +]; + /** - * Read the stored impulse and run the model up to the last stored day, at - * most the day the strain run scores (yesterday in UTC). + * Read the stored impulse, the days with activity data (from the daily + * rollups) and the days with a workout, and run the model up to the day the + * strain run scores (yesterday in UTC). */ export async function readTrainingLoad( userId: string, @@ -162,14 +225,44 @@ export async function readTrainingLoad( // graph, and this read stays off it. // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day const until = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); - const rows = await prisma.strainTrimpCache.findMany({ - where: { - userId, - day: { gte: shiftDateKey(until, -TRAINING_LOAD_READ_DAYS), lte: until }, - }, - orderBy: { day: "asc" }, - take: TRAINING_LOAD_READ_DAYS + 1, - select: { day: true, dayTrimp: true }, + const from = shiftDateKey(until, -TRAINING_LOAD_READ_DAYS); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day + const fromInstant = new Date(`${from}T00:00:00.000Z`); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day + const endInstant = new Date(`${shiftDateKey(until, 1)}T00:00:00.000Z`); + const [rows, presence, workoutDays] = await Promise.all([ + prisma.strainTrimpCache.findMany({ + where: { userId, day: { gte: from, lte: until } }, + orderBy: { day: "asc" }, + take: TRAINING_LOAD_READ_DAYS + 1, + select: { day: true, dayTrimp: true }, + }), + // Daily rollups bucket on the UTC day, the same day the strain cache + // keys on. + prisma.measurementRollup.findMany({ + where: { + userId, + granularity: "DAY", + type: { in: TRAINING_LOAD_PRESENCE_TYPES }, + bucketStart: { gte: fromInstant, lt: endInstant }, + count: { gt: 0 }, + }, + distinct: ["bucketStart"], + select: { bucketStart: true }, + }), + // One row per UTC day with a workout; `started_at` is stored as UTC. + prisma.$queryRaw>` + SELECT DISTINCT to_char("started_at", 'YYYY-MM-DD') AS day + FROM "workouts" + WHERE "user_id" = ${userId} + AND "started_at" >= ${fromInstant} AND "started_at" < ${endInstant} + `, + ]); + return computeTrainingLoad(rows, until, { + dataDays: new Set( + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a DAY rollup bucket starts at UTC midnight + presence.map((r) => r.bucketStart.toISOString().slice(0, 10)), + ), + workoutDays: new Set(workoutDays.map((r) => r.day)), }); - return computeTrainingLoad(rows, until); } diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index a64febd99..818f6080c 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -118,7 +118,7 @@ const trainingLoadPoint = z.object({ rated: z .boolean() .describe( - "False for a day without a stored impulse (never scored): the averages held through it rather than counting it as rest.", + "False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active energy), or a workout that was never scored. The averages held through it rather than counting it as rest. A day with activity data and no workout is rest (impulse 0).", ), }); @@ -129,7 +129,7 @@ const trainingLoad = z asOf: z .string() .describe( - "The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never reads as a rest day.", + "The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to yesterday, so a later stretch without data shows as unrated days.", ), ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."), atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."), @@ -140,7 +140,7 @@ const trainingLoad = z .number() .int() .describe( - "Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.", + "Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored impulse, or activity data without a workout.", ), recentDays: z.number().int(), warmingUp: z @@ -162,7 +162,7 @@ const trainingLoad = z .meta({ id: "WorkoutTrainingLoad", description: - "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.", + "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day with activity data but no training is 0, a day with no data or an unscored workout is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.", }); const workoutListResponse = z From 4fbbdb12c20e2a1ed88cd5ed332ac6b04df659b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:27:02 +0200 Subject: [PATCH 77/98] fix(fhir): page _offset as far as a series reaches The 100000 ceiling refused offsets a correctly paging client reaches on its own: an Observation series with a heart-rate history runs well past a hundred thousand rows, and the next link the server handed out led straight into a 400. The 500 the ceiling was added for came from an offset such as 1e20 that no longer floors to an exact integer and that the database cannot read as an OFFSET. The refusal now sits at the largest exact integer, and a next link never carries an offset the server would refuse. --- docs/api/openapi.yaml | 24 ++++++------- src/app/api/fhir/__tests__/route.test.ts | 43 ++++++++++++++++++++---- src/lib/fhir/rest.ts | 19 ++++++----- src/lib/openapi/routes/health-record.ts | 12 +++---- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0adfaa593..62c9b564c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1274,9 +1274,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1312,9 +1312,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. - in: query name: date schema: @@ -1349,9 +1349,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1378,9 +1378,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1408,9 +1408,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1605,9 +1605,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index eaf538a41..5d2fd4499 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -39,7 +39,12 @@ vi.mock("next/headers", () => ({ import { GET as observationGet } from "../Observation/route"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; -import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest"; +import { + loadFhirContext, + MAX_COUNT, + MAX_OFFSET, + searchsetResponse, +} from "@/lib/fhir/rest"; import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; import { observationsFromReportData } from "@/lib/fhir/resources"; @@ -147,9 +152,9 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(bundle.entry[0].resource.id).toBe("obs-3"); }); - it("refuses an offset past the ceiling with a 400 OperationOutcome", async () => { + it("refuses only an offset a number cannot carry exactly", async () => { vi.mocked(observationsFromReportData).mockReturnValue([obs(1)] as never); - for (const offset of ["100001", "1e20", "Infinity"]) { + for (const offset of ["9007199254740992", "1e20", "Infinity"]) { const res = await observationGet(req(`?_offset=${offset}`)); expect(res.status).toBe(400); const outcome = (await res.json()) as { @@ -159,9 +164,35 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(outcome.resourceType).toBe("OperationOutcome"); expect(outcome.issue[0].code).toBe("invalid"); } - // The ceiling itself still pages (an empty page past the end). - const atCeiling = await observationGet(req("?_offset=100000")); - expect(atCeiling.status).toBe(200); + // A long series pages past a hundred thousand rows, and the ceiling + // itself still pages (an empty page past the end). + for (const offset of ["100001", "250000", String(MAX_OFFSET)]) { + const res = await observationGet(req(`?_offset=${offset}`)); + expect(res.status).toBe(200); + } + }); + + it("keeps emitting next links deep into a long series, never past the ceiling", () => { + const url = new URL("http://localhost/api/fhir/Observation"); + const deep = searchsetResponse(url, [], 300_000, 50, 150_000); + const nextOf = async (res: Response) => + ( + (await res.json()) as { link: Array<{ relation: string; url: string }> } + ).link.find((l) => l.relation === "next"); + return Promise.all([ + nextOf(deep).then((next) => { + expect(next?.url).toContain("_offset=150050"); + }), + nextOf( + searchsetResponse( + url, + [], + Number.POSITIVE_INFINITY, + 50, + MAX_OFFSET - 10, + ), + ).then((next) => expect(next).toBeUndefined()), + ]); }); it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => { diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index ebd1c54e3..053ffd736 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -92,19 +92,21 @@ export const DEFAULT_COUNT = 50; export const MAX_COUNT = 200; /** - * Ceiling on `_offset`. No record pages that far (the largest search, the - * Observation time series, is bounded by the readings in a date range), and - * an unbounded offset reached the database as a skip it cannot represent and - * answered 500. + * Ceiling on `_offset`: the largest integer a JavaScript number holds + * exactly. It is no paging limit (the Observation time series of a heart-rate + * history pages well past a hundred thousand rows, and every page's `next` + * link must keep working); it refuses only an offset that cannot be passed + * on faithfully. Above it `Math.floor` returns a float such as `1e20`, which + * the database cannot read as a `bigint` `OFFSET` and which answered 500. */ -export const MAX_OFFSET = 100_000; +export const MAX_OFFSET = Number.MAX_SAFE_INTEGER; /** * Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`; * `offset` floors at 0. A non-numeric / negative input collapses to the * default rather than erroring — FHIR search is forgiving on paging params. - * An offset above {@link MAX_OFFSET} is the one refusal: the caller answers - * it with a 400 `invalid` OperationOutcome. + * An offset above {@link MAX_OFFSET} (including `Infinity`) is the one + * refusal: the caller answers it with a 400 `invalid` OperationOutcome. */ export function parsePaging( searchParams: URLSearchParams, @@ -127,6 +129,7 @@ export function parsePaging( * Build the absolute self link, and a next link when more rows remain. The * URL is rebuilt from the request URL with the paging params normalised, so * the echoed link always carries the clamped `_count` and concrete `_offset`. + * A next link never carries an offset {@link parsePaging} would refuse. */ function pagingLinks( requestUrl: URL, @@ -140,7 +143,7 @@ function pagingLinks( const links: FhirBundleLink[] = [{ relation: "self", url: self.toString() }]; const nextOffset = offset + count; - if (nextOffset < total) { + if (nextOffset < total && nextOffset <= MAX_OFFSET) { const next = new URL(requestUrl.toString()); next.searchParams.set("_count", String(count)); next.searchParams.set("_offset", String(nextOffset)); diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 20e0e4042..303055b89 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -380,7 +380,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -410,7 +410,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), date: z.string().optional(), code: z.string().optional(), @@ -442,7 +442,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -472,7 +472,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -502,7 +502,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -682,7 +682,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, From 6330908619f2c227bf992adf66e5ce5d1f1b8cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:29:19 +0200 Subject: [PATCH 78/98] fix(mcp): run detect_changepoints on the insights level-shift detector The tool kept its own CUSUM with a plain standard-deviation bar, so the per-metric floors and the drift rejection the health-status scan gained never reached it, nor the medication-effect note that reads it: a 0.6 kg weight step or a steady climb still came back as a dated shift. The tool now segments the series with the shared detector, recursing on each side of a shift with the same bar and the metric's floors. The output shape is unchanged. --- src/lib/insights/derived/changepoint.ts | 41 +++++++++-- src/lib/mcp/__tests__/rich-reads.test.ts | 52 +++++++++++++ src/lib/mcp/rich-reads.ts | 93 +++++++----------------- 3 files changed, 116 insertions(+), 70 deletions(-) diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index abbbdb39c..c993186b4 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -176,6 +176,30 @@ export function detectLevelShift( }; } +/** + * Every validated level shift in a series, by binary segmentation: find one + * with {@link detectLevelShift}, then look again on each side of it with the + * same bar, floors and drift rejection. `breakIndex` is into `series`. + * Callers that want one dated shift per metric use `detectLevelShift`; this + * is for a reader that lists several (the MCP changepoint tool). + */ +export function detectLevelShifts( + series: readonly number[], + opts: DetectLevelShiftOptions = {}, +): LevelShift[] { + const out: LevelShift[] = []; + const walk = (lo: number, hi: number) => { + const shift = detectLevelShift(series.slice(lo, hi), opts); + if (!shift) return; + const split = lo + shift.breakIndex + 1; + out.push({ ...shift, breakIndex: split - 1 }); + walk(lo, split); + walk(split, hi); + }; + walk(0, series.length); + return out; +} + /** * True when a straight line through the whole series fits at least as well as * the two-level step (same parameter count, so the residual sums compare @@ -263,6 +287,17 @@ export const CHANGEPOINT_FLOORS: Partial< VO2_MAX: { minDelta: 1.5, spreadFloor: 0.5 }, }; +/** The {@link CHANGEPOINT_FLOORS} of a metric as detector options. */ +export function levelShiftFloors( + metric: MeasurementType, +): Pick { + const floors = CHANGEPOINT_FLOORS[metric]; + return { + minAbsDelta: floors?.minDelta, + spreadFloor: floors?.spreadFloor, + }; +} + /** Window the reader looks back over (days). */ export const CHANGEPOINT_WINDOW_DAYS = 90; /** At most this many changepoints in one snapshot (strongest first). */ @@ -329,13 +364,9 @@ async function scanChangepoints( coverage, ); if (points.length === 0) continue; - const floors = CHANGEPOINT_FLOORS[metric]; const shift = detectLevelShift( points.map((p) => p.mean), - { - minAbsDelta: floors?.minDelta, - spreadFloor: floors?.spreadFloor, - }, + levelShiftFloors(metric), ); if (!shift) continue; const breakDate = diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 5a93d9a74..92f9e1a8b 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -654,6 +654,58 @@ describe("detect_changepoints", () => { expect(res.bucketsAnalysed).toBe(12); }); + it("applies the metric floors and rejects a drift, as the insights scan does", async () => { + const days = (n: number) => + Array.from( + { length: n }, + (_, i) => `2026-03-${String(i + 1).padStart(2, "0")}`, + ); + // A clean 0.6 kg step: many deviations of its own noise, but below the + // 1 kg a weight change has to reach. + vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({ + granularity: "DAY", + rows: days(12).map((d, i) => + row(d, (i < 6 ? 80 : 80.6) + (i % 2) * 0.05), + ), + }); + const small = await detectChangepoints(USER, { metric: "weight" }); + expect(small.present).toBe(false); + expect(small.reason).toBe("no_changepoint"); + // A steady climb of 1 kg a day: two halves with different means, but a + // straight line explains it, so it is a drift and not a dated shift. + vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({ + granularity: "DAY", + rows: days(20).map((d, i) => row(d, 80 + i)), + }); + const drift = await detectChangepoints(USER, { metric: "weight" }); + expect(drift.present).toBe(false); + expect(drift.reason).toBe("no_changepoint"); + }); + + it("lists more than one shift with the same bar on each side", async () => { + const values = [ + ...Array.from({ length: 10 }, (_, i) => 70 + (i % 2) * 0.2), + ...Array.from({ length: 10 }, (_, i) => 90 + (i % 2) * 0.2), + ...Array.from({ length: 10 }, (_, i) => 95 + (i % 2) * 0.2), + ]; + vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({ + granularity: "DAY", + rows: values.map((v, i) => + row(new Date(Date.UTC(2026, 2, 1 + i)).toISOString(), v), + ), + }); + const res = await detectChangepoints(USER, { metric: "weight" }); + expect(res.present).toBe(true); + expect(res.changepoints?.map((cp) => cp.at)).toEqual([ + new Date(Date.UTC(2026, 2, 11)).toISOString(), + new Date(Date.UTC(2026, 2, 21)).toISOString(), + ]); + expect(res.changepoints?.map((cp) => cp.direction)).toEqual([ + "increase", + "increase", + ]); + }); + it("honest-null when too few buckets exist", async () => { vi.mocked(readBestGranularityRollups).mockResolvedValue({ granularity: "DAY", diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index e32f4ba55..9d15570fa 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -63,7 +63,11 @@ import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot"; import { scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; import { encodeOffsetCursor } from "@/lib/mcp/pagination"; import type { CoachScopeWindow } from "@/lib/ai/coach/types"; -import { standardDeviation } from "@/lib/stats/spread"; +import { + detectLevelShifts, + levelShiftFloors, + type DetectLevelShiftOptions, +} from "@/lib/insights/derived/changepoint"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -1151,77 +1155,30 @@ export interface ChangepointsResult { } const MIN_SEGMENT = 5; -/** Mean shift must clear this many series standard deviations to fire. */ -const SHIFT_SD_FLOOR = 1.5; const MAX_CHANGEPOINTS = 5; -function mean(xs: number[]): number { - return xs.reduce((s, x) => s + x, 0) / xs.length; -} - -/** The CUSUM threshold estimates the series' noise, so it is the sample SD. */ -function stdDev(xs: number[]): number { - return standardDeviation(xs, "sample") ?? 0; -} - /** - * Minimal CUSUM binary-segmentation changepoint detector over a 1-D series. - * - * For a segment, the cumulative sum of mean-centred deviations peaks at the most - * likely split. The split fires ONLY when the resulting mean shift clears a - * conservative effect-size floor (≥ SHIFT_SD_FLOOR · series SD) and both halves - * are at least MIN_SEGMENT long — a deliberately high bar so noise does not - * register (honest-null over false positives). Accepted splits recurse on each - * half; the result is capped to the strongest MAX_CHANGEPOINTS shifts by the - * caller. - * - * Indices are into the original series. Pure / dependency-free. + * The bar the tool's segmentation runs at: the insights level-shift detector + * with the metric's floors, sized for buckets rather than days. A bucket may + * be a week or a month, so a segment of five buckets is enough to persist. */ -function cusumSegment( - values: number[], - lo: number, - hi: number, - out: Array<{ index: number; beforeMean: number; afterMean: number }>, -): void { - const n = hi - lo; - if (n < 2 * MIN_SEGMENT) return; - - const segment = values.slice(lo, hi); - const segMean = mean(segment); - const sd = stdDev(segment); - if (sd <= 0) return; - - // Cumulative sum of mean-centred deviations; track the extreme position. - let cusum = 0; - let maxAbs = 0; - let splitRel = -1; - for (let i = 0; i < n; i++) { - cusum += segment[i] - segMean; - if (Math.abs(cusum) > maxAbs) { - maxAbs = Math.abs(cusum); - // The new regime starts at the bucket AFTER the accumulation peak. - splitRel = i + 1; - } - } - if (splitRel < MIN_SEGMENT || n - splitRel < MIN_SEGMENT) return; - - const before = segment.slice(0, splitRel); - const after = segment.slice(splitRel); - const beforeMean = mean(before); - const afterMean = mean(after); - if (Math.abs(afterMean - beforeMean) < SHIFT_SD_FLOOR * sd) return; - - out.push({ index: lo + splitRel, beforeMean, afterMean }); - cusumSegment(values, lo, lo + splitRel, out); - cusumSegment(values, lo + splitRel, hi, out); +function changepointOptions(type: MeasurementType): DetectLevelShiftOptions { + return { + minSeriesLength: 2 * MIN_SEGMENT, + minSegmentLength: MIN_SEGMENT, + minPersistDays: MIN_SEGMENT, + ...levelShiftFloors(type), + }; } /** * Surface level shifts in a metric over the rollup tier. Reads the trailing * window's rollup buckets (`readBestGranularityRollups` — DAY for ≤90 days, * coarser for longer windows; the resolved granularity is reported) and runs - * the minimal CUSUM above over the bucket means. High firing bar — returns - * `{ present: false }` when too few buckets exist or no shift clears the floor. + * the insights level-shift detector over the bucket means by binary + * segmentation (`detectLevelShifts`): the same robust bar, per-metric floors + * and drift rejection as the health-status scan. Returns `{ present: false }` + * when too few buckets exist or no shift clears the bar. * Re-uses the rollup tier's already-computed bucket means; adds no new * persisted analytics. */ @@ -1291,9 +1248,15 @@ export async function detectChangepoints( const rows = rangeRows; const values = rows.map((r) => r.mean); - const found: Array<{ index: number; beforeMean: number; afterMean: number }> = - []; - cusumSegment(values, 0, values.length, found); + const found = detectLevelShifts( + values, + changepointOptions(metric.measurementType), + ).map((shift) => ({ + // The new regime starts at the bucket after the before segment. + index: shift.breakIndex + 1, + beforeMean: shift.beforeMean, + afterMean: shift.afterMean, + })); if (found.length === 0) { return { From e91957c0172180d163a33ec376912f9993917211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:32:13 +0200 Subject: [PATCH 79/98] fix(labs): word a three-year projection horizon as the cap it is The beyond-horizon line said the range held "for more than {years} years, the span your readings cover", but the horizon is capped at three years, so a ten-year history was told its readings spanned three. The trend now says whether the cap or the span set the horizon (`horizonCapped`), and the card words each case on its own in every locale. --- docs/api/openapi.yaml | 4 +++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + .../labs/__tests__/lab-trend-card.test.tsx | 29 +++++++++++++++++++ src/components/labs/lab-trend-card.tsx | 14 +++++++-- src/components/labs/types.ts | 1 + src/lib/labs/__tests__/lab-trend.test.ts | 15 +++++++++- src/lib/labs/lab-trend.ts | 13 ++++++--- src/lib/openapi/routes/biomarkers.ts | 5 ++++ 15 files changed, 81 insertions(+), 8 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 62c9b564c..25e93ba9f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -74827,6 +74827,9 @@ components: type: number description: "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when shorter." + horizonCapped: + type: boolean + description: True when `horizonYears` is the three-year cap, false when the readings' shorter span set it. projection: type: string enum: @@ -74861,6 +74864,7 @@ components: - fittedNow - asOf - horizonYears + - horizonCapped - projection - exitDate - exitBound diff --git a/messages/de.json b/messages/de.json index 5a5de7ff2..27d928e45 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10199,6 +10199,7 @@ "low": "Liefe diese Gerade so weiter, erreichte sie um den {date} das untere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage." }, "beyond_horizon": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit reichen deine Werte zurück.", + "beyond_horizon_capped": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit schaut die Fortschreibung voraus.", "no_exit": "Die Gerade läuft von den Grenzen deines Referenzbereichs weg.", "already_outside": "Die Gerade liegt bereits außerhalb deines Referenzbereichs.", "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben.", diff --git a/messages/en.json b/messages/en.json index 6d0e9d027..00bd35af2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10199,6 +10199,7 @@ "low": "If this straight line continued, it would reach the lower end of your reference range around {date}. That is a projection, not a forecast." }, "beyond_horizon": "At this pace the line stays inside your reference range for more than {years} years, the span your readings cover.", + "beyond_horizon_capped": "At this pace the line stays inside your reference range for more than {years} years, as far ahead as the projection looks.", "no_exit": "The line heads away from the edges of your reference range.", "already_outside": "The line already sits outside your reference range.", "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn.", diff --git a/messages/es.json b/messages/es.json index b912c397c..60d87894d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10199,6 +10199,7 @@ "low": "Si esta línea recta continuara, llegaría al límite inferior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico." }, "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, el periodo que cubren tus valores.", + "beyond_horizon_capped": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, hasta donde llega la proyección.", "no_exit": "La línea se aleja de los límites de tu rango de referencia.", "already_outside": "La línea ya está fuera de tu rango de referencia.", "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada.", diff --git a/messages/fr.json b/messages/fr.json index 8df07c896..5d94ed83e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10199,6 +10199,7 @@ "low": "Si cette droite se prolongeait, elle atteindrait la limite basse de ta plage de référence vers le {date}. C'est une projection, pas une prévision." }, "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, la durée couverte par tes valeurs.", + "beyond_horizon_capped": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, aussi loin que porte la projection.", "no_exit": "La droite s'éloigne des limites de ta plage de référence.", "already_outside": "La droite est déjà hors de ta plage de référence.", "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée.", diff --git a/messages/it.json b/messages/it.json index e5115f814..99c97f0ad 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10199,6 +10199,7 @@ "low": "Se questa retta proseguisse, raggiungerebbe il limite inferiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione." }, "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, il periodo coperto dai tuoi valori.", + "beyond_horizon_capped": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, fin dove arriva la proiezione.", "no_exit": "La retta si allontana dai limiti del tuo intervallo di riferimento.", "already_outside": "La retta è già fuori dal tuo intervallo di riferimento.", "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla.", diff --git a/messages/ko.json b/messages/ko.json index cad8527fb..db2d6a0b6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10199,6 +10199,7 @@ "low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다." }, "beyond_horizon": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 측정값이 다루는 기간입니다.", + "beyond_horizon_capped": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 연장선이 내다보는 최대 기간입니다.", "no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.", "already_outside": "직선이 이미 참고 범위 밖에 있습니다.", "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다.", diff --git a/messages/nl.json b/messages/nl.json index 76e8e3d6c..5c1fe7411 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10199,6 +10199,7 @@ "low": "Als deze rechte lijn zo doorliep, zou ze rond {date} de ondergrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling." }, "beyond_horizon": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, de periode die je waarden beslaan.", + "beyond_horizon_capped": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, zo ver kijkt de doortrekking vooruit.", "no_exit": "De lijn loopt weg van de grenzen van je referentiebereik.", "already_outside": "De lijn ligt al buiten je referentiebereik.", "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken.", diff --git a/messages/pl.json b/messages/pl.json index 5bd631a54..0acd8f32d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10199,6 +10199,7 @@ "low": "Gdyby ta prosta biegła dalej, osiągnęłaby dolną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza." }, "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli okres objęty twoimi wynikami.", + "beyond_horizon_capped": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli tak daleko, jak sięga ekstrapolacja.", "no_exit": "Prosta oddala się od granic twojego zakresu referencyjnego.", "already_outside": "Prosta jest już poza twoim zakresem referencyjnym.", "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja.", diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx index 7757520f1..0a30bd0e2 100644 --- a/src/components/labs/__tests__/lab-trend-card.test.tsx +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -21,6 +21,7 @@ const OK: BiomarkerTrendDto = { fittedNow: 130, asOf: "2026-10-21", horizonYears: 3, + horizonCapped: true, projection: "exits", exitDate: "2027-11-02", exitBound: "high", @@ -87,10 +88,38 @@ describe("", () => { const beyond = render({ ...OK, horizonYears: 1.5, + horizonCapped: false, projection: "beyond_horizon", exitDate: null, exitBound: null, }); expect(beyond).toContain("for more than 1.5 years"); + expect(beyond).toContain("the span your readings cover"); + }); + + it("does not call a capped three-year horizon the span of the readings", () => { + for (const locale of ["en", "de"] as const) { + const capped = render( + { + ...OK, + horizonYears: 3, + horizonCapped: true, + projection: "beyond_horizon", + exitDate: null, + exitBound: null, + }, + locale, + ); + expect(capped).toContain( + locale === "en" ? "more than 3 years" : "länger als 3 Jahre", + ); + expect(capped).not.toContain("the span your readings cover"); + expect(capped).not.toContain("so weit reichen deine Werte zurück"); + expect(capped).toContain( + locale === "en" + ? "as far ahead as the projection looks" + : "so weit schaut die Fortschreibung voraus", + ); + } }); }); diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx index 975518443..94f87a107 100644 --- a/src/components/labs/lab-trend-card.tsx +++ b/src/components/labs/lab-trend-card.tsx @@ -53,9 +53,17 @@ export function LabTrendCard({ date: fmt.dateShortSmartCalendar(trend.lastAt), }) : trend.projection === "beyond_horizon" - ? t("labs.trend.projection.beyond_horizon", { - years: fmt.number(trend.horizonYears, 1), - }) + ? t( + trend.horizonCapped + ? "labs.trend.projection.beyond_horizon_capped" + : "labs.trend.projection.beyond_horizon", + { + years: fmt.number( + trend.horizonYears, + trend.horizonCapped ? 0 : 1, + ), + }, + ) : t(`labs.trend.projection.${trend.projection}`); return ( diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 783c3d3c1..d84d55db9 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -126,6 +126,7 @@ export type BiomarkerTrendDto = fittedNow: number; asOf: string; horizonYears: number; + horizonCapped: boolean; projection: | "exits" | "beyond_horizon" diff --git a/src/lib/labs/__tests__/lab-trend.test.ts b/src/lib/labs/__tests__/lab-trend.test.ts index 9a7591b63..ec5f1462f 100644 --- a/src/lib/labs/__tests__/lab-trend.test.ts +++ b/src/lib/labs/__tests__/lab-trend.test.ts @@ -139,7 +139,20 @@ describe("computeLabTrend", () => { ]); expect( computeLabTrend(short, { low: 70, high: 125 }, atYears(1)), - ).toMatchObject({ projection: "beyond_horizon", horizonYears: 1 }); + ).toMatchObject({ + projection: "beyond_horizon", + horizonYears: 1, + horizonCapped: false, + }); + // Over four years of readings the horizon is the three-year cap. + const long = readings([ + [0, 100], + [2, 101], + [4, 102], + ]); + expect( + computeLabTrend(long, { low: 70, high: 200 }, atYears(4)), + ).toMatchObject({ horizonYears: 3, horizonCapped: true }); // The same line with a top of 118 meets it 0.8 years out: projected. expect( computeLabTrend(short, { low: 70, high: 118 }, atYears(1)), diff --git a/src/lib/labs/lab-trend.ts b/src/lib/labs/lab-trend.ts index c5781b80e..62f275aab 100644 --- a/src/lib/labs/lab-trend.ts +++ b/src/lib/labs/lab-trend.ts @@ -83,6 +83,11 @@ export interface LabTrendOk { * or the readings' span when shorter. */ horizonYears: number; + /** + * True when `horizonYears` is the three-year cap, false when the readings' + * shorter span set it. + */ + horizonCapped: boolean; projection: LabTrendProjection; /** Set only for `exits`: the day the line meets `exitBound`. */ exitDate: string | null; @@ -162,10 +167,9 @@ export function computeLabTrend( const tAsOf = stale ? tLast : now.getTime(); const xNow = (tAsOf - t0) / DAY_MS / YEAR_DAYS; const fittedNow = fit.intercept + fit.slope * xNow; - const horizonYears = Math.min( - LAB_PROJECTION_HORIZON_YEARS, - (tLast - t0) / DAY_MS / YEAR_DAYS, - ); + const spanYears = (tLast - t0) / DAY_MS / YEAR_DAYS; + const horizonCapped = spanYears >= LAB_PROJECTION_HORIZON_YEARS; + const horizonYears = Math.min(LAB_PROJECTION_HORIZON_YEARS, spanYears); let projection: LabTrendProjection; let exitDate: string | null = null; @@ -207,6 +211,7 @@ export function computeLabTrend( fittedNow: round(fittedNow, 4), asOf: dayOf(tAsOf), horizonYears: round(horizonYears, 2), + horizonCapped, projection, exitDate, exitBound, diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index b4568f1fe..fc850c598 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -103,6 +103,11 @@ const labTrend = z .describe( "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when shorter.", ), + horizonCapped: z + .boolean() + .describe( + "True when `horizonYears` is the three-year cap, false when the readings' shorter span set it.", + ), projection: z.enum([ "exits", "beyond_horizon", From 08ea2b662059b827857ede30eb7c53935d097f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:35:35 +0200 Subject: [PATCH 80/98] fix(import): keep a failing audit write from replacing the import's answer The CSV import wrote its audit row in a finally block, so an audit write that threw discarded whatever the import had settled on: the result of a finished import, the 413 of a file over the cap, or the error of one cut off part way. The failure is now noted on the wide event and the import answers as it would have. --- .../__tests__/record-import-route.test.ts | 47 ++++++++++++++++++- src/lib/import/record-import-route.ts | 39 ++++++++------- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/lib/import/__tests__/record-import-route.test.ts b/src/lib/import/__tests__/record-import-route.test.ts index 33b74f93b..934739f24 100644 --- a/src/lib/import/__tests__/record-import-route.test.ts +++ b/src/lib/import/__tests__/record-import-route.test.ts @@ -47,7 +47,11 @@ const DONE = { failed: 0, } as unknown as RecordImportResult; -beforeEach(() => auditLog.mockReset()); +// A block body: a function returned from `beforeEach` runs as its teardown, +// and `mockReset()` returns the mock itself. +beforeEach(() => { + auditLog.mockReset(); +}); describe("handleRecordImport audit", () => { it("audits a finished import as complete", async () => { @@ -88,6 +92,47 @@ describe("handleRecordImport audit", () => { }); }); + it("never lets a failing audit write replace the answer or the error", async () => { + auditLog.mockImplementation(async () => { + throw new Error("audit table unavailable"); + }); + const ok = await handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async ({ onWritten }) => { + onWritten(400); + return DONE; + }, + }); + expect(ok.status).toBe(200); + // A cut-off import still surfaces its own error. + await expect( + handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async ({ onWritten }) => { + onWritten(200); + throw new Error("client went away"); + }, + }), + ).rejects.toThrow("client went away"); + // And the size cap still answers 413. + const { CsvTooLargeError } = await import("@/lib/import/csv-stream"); + const tooLarge = await handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async ({ onWritten }) => { + onWritten(200); + throw new CsvTooLargeError(16); + }, + }); + expect(tooLarge.status).toBe(413); + expect(auditLog).toHaveBeenCalledTimes(3); + }); + it("writes no audit row for a dry run or an abort that wrote nothing", async () => { await handleRecordImport(request("1"), { kind: "labs", diff --git a/src/lib/import/record-import-route.ts b/src/lib/import/record-import-route.ts index 8035becd4..fe6e85b3a 100644 --- a/src/lib/import/record-import-route.ts +++ b/src/lib/import/record-import-route.ts @@ -126,26 +126,33 @@ export async function handleRecordImport< } finally { // The audit row is written whatever happened: an import cut off part way // (the client went away, a proxy timed out, the cap was hit after some - // chunks) has already written rows, and the trail says how many. + // chunks) has already written rows, and the trail says how many. A + // failing audit write is noted on the wide event and never replaces the + // import's own answer or error: a throw from `finally` would discard + // both, and the rows are written either way. if ( !query.dryRun && ((result !== null && result.rows > 0) || written > 0) ) { - await auditLog(action, { - userId: user.id, - ipAddress: getClientIp(request), - details: - result !== null - ? { - rows: result.rows, - inserted: result.inserted, - duplicates: result.duplicates, - failed: result.failed, - partial: false, - written, - } - : { partial: true, written }, - }); + try { + await auditLog(action, { + userId: user.id, + ipAddress: getClientIp(request), + details: + result !== null + ? { + rows: result.rows, + inserted: result.inserted, + duplicates: result.duplicates, + failed: result.failed, + partial: false, + written, + } + : { partial: true, written }, + }); + } catch { + annotate({ meta: { import_audit_failed: true } }); + } } } if (result.rows === 0) { From bd811eb753e281b69eba4028d468c1a4f2872818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:38:03 +0200 Subject: [PATCH 81/98] test(crypto): follow a pushed payload array into the write that sends it The lab CSV import builds its rows into an array and writes each chunk with one createMany({ data: toWrite }). The ciphertext-writer scan only read payload literals at the call site, so it never saw the encrypted note that write carries; the column stayed covered only by the name scan. The scan now follows an identifier payload into the literals the same file pushes onto it, and asserts it sees the chunked note write. --- .../encrypted-column-writers.test.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts index 5b2814c04..e5e007f14 100644 --- a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts +++ b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts @@ -28,9 +28,13 @@ * call or a variable derived from one. * 3. Assert the result is a subset of the rotation registry. * - * Known limit, stated so it is not mistaken for coverage: a payload assembled - * somewhere other than the call site (`data: updates` where `updates` is built - * field by field) resolves to nothing here. Those columns are covered by the + * A payload passed as an array the same file fills with `.push({…})` + * (`createMany({ data: toWrite })`, the shape a chunked writer takes) is + * followed into those pushed literals. + * + * Known limit, stated so it is not mistaken for coverage: any other payload + * assembled somewhere other than the call site (`data: updates` where + * `updates` is built field by field) resolves to nothing here. Those columns are covered by the * name scan in the sibling guard; the two signals are deliberately * independent, and a column has to escape BOTH to go unregistered. The scan * asserts a non-zero, anchored match count, so a matcher that quietly stops @@ -398,6 +402,19 @@ function scanCiphertextWrites(): Map> { depth = 0, ): void => { if (depth > 4) return; + // `data: rows` where the file fills `rows` with `rows.push({…})`: walk + // each pushed literal as part of the payload. + const ident = /^\s*(\w+)\s*$/.exec(value)?.[1]; + if (ident) { + for (const push of src.matchAll( + new RegExp(`(? { } }); + it("follows a payload array filled by push into the write that sends it", () => { + // The lab CSV import builds its rows into an array and writes each chunk + // with one `createMany({ data: toWrite })`. + expect( + [...(written.get("LabResult.noteEncrypted") ?? [])].some((at) => + at.startsWith("src/lib/import/labs-csv.ts:"), + ), + "the chunked lab CSV write of LabResult.noteEncrypted was not seen", + ).toBe(true); + }); + it("registers every column the code writes ciphertext into", () => { const registered = new Set(ENCRYPTED_COLUMNS.map(encryptedColumnKey)); const unregistered = [...written.keys()] From 13039eb430ddbd30bc47328c81ee19f7b5a2005d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:39:59 +0200 Subject: [PATCH 82/98] fix(import): write an imported medication and its category together The medication CSV import created the medication and then set its category in a second write. A crash between the two left a medication without a category, and a retry skipped it as a duplicate, so it never got one. Both writes now share one transaction. --- src/lib/import/medications-csv.ts | 82 +++++++++++---------- tests/integration/record-csv-import.test.ts | 64 +++++++++++++++- 2 files changed, 106 insertions(+), 40 deletions(-) diff --git a/src/lib/import/medications-csv.ts b/src/lib/import/medications-csv.ts index 354bfab13..c3d3b848e 100644 --- a/src/lib/import/medications-csv.ts +++ b/src/lib/import/medications-csv.ts @@ -284,48 +284,52 @@ export async function importMedicationsCsv(args: { const data = parsed.data; // Field by field, with the create route's invariants for the shapes an // import produces: a daily schedule (FREQ=DAILY, times dual-written), an - // opening course for a dated window, no schedule for as-needed. - const medication = await prisma.medication.create({ - data: { - userId, - name: data.name, - dose: data.dose, - asNeeded: data.asNeeded ?? false, - notificationsEnabled: data.notificationsEnabled ?? false, - ...(course - ? { - startsOn: course.startsOn, - endsOn: course.endsOn, - courses: { - create: { - userId, - startsOn: course.startsOn, - endsOn: course.endsOn, + // opening course for a dated window, no schedule for as-needed. The + // category is written in the same transaction: a crash between the two + // left a medication without one, and the retry skips it as a duplicate. + await prisma.$transaction(async (tx) => { + const medication = await tx.medication.create({ + data: { + userId, + name: data.name, + dose: data.dose, + asNeeded: data.asNeeded ?? false, + notificationsEnabled: data.notificationsEnabled ?? false, + ...(course + ? { + startsOn: course.startsOn, + endsOn: course.endsOn, + courses: { + create: { + userId, + startsOn: course.startsOn, + endsOn: course.endsOn, + }, }, - }, - } - : {}), - schedules: { - create: (data.schedules ?? []).map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: null, - dose: null, - daysOfWeek: serializeScheduleRecurrence({ - daysOfWeek: [], - intervalWeeks: 1, - }), - timesOfDay: - s.timesOfDay && s.timesOfDay.length > 0 - ? s.timesOfDay - : [s.windowStart], - rrule: "FREQ=DAILY", - })), + } + : {}), + schedules: { + create: (data.schedules ?? []).map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: null, + dose: null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: [], + intervalWeeks: 1, + }), + timesOfDay: + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart], + rrule: "FREQ=DAILY", + })), + }, }, - }, - select: { id: true }, + select: { id: true }, + }); + await setMedicationCategory(medication.id, undefined, tx); }); - await setMedicationCategory(medication.id, undefined); written += 1; args.onWritten?.(1); } diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index 7e28273ab..de25d0251 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -8,7 +8,8 @@ * - re-uploading the same file writes nothing: every row is a duplicate; * - a refused row is reported by line and does not stop the rest; * - medications take a daily schedule, reminders off, as-needed without - * schedule, and a dated window opens a course. + * schedule, and a dated window opens a course; + * - a medication and its category land together or not at all. */ import { beforeAll, describe, expect, it, vi } from "vitest"; @@ -27,6 +28,20 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn(() => null), })); +const categoryFault = vi.hoisted(() => ({ on: false })); +vi.mock("@/lib/medication-category", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + setMedicationCategory: vi.fn( + async (...args: Parameters) => { + if (categoryFault.on) throw new Error("category write failed"); + return actual.setMedicationCategory(...args); + }, + ), + }; +}); const prisma = getPrismaClient(); const USER = "csv-import-user"; @@ -367,3 +382,50 @@ describe("medication CSV import", () => { expect(again).toMatchObject({ inserted: 0, duplicates: 4 }); }); }); + +describe("medication CSV import, a crash between medication and category", () => { + const CSV = ["name,dose,times", "Atorvastatin,20 mg,20:00"].join("\n"); + const query = medicationsCsvQuerySchema.parse({ + name: "0", + dose: "1", + times: "2", + dryRun: "0", + }); + + it("leaves no medication without a category, so the retry writes it whole", async () => { + categoryFault.on = true; + await expect( + importMedicationsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(CSV), + maxBytes: 100_000, + query, + }), + ).rejects.toThrow("category write failed"); + categoryFault.on = false; + expect( + await prisma.medication.count({ + where: { userId: USER, name: "Atorvastatin" }, + }), + ).toBe(0); + + const retry = await importMedicationsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(CSV), + maxBytes: 100_000, + query, + }); + expect(retry).toMatchObject({ inserted: 1, duplicates: 0 }); + const med = await prisma.medication.findFirstOrThrow({ + where: { userId: USER, name: "Atorvastatin" }, + select: { id: true }, + }); + expect( + await prisma.medicationCategoryAssignment.count({ + where: { medicationId: med.id }, + }), + ).toBe(1); + }); +}); From 663b8756a6403689472888b36f8e958dc201bf46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:40:08 +0200 Subject: [PATCH 83/98] docs(import): say why lab CSV duplicates are cut on the read zone's day A wall-clock time read in another zone on a retry is a different instant, so neither an exact-instant match nor the zone's day catches it near midnight, and widening the match would drop a genuine reading of the same value on the neighbouring day. The earlier import's zone is not stored, so the comment states the rule that never loses a reading. --- src/lib/import/labs-csv.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/import/labs-csv.ts b/src/lib/import/labs-csv.ts index e3308503a..92ec2e998 100644 --- a/src/lib/import/labs-csv.ts +++ b/src/lib/import/labs-csv.ts @@ -378,6 +378,18 @@ export async function importLabsCsv(args: { } // 2. Duplicates against the record, one query for the chunk. + // + // The day is cut in the zone the file is read in. Only a reading with a + // wall-clock time depends on it: a date alone is stored at the noon-UTC + // anchor and a time with an offset is one instant, and both land on the + // same day in every zone. A wall-clock time read in another zone on a + // retry is a different instant, and near midnight a different day, so it + // can be written twice. Matching on the exact instant would not catch it + // (the instant moved with the zone), and widening the match by the zone + // difference would also swallow a genuine reading of the same value on + // the neighbouring day. The zone the earlier import read the file in is + // not stored, so the record cannot tell those two apart; the day in the + // current zone is the one rule that never drops a real reading. const days = planned .map((p) => labReadingDay(p.row.takenAt, timezone)) .sort(); From e96a5f834f21ab8325831baa4e59c7e2c2b5e508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:42:12 +0200 Subject: [PATCH 84/98] test(pulse): drop the MCP changepoint exemption from the day-mean guard detect_changepoints no longer averages bucket means itself; it hands them to the shared level-shift detector, so the file has no in-memory mean left for the guard to list. --- src/__tests__/pulse-day-mean-reader-guard.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index bdd7656ac..5e36ecbf8 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -160,10 +160,6 @@ const TS_READERS: Record = { "lib/insights/narrative/period-narrative.ts": { exempt: "means of day-keyed series, already one value per day", }, - "lib/mcp/rich-reads.ts": { - exempt: - "changepoints over rollup bucket means, which weigh pulse days once (readCanonicalRollupBuckets)", - }, "lib/medications/efficacy/build-efficacy.ts": { exempt: "means of the day-mean series (readDayMeanSeries)", }, From 455e92c5a59f0961e1b80814ab4f05c31a2cf99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:46:34 +0200 Subject: [PATCH 85/98] chore(release): v1.44.0 --- CHANGELOG.md | 107 ++++++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff9c8570e..17d339d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,112 @@ # Changelog +## [1.44.0] — 2026-10-22 + +### Added + +- **Import lab results and medications from a spreadsheet.** Settings > + Export & Import has a card that reads the head of a CSV file, suggests a + column for each field from its header, and lets you correct the + separator, decimal mark, date order and time zone. A check run lists + every refused row with its line and reason before anything is written, + and importing the same file again only finds duplicates. Templates for + both kinds download from the card. (#1215) +- **An activity calendar** on the workouts page: a week or month of steps, + active minutes, workouts by sport and training load, each day measured + against your own usual range and each period against the ones before. + Weekly and monthly goals are optional. Today counts as in progress until + it is over. +- **Fitness, fatigue and form** above the workout list, from the training + impulse HealthLog already stores. A day with activity data but no + training counts as rest; a day without any data is left out instead of + counted, and the card says how current the figures are. +- **Where a value comes from.** Each metric page links to a view that lists + every source with readings for a day, its place in your source priority + and which one the charts use, with a side-by-side comparison of two + sources over 30, 90 or 365 days. +- **Lab trends.** A marker with at least three readings over a year shows + its slope, and when the straight line would leave the reference range + within three years. A series whose newest reading is more than a year + old is shown without a projection. +- **The change at a medication start**, as a level and a trend change with + intervals, next to the existing before and after comparison. Days with + missed doses, pauses and other medications that changed in the window are + accounted for, and the view says when the data are too thin. +- **More in the doctor report:** sleep duration, HRV as SDNN and RMSSD, + daily steps, a workout summary per sport and the medication effect, each + only when its module is on. The medication effect is included only when + the medication list is shared too. The report names the window it + analysed. +- **More correlations:** water, caffeine, micronutrients, alcohol days, + workout minutes, training impulse, activity ring minutes, State of Mind + and room climate against that night's sleep. State of Mind, mindful + minutes and alcohol days stay on the correlations page and are never + handed to the Coach or the narrative cards. +- **Level shifts in thirteen vitals over 180 days** instead of five over 90. + Slow drifts and plateaus no longer count as a shift. +- **Mental health screeners** show whether a change reaches the threshold + that counts as meaningful. +- **Apple Health:** exercise, stand and mindful minutes, stand hours, State + of Mind and AFib burden. Workout GPS tracks are kept only when you turn + that on (Settings > Data & privacy); tracks already stored stay, and + accounts that already kept tracks start with it on. +- **Oura:** workouts, intraday heart rate and a full history backfill that + resumes after a restart. +- **Indoor climate** (CO2, temperature, humidity) through the measurement + API, with a Home Assistant blueprint. +- **A guardian can connect a managed profile's integrations** (Withings, + WHOOP, Oura, Polar, Strava, Google Health), and each card says whose + record it connects for. +- **FHIR:** Observations as a time series by `date` and `code`, with RMSSD + and workouts coded. + +### Changed + +- **Settings are sorted into nine groups:** Account, Tracking, + Arrangement, Notifications, Health profile, AI, Connections, Data and + System. Every old address still works and forwards to the new place. On + a phone one button names the current section and opens the list. Each + card saves the same way and asks once before anything destructive, and + German wording uses one word per thing. +- **All OAuth integrations refresh their tokens the same way**, so a + refresh that races another one no longer signs you out of Strava, Google + Health or Withings. One provider account can be linked to only one + record. +- **Imports from Apple Health insert large batches faster**, and the health + check answers "starting" while the background jobs come up, so a slow + boot no longer marks the container unhealthy. + +### Fixed + +- Oura no longer imports the equivalent walking distance as distance. +- A workout synced from two sources counts once in strain, the briefing + and the day view. +- The Coach's provider and model switch is back, and notification channel + switches act on the first tap again. +- Jobs queued during start-up wait for the queue instead of being dropped. + +### Upgrade notes + +- **1536 MB is the supported memory floor for the app container.** This is + the existing default of `APP_MEMORY_LIMIT`; if you lowered it, raise it + back. Below the floor the app still starts, but the boot log, the + support report and Admin > System status say so. Plan for 3 GB of RAM on + the host. +- Migrations 0424, 0430 to 0434, 0440 to 0442 and 0470 run on start. They + add columns and settings, turn the GPS track setting on for accounts + that already have tracks, and drop a cached copy of the AI insights + text, which is rebuilt on the next run. Workouts with a track but no + elevation get their climb filled in by a background pass after the + first start. +- If you use Fitbit, connect Google Health before 30 October 2026, when + Google turns the old Fitbit API off. + +### Thanks + +Thanks to @Cnote43 for the case for a spreadsheet import of twenty years of +lab results (#1215), and to @alondin and @haritowa for staying with the +Oura connection until it held (#1204). + ## [1.43.11] — 2026-10-21 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 25e93ba9f..6cc0471ec 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.43.11 + version: 1.44.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 761b2393a..15a5b2028 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.43.11", + "version": "1.44.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 7132c296a..58b5aebf1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.43.11"; + /* @sw-version-fallback */ "v1.44.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 832a9da98a4915b7dd14119d2752279ac824f718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:12:17 +0200 Subject: [PATCH 86/98] test(backup): give the activity goals a valid fill in the settings round trip The restore admits activity goals key by key against the goals schema, so the generic { roundTrip } JSON fill was refused as an unknown goal rather than carried. Fill the column with real goals and pin the per-goal admission (valid goals kept, unknown keys and out-of-bounds goals named) in the unit suite. --- .../__tests__/account-settings-backup.test.ts | 35 +++++++++++++++++++ tests/integration/backup-round-trip.test.ts | 3 ++ 2 files changed, 38 insertions(+) diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index 09b105b6f..4784869d2 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -198,6 +198,41 @@ describe("admitAccountSettings", () => { ]); }); + it("keeps the valid activity goals and names the ones it drops", () => { + const { data, refused } = admitAccountSettings( + { + activityGoalsJson: { + workoutsPerWeek: 4, + activeMinutesPerWeek: 150, + stepsPerWeek: 70_000, + stepsPerMonth: 300_000, + stepsPerDay: 10_000, + workoutsPerWeekTypo: "four", + }, + }, + ctx, + ); + expect(data.activityGoalsJson).toEqual({ + workoutsPerWeek: 4, + activeMinutesPerWeek: 150, + stepsPerWeek: 70_000, + stepsPerMonth: 300_000, + }); + expect(refused.sort()).toEqual([ + "activityGoalsJson.stepsPerDay", + "activityGoalsJson.workoutsPerWeekTypo", + ]); + }); + + it("refuses an activity goal outside its bounds and keeps the rest", () => { + const { data, refused } = admitAccountSettings( + { activityGoalsJson: { workoutsPerWeek: 0, stepsPerWeek: 50_000 } }, + ctx, + ); + expect(data.activityGoalsJson).toEqual({ stepsPerWeek: 50_000 }); + expect(refused).toEqual(["activityGoalsJson.workoutsPerWeek"]); + }); + // #1126 — the reasoning setting lives on the provider chain entries, so // the chain column carries it through a backup unchanged. it("carries the provider chain's reasoning settings through a round trip", async () => { diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index fcf557ca4..408ed1b73 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -4239,6 +4239,9 @@ const USER_FILL_OVERRIDES: Readonly> = { "User.aiBaseUrl": "https://llm.example.com/v1", "User.aiCompatBaseUrl": "https://gateway.example.com/v1", "User.thresholdsJson": { WEIGHT: { min: 60, max: 80 } }, + // The restore admits goal by goal against the goals schema, so the generic + // `{ roundTrip }` blob would be refused as an unknown key, not carried. + "User.activityGoalsJson": { workoutsPerWeek: 4, stepsPerMonth: 250_000 }, "User.globalExcludedInjectionSites": ["THIGH_LEFT"], "User.avatarBytes": () => { const bytes = new Uint8Array(new ArrayBuffer(AVATAR_PNG.byteLength)); From f4acf0e246a44e179870822626ac772e4b18dcb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:15:01 +0200 Subject: [PATCH 87/98] test(openapi): call the provenance reads and fill the list layout in the GET contract run The two provenance reads take required query parameters and had no entry, so the run could not call them. Give them a day and a range on the steps, seed a second steps source for the last week so the pick and the comparison have two sources to explain, and save a labs list order so the module list layout read answers with a row. --- tests/integration/contract-seed.ts | 29 +++++++++++++++++++ .../integration/openapi-get-contract.test.ts | 16 ++++++++++ 2 files changed, 45 insertions(+) diff --git a/tests/integration/contract-seed.ts b/tests/integration/contract-seed.ts index c297e90c2..a08484fd2 100644 --- a/tests/integration/contract-seed.ts +++ b/tests/integration/contract-seed.ts @@ -255,6 +255,19 @@ export async function seedContractAccount( : {}), })), }); + // A second source for the steps on the last week, so the provenance reads + // explain a pick between two sources and the comparison has a pair. + await prisma.measurement.createMany({ + data: [1, 2, 3, 4, 5, 6, 7].map((n) => ({ + userId: CONTRACT_USER_ID, + type: "ACTIVITY_STEPS" as const, + value: 6500 + ((n * 613) % 4000), + unit: "count", + measuredAt: daysAgo(n, 21), + source: "APPLE_HEALTH" as const, + externalId: `contract-steps-watch-${n}`, + })), + }); const firstMeasurement = await prisma.measurement.findFirst({ where: { userId: CONTRACT_USER_ID, type: "WEIGHT" }, orderBy: { measuredAt: "desc" }, @@ -790,6 +803,22 @@ export async function seedContractAccount( { id: ids.lab }, ); } + // A saved manual order for the labs list, so the layout read carries an + // order with an id in it. + if (ids.lab) { + await write( + "module-list-layout", + "PUT", + "api/settings/module-list-layout", + { + version: 1, + list: "labs", + view: "list", + order: [ids.lab], + sortDir: "manual", + }, + ); + } await write("procedure", "POST", "api/encounters", { kind: "PROCEDURE", occurredAt: daysAgo(25).toISOString(), diff --git a/tests/integration/openapi-get-contract.test.ts b/tests/integration/openapi-get-contract.test.ts index 47fffdb99..f21ed4cbc 100644 --- a/tests/integration/openapi-get-contract.test.ts +++ b/tests/integration/openapi-get-contract.test.ts @@ -210,6 +210,22 @@ function queriesFor( return [{ nutrient: "vitamin_d" }]; case "/api/day/index": return [{ from: day(14), to: day(0) }]; + case "/api/measurements/provenance": + return [ + { type: "ACTIVITY_STEPS", date: day(2) }, + { type: "WEIGHT", date: day(2) }, + ]; + case "/api/measurements/provenance/compare": + return [ + { type: "ACTIVITY_STEPS", from: day(14), to: day(0) }, + { + type: "ACTIVITY_STEPS", + from: day(14), + to: day(0), + a: "APPLE_HEALTH", + b: "MANUAL", + }, + ]; case "/api/documents/inbound/source": return [{ sourceSystem: "PAPERLESS", sourceId: "4711" }]; case "/api/documents/sources/{system}/search": From 75f9ef150764f8965b57ffcc83ade8c73c6f300b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:15:01 +0200 Subject: [PATCH 88/98] test(workouts): opt the account in before expecting a stored track GPS tracks are kept only after the account turns storeWorkoutRoutes on. The two route-storing cases opt in first, and a new case proves that an account which has not opted in keeps the workout, answers it as inserted, and stores no track. --- .../integration/workout-batch-create.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/integration/workout-batch-create.test.ts b/tests/integration/workout-batch-create.test.ts index 190fa34ef..00e431dfd 100644 --- a/tests/integration/workout-batch-create.test.ts +++ b/tests/integration/workout-batch-create.test.ts @@ -137,6 +137,17 @@ function makeRequest( }); } +/** + * GPS tracks are kept only after the account opts in (v1.44, + * `storeWorkoutRoutes`, off by default). The route-storing cases turn it on. + */ +async function keepWorkoutRoutes(): Promise { + await getPrismaClient().user.update({ + where: { id: TEST_USER_ID }, + data: { storeWorkoutRoutes: true }, + }); +} + function baseWorkout( externalId: string, overrides: WorkoutFixture = {}, @@ -154,6 +165,7 @@ function baseWorkout( describe("POST /api/workouts/batch (real Postgres)", () => { it("inserts a single workout with a nested route", async () => { const { POST } = await import("@/app/api/workouts/batch/route"); + await keepWorkoutRoutes(); const body = { workouts: [ @@ -216,6 +228,58 @@ describe("POST /api/workouts/batch (real Postgres)", () => { ).toEqual(body.workouts[0]!.route!.geometry); }); + it("keeps the workout and drops its track for an account that has not opted in", async () => { + const { POST } = await import("@/app/api/workouts/batch/route"); + const before = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: TEST_USER_ID }, + select: { storeWorkoutRoutes: true }, + }); + expect(before.storeWorkoutRoutes).toBe(false); + + const body = { + workouts: [ + baseWorkout("hk-uuid-no-optin-001", { + totalDistanceM: 5200, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + }), + ], + }; + + const res = await POST(makeRequest(body)); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + inserted: number; + entries: Array<{ index: number; status: string }>; + }; + }; + // The entry status speaks for the workout, which was stored; the track + // follows the account's own setting, which the client reads separately. + expect(json.data.inserted).toBe(1); + expect(json.data.entries).toEqual([{ index: 0, status: "inserted" }]); + + const workouts = await getPrismaClient().workout.findMany({ + where: { userId: TEST_USER_ID }, + include: { route: true }, + }); + expect(workouts).toHaveLength(1); + expect(workouts[0]?.totalDistanceM).toBe(5200); + expect(workouts[0]?.route).toBeNull(); + expect( + await getPrismaClient().workoutRoute.count({ + where: { workout: { userId: TEST_USER_ID } }, + }), + ).toBe(0); + }); + it("persists a route-independent HR series for an indoor workout (v1.10.0)", async () => { const { POST } = await import("@/app/api/workouts/batch/route"); @@ -254,6 +318,7 @@ describe("POST /api/workouts/batch (real Postgres)", () => { it("inserts a 100-workout batch with a mix of routed and routeless entries", async () => { const { POST } = await import("@/app/api/workouts/batch/route"); + await keepWorkoutRoutes(); const workouts: WorkoutFixture[] = Array.from({ length: 100 }, (_, i) => { const offsetMin = i * 60; From 41c906e862c785d36dd3af6fdd8a8b0342a04f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:19:01 +0200 Subject: [PATCH 89/98] test(encryption): drop the insights cache step from the backfill summaries The free-text backfill no longer has an insights cache step since the readable cached-text column was dropped, so its summary carries no insightsCachesMigrated count. The expected summaries follow. --- tests/integration/free-text-encryption-backfill.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/integration/free-text-encryption-backfill.test.ts b/tests/integration/free-text-encryption-backfill.test.ts index beb3ef479..fe97cd336 100644 --- a/tests/integration/free-text-encryption-backfill.test.ts +++ b/tests/integration/free-text-encryption-backfill.test.ts @@ -140,7 +140,6 @@ describe("free-text encryption backfill (real Postgres)", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); expect(await runFreeTextEncryptionBackfillForUser("ft-b")).toEqual({ conversationTitlesMigrated: 0, @@ -149,7 +148,6 @@ describe("free-text encryption backfill (real Postgres)", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); const conversations = await prisma.coachConversation.findMany({ @@ -198,7 +196,6 @@ describe("free-text encryption backfill (real Postgres)", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); }); @@ -283,7 +280,6 @@ describe("free-text encryption backfill (real Postgres)", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({ conversationTitlesMigrated: 0, @@ -292,7 +288,6 @@ describe("free-text encryption backfill (real Postgres)", () => { routeGeometriesMigrated: 1, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); const practitioners = await prisma.practitioner.findMany({ @@ -353,7 +348,6 @@ describe("free-text encryption backfill (real Postgres)", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); }); @@ -451,7 +445,6 @@ describe("free-text encryption backfill (real Postgres)", () => { expect(summaryA).toMatchObject({ appointmentAddressesCleared: 1, contactAuditRowsScrubbed: 1, - insightsCachesMigrated: 0, }); await runFreeTextEncryptionBackfillForUser("ad-b"); From ebdb63f0d86e455af94d40e9be5376c8c456bad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:36:06 +0200 Subject: [PATCH 90/98] perf(insights): keep Zod off the first load of the metric pages The value domain read the plausibility band out of the measurement validation module, so every metric sub-page that asks whether a metric can be negative or is integer-valued loaded the measurement schemas and Zod with them. The band and its check live in a Zod-free module now, re-exported by the validation module for the server writers, and the values list checks its segment against the exhaustive type label map instead of the Zod enum. About fifty metric routes drop roughly 88 KB gzip from their first load. The client-route guard walks a metric page and the values list too. --- src/__tests__/client-route-zod-guard.test.ts | 8 +- src/app/insights/values/[type]/page.tsx | 9 +- src/lib/measurements/plausibility-gate.ts | 4 +- src/lib/measurements/value-domain.ts | 4 +- src/lib/measurements/value-ranges.ts | 259 +++++++++++++++++++ src/lib/validations/measurement.ts | 258 +----------------- 6 files changed, 284 insertions(+), 258 deletions(-) create mode 100644 src/lib/measurements/value-ranges.ts diff --git a/src/__tests__/client-route-zod-guard.test.ts b/src/__tests__/client-route-zod-guard.test.ts index e50290239..ad2f39e9a 100644 --- a/src/__tests__/client-route-zod-guard.test.ts +++ b/src/__tests__/client-route-zod-guard.test.ts @@ -1,5 +1,6 @@ /** - * The documents vault and the Coach load no schema library in the browser. + * The documents vault, the Coach and the metric pages load no schema library + * in the browser. * * Both routes reached zod through a constant: the list of document kinds, the * bulk limit, the attachment cap, the Coach data clusters, a conversation @@ -121,6 +122,11 @@ describe("client routes without the schema library", () => { "src/app/documents/page.tsx", "src/app/coach/page.tsx", "src/app/coach/conversations/page.tsx", + // The metric sub-pages read the plausibility band through the value + // domain (`src/lib/measurements/value-ranges.ts`), and the values list + // checks its segment against the type label map. + "src/app/insights/pulse/page.tsx", + "src/app/insights/values/[type]/page.tsx", ]) { it(`${route} ships no zod in its static client graph`, () => { const hits = clientZodImports(route); diff --git a/src/app/insights/values/[type]/page.tsx b/src/app/insights/values/[type]/page.tsx index e54f9868c..0f9c25633 100644 --- a/src/app/insights/values/[type]/page.tsx +++ b/src/app/insights/values/[type]/page.tsx @@ -8,8 +8,8 @@ import { INSIGHTS_OVERVIEW, resolveValuesBackHref, } from "@/lib/insights/values-back-link"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; +import { MEASUREMENT_TYPE_LABEL_KEYS as EVERY_MEASUREMENT_TYPE } from "@/lib/measurements/type-label-keys"; import { BackLink } from "@/components/ui/back-link"; import { MeasurementList } from "@/components/measurements/measurement-list"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -38,8 +38,11 @@ export default function InsightsMetricValuesPage({ const searchParams = useSearchParams(); // Reject any segment that is not a real MeasurementType so a typo'd - // deep-link 404s instead of mounting a blank list. - if (!measurementTypeEnum.options.includes(type as never)) { + // deep-link 404s instead of mounting a blank list. The leaf label map is + // typed `Record`, so the compiler holds it to + // exactly the enum, and it answers the question without pulling the + // measurement schemas, and Zod, into the page. + if (!Object.hasOwn(EVERY_MEASUREMENT_TYPE, type)) { notFound(); } diff --git a/src/lib/measurements/plausibility-gate.ts b/src/lib/measurements/plausibility-gate.ts index d079f5391..5fe8197da 100644 --- a/src/lib/measurements/plausibility-gate.ts +++ b/src/lib/measurements/plausibility-gate.ts @@ -1,7 +1,7 @@ /** * The ingest-side half of the app's plausibility domain. * - * `VALUE_RANGES` in `@/lib/validations/measurement` is the band the + * `VALUE_RANGES` in `@/lib/measurements/value-ranges` is the band the * application declares a metric can occupy — PULSE 20–300, WEIGHT 1–500, * SLEEP_DURATION 0–1440 minutes. Every path a person can drive enforces it: * the measurement routes, the batch endpoint, the CSV and Apple-export @@ -46,7 +46,7 @@ */ import { annotate, getEvent } from "@/lib/logging/context"; import { isPlausibleMetricValue } from "@/lib/measurements/value-domain"; -import { VALUE_RANGES } from "@/lib/validations/measurement"; +import { VALUE_RANGES } from "@/lib/measurements/value-ranges"; /** * Stable meta key the tally lands under. Pinned so dashboards can count it diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts index 1ae492854..eb0021bcd 100644 --- a/src/lib/measurements/value-domain.ts +++ b/src/lib/measurements/value-domain.ts @@ -10,7 +10,7 @@ * same question, two answers. The fact is a property of the METRIC, not * of the surface that renders it, so it belongs beside the metric — and * it is already declared there: `VALUE_RANGES` in - * `@/lib/validations/measurement` is the app-wide plausibility domain + * `@/lib/measurements/value-ranges` is the app-wide plausibility domain * every ingest path validates against, and its `min` IS the metric's * floor. Nothing new to maintain; this module only reads it. Two types * declare a genuinely signed floor (`ANS_CHARGE`, @@ -30,7 +30,7 @@ * real quantity too, but a step is indivisible, so the metric is read as a * count and rendered as one. */ -import { VALUE_RANGES } from "@/lib/validations/measurement"; +import { VALUE_RANGES } from "@/lib/measurements/value-ranges"; /** * Decimal places used for a metric with no discreteness constraint — the diff --git a/src/lib/measurements/value-ranges.ts b/src/lib/measurements/value-ranges.ts new file mode 100644 index 000000000..e37d1eba7 --- /dev/null +++ b/src/lib/measurements/value-ranges.ts @@ -0,0 +1,259 @@ +/** + * The plausibility band each measurement type may occupy, and the check every + * ingest path runs against it. Zod-free on purpose: the value-domain facts + * (`value-domain.ts`) read the band on the client, and this module keeps that + * read from pulling the measurement schemas, and Zod with them, into every + * metric page. `@/lib/validations/measurement` re-exports both for the + * server-side writers. + */ + +// Plausible ranges per measurement type +export const VALUE_RANGES: Record = { + WEIGHT: { min: 1, max: 500 }, + BLOOD_PRESSURE_SYS: { min: 40, max: 300 }, + BLOOD_PRESSURE_DIA: { min: 20, max: 200 }, + PULSE: { min: 20, max: 300 }, + BODY_FAT: { min: 1, max: 80 }, + // Minutes (v1.4.23 unit change). Per-stage rows can run from a few + // seconds (a brief awakening) to 600+ minutes (a long IN_BED block), + // so the upper bound covers a 24-hour day. + SLEEP_DURATION: { min: 0, max: 1440 }, + ACTIVITY_STEPS: { min: 0, max: 200000 }, + BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia + TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg + BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) + // Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the + // healthy resting range at 95–100%. We accept down to 50% so a faulty-sensor + // critically-low reading still gets logged for the doctor to see; below 50% + // is incompatible with sustained life and almost certainly a sensor glitch. + OXYGEN_SATURATION: { min: 50, max: 100 }, + // ── v1.4.23 Apple Health ranges ── + // HRV SDNN ms — Apple's own "high HRV" threshold sits ~80 ms; lows can + // reach 5–10 ms in stressed/sick samples. 200 ms is a generous upper + // bound for unusually relaxed athletic windows. + HEART_RATE_VARIABILITY: { min: 1, max: 200 }, + // Resting HR bpm — endurance athletes can reach the high 30s; severe + // tachycardia caps below 220. + RESTING_HEART_RATE: { min: 25, max: 220 }, + // Active energy kcal/sample — a daily-rollup sample tops out around + // 6–8000 kcal even for ultra-endurance days. + ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 }, + // Flights — vertical-feet/3 ≈ flights; cap generous. + FLIGHTS_CLIMBED: { min: 0, max: 1000 }, + // Walking/running distance per sample (metres). 200 km covers an + // ultra-marathon or a multi-stage day. + WALKING_RUNNING_DISTANCE: { min: 0, max: 200000 }, + // VO2 max mL/(kg·min) — elite endurance hovers ~85; sedentary floor ~10. + VO2_MAX: { min: 5, max: 100 }, + // Body temperature °C — survivable lows ~28; severe hyperthermia ~45. + BODY_TEMPERATURE: { min: 28, max: 45 }, + // ── v1.4.25 W5d Withings full coverage ── + // Fat-free mass kg — adult plausibility, ~30 kg (small adult) to 120 kg + // (large lean athlete). Same bounds as weight minus a fat-mass floor. + FAT_FREE_MASS: { min: 10, max: 250 }, + // Fat mass kg — pairs with FAT_FREE_MASS so totals reconcile to weight. + FAT_MASS: { min: 0, max: 250 }, + // Muscle mass kg — sub-component of fat-free mass; widest sensible bound. + MUSCLE_MASS: { min: 5, max: 200 }, + // Skin temperature °C — surface temps run cooler than core. ScanWatch's + // dermal sensor reports a relative offset that lands in the 25–40 °C + // band; treat anything outside as a sensor glitch. + SKIN_TEMPERATURE: { min: 20, max: 45 }, + // Pulse-wave velocity m/s — clinical ranges sit ~4–15 m/s. Higher + // numbers indicate stiffer arteries (cardiovascular risk). + PULSE_WAVE_VELOCITY: { min: 1, max: 30 }, + // Vascular age in years — Withings derives this from PWV + chronological + // age; the value is a "biological age" not a chronological one. Hard cap + // at 130 because that's the human longevity record. + VASCULAR_AGE: { min: 10, max: 130 }, + // Visceral fat rating 1–12 (Withings' own scale; not a percent). + VISCERAL_FAT: { min: 0, max: 30 }, + // ── v1.4.25 W8d Apple Health server-prep ── + // Audio exposure dBA — Apple's "loud audio" warning sits at 80 dBA; + // concerts run 100–115 dBA; 140 dBA is the pain threshold. The floor + // of 30 dBA covers a quiet bedroom; anything below is silence and + // almost certainly a sensor artefact. + AUDIO_EXPOSURE_ENV: { min: 30, max: 140 }, + // Headphone audio caps slightly below the open-air upper edge because + // sealed-driver listening physically cannot reach a concert PA's SPL. + AUDIO_EXPOSURE_HEADPHONE: { min: 30, max: 140 }, + // Time in daylight (minutes/day) — full 24-hour window. Outdoor work + // tops the range; sedentary indoor days sit near 0. + TIME_IN_DAYLIGHT: { min: 0, max: 1440 }, + // ── v1.5.5 iOS-coord additions ── + // Respiratory rate breaths/min — adult range ~12-20 at rest; severe + // distress can spike past 40; bradypnea floor ~4. Generous bounds. + RESPIRATORY_RATE: { min: 3, max: 60 }, + // BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity. + // Outside the band is almost certainly a height-or-weight typo. + BODY_MASS_INDEX: { min: 8, max: 70 }, + // Lean body mass kg — adult plausibility, ~25 kg (small adult) to + // 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS. + LEAN_BODY_MASS: { min: 10, max: 250 }, + // Walking heart rate average bpm — endurance athletes can sit in + // the high 70s while walking; ailing or post-stimulant readings can + // touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE. + WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 }, + // Gait percent (0-100). Apple ships these as 0..1 fractions; after + // the ×100 server-side scaling the canonical band is 0..100. + WALKING_ASYMMETRY: { min: 0, max: 100 }, + WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 }, + // Same ×100-scaled 0..100 percent as its two gait siblings above. The + // entry was simply missing; the map is the app's plausibility domain AND + // (via `value-domain.ts`) the answer to "can this metric be negative", + // so an omission reads as "unknown" on both questions. + WALKING_STEADINESS: { min: 0, max: 100 }, + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Step length (metres) — adult walking sits around 0.5–0.8 m; the + // 0.1 floor captures shuffling gait and the 2.0 ceiling covers + // unusually tall sprinters without catching obvious sensor noise. + WALKING_STEP_LENGTH: { min: 0.1, max: 2.0 }, + // Walking speed (m/s) — healthy adult casual gait ≈ 1.2–1.4 m/s; + // brisk walking tops out near 2.2 m/s before transitioning to a + // run. The 0.1 floor captures a very slow shuffle; 3.0 covers + // race-walking record territory. + WALKING_SPEED: { min: 0.1, max: 3.0 }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery (bpm) — the one-minute HR drop after peak exertion. + // Trained athletes can drop 50+ bpm; an unfit or autonomically + // impaired recovery sits in the single digits. 0 is a flat (poor) + // recovery; 100 is a generous ceiling beyond any plausible reading. + CARDIO_RECOVERY: { min: 0, max: 100 }, + // Wrist temperature (°C) — overnight skin-side reading. Runs cooler + // than core; the 30–42 band covers the plausible nighttime range and + // rejects obvious sensor glitches. Same shape as SKIN_TEMPERATURE. + WRIST_TEMPERATURE: { min: 30, max: 42 }, + // Fall count — a daily tally. A handful in a bad day is plausible; + // 50 is a generous ceiling that still catches a runaway sensor. + FALL_COUNT: { min: 0, max: 50 }, + // Six-minute-walk distance (metres). Healthy adults walk ~400–700 m; + // the 50 m floor captures severe impairment and the 1000 m ceiling + // covers an unusually fit reading without admitting sensor noise. + SIX_MINUTE_WALK_DISTANCE: { min: 50, max: 1000 }, + // Stair gait speed (m/s) — slower than level walking. Adult stair + // ascent sits around 0.4–0.7 m/s, descent slightly faster. The 0.05 + // floor captures a very slow climb; 2.0 is well above any sustained + // stair pace and rejects a free-fall artefact. + STAIR_ASCENT_SPEED: { min: 0.05, max: 2.0 }, + STAIR_DESCENT_SPEED: { min: 0.05, max: 2.0 }, + // Breathing-disturbance index (count). A per-night index; 0 is an + // undisturbed night and the 1000 ceiling is generous headroom over + // any plausible severe-apnea night. + BREATHING_DISTURBANCES: { min: 0, max: 1000 }, + // ── v1.10.0 — categorical events (WX-B) ── + // EVENT rows carry a fixed `value = 1` (one fired event). The range pins + // the value so a malformed ingest (value 0, or a stray sensor number) + // is rejected rather than stored as a phantom event. + IRREGULAR_RHYTHM_NOTIFICATION: { min: 1, max: 1 }, + HIGH_HEART_RATE_EVENT: { min: 1, max: 1 }, + LOW_HEART_RATE_EVENT: { min: 1, max: 1 }, + WALKING_STEADINESS_EVENT: { min: 1, max: 1 }, + BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 }, + // Loud-listening event flag (env + headphone). One fired event per row, so + // the floor is 1 — a non-event is not stored. The entry was missing from + // the map entirely, which let ANY number through ingest, including a + // negative one. The ceiling stays generous rather than pinned at 1 like the + // five events above: this type is written only by the iOS client and + // nothing in this repo asserts the exact value, so the floor is the part + // that is certain. + AUDIO_EXPOSURE_EVENT: { min: 1, max: 1000 }, + // ── v1.10.0 — computed scores (WX-C) ── + // Server-derived 0–100 scores. The plausibility band pins them to the + // score range so a malformed store (a stray negative or > 100) is rejected. + RECOVERY_SCORE: { min: 0, max: 100 }, + STRESS_SCORE: { min: 0, max: 100 }, + STRAIN_SCORE: { min: 0, max: 100 }, + // ── v1.11.0 — WHOOP-native score classes ── + // RMSSD HRV (ms). Lows reach single digits in stressed samples. RMSSD runs + // higher than SDNN in relaxed, well-trained sleepers, and nightly readings + // above 200 ms are real (#1110), so the ceiling sits at 300 ms. + HRV_RMSSD: { min: 1, max: 300 }, + // Day / workout strain on WHOOP's bounded 0–21 scale. + DAY_STRAIN: { min: 0, max: 21 }, + WORKOUT_STRAIN: { min: 0, max: 21 }, + // Sleep quality percentages (0–100). + SLEEP_PERFORMANCE: { min: 0, max: 100 }, + SLEEP_EFFICIENCY: { min: 0, max: 100 }, + SLEEP_CONSISTENCY: { min: 0, max: 100 }, + // Recommended sleep duration in minutes — 0..1440 covers the 24-hour day. + SLEEP_NEED: { min: 0, max: 1440 }, + // Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous + // ceiling over any plausible ultra-endurance day. + ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 }, + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // Daily average / max heart rate bpm — same plausibility band as the + // spot PULSE: endurance athletes touch the 30s at rest, severe + // tachycardia caps below 300. + AVERAGE_HEART_RATE: { min: 20, max: 300 }, + MAX_HEART_RATE: { min: 20, max: 300 }, + // Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a + // generous ceiling over any plausible severely-fragmented night. + SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 }, + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + // ANS charge is a baseline-relative autonomic deviation that can run + // negative; a generous symmetric ±100 band covers any plausible reading. + // Cardio Load is a non-negative cumulative-strain figure; 1000 is a generous + // ceiling over any plausible single-day training load. + ANS_CHARGE: { min: -100, max: 100 }, + CARDIO_LOAD: { min: 0, max: 1000 }, + // ── v1.17.1 — Oura coverage completion ── + // Sleep Score (0–100), same band as the other score classes. + SLEEP_SCORE: { min: 0, max: 100 }, + // Body-temperature deviation (°C) — a signed nightly offset centred on 0. + // Oura clamps its own display near ±1 °C; ±5 °C is a generous band that + // rejects an obvious sensor glitch while keeping every real illness / + // luteal-phase swing. + BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 }, + // ── v1.19.0 — Oura resilience ── + // Ordinal level scale: 1 (limited) … 5 (exceptional). The closed encoding + // never falls outside this band. + RESILIENCE: { min: 1, max: 5 }, + // ── v1.25 — clinical-signals wave ── + // PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener. + PHQ9_SCORE: { min: 0, max: 27 }, + GAD7_SCORE: { min: 0, max: 21 }, + // ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4), + // SCI total 0–32 — both server-derived, higher = better. + WHO5_SCORE: { min: 0, max: 100 }, + SCI_SCORE: { min: 0, max: 32 }, + // ── v1.44 — the minute rings and mindful minutes are bounded by the day; + // a row is one sample or one day's total, never more than 1440 minutes. + EXERCISE_MINUTES: { min: 0, max: 1440 }, + STAND_MINUTES: { min: 0, max: 1440 }, + MINDFUL_MINUTES: { min: 0, max: 1440 }, + // Stood hours: 1 per hour sample, at most 24 on a day's total. + STAND_HOURS: { min: 0, max: 24 }, + // State of Mind valence: HealthKit's closed −1..1 scale. + STATE_OF_MIND_VALENCE: { min: -1, max: 1 }, + // AFib burden as a percent of the week, after the ×100 scaling. A client + // that already scaled sends 2 for 2 %, which lands at 200 and is refused. + AFIB_BURDEN: { min: 0, max: 100 }, + // ── v1.44 — room climate. CO2: outdoor air sits near 420 ppm and a closed + // bedroom can pass 3000; consumer sensors top out at 5000–10000 ppm. + INDOOR_CO2: { min: 0, max: 10000 }, + // Indoor air °C: a cold cellar to an overheated attic. + INDOOR_TEMPERATURE: { min: -20, max: 60 }, + // Relative humidity, percent. + INDOOR_HUMIDITY: { min: 0, max: 100 }, + // Grip strength kg — a child can read in the single digits, an elite athlete + // tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise. + GRIP_STRENGTH: { min: 0, max: 120 }, + // Pain NRS — the validated 0–10 scale; the closed band pins the integer. + PAIN_NRS: { min: 0, max: 10 }, + // Waist circumference cm — paediatric lows ~30, severe obesity ~250. + WAIST_CIRCUMFERENCE: { min: 30, max: 250 }, + // Waist-to-height ratio — dimensionless; healthy ~0.4–0.5, the band is + // generous either side to admit any plausible body habitus. + WAIST_TO_HEIGHT: { min: 0.2, max: 1.5 }, +}; + +export function validateMeasurementRange( + type: string, + value: number, +): string | null { + const range = VALUE_RANGES[type]; + if (range && (value < range.min || value > range.max)) { + return `Value must be between ${range.min} and ${range.max}`; + } + return null; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index fa6a5692b..93f9322de 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -243,256 +243,14 @@ export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [ // display transform can read it without pulling Zod into every chart route. export { getUnitForType } from "@/lib/measurements/unit-map"; -// Plausible ranges per measurement type -export const VALUE_RANGES: Record = { - WEIGHT: { min: 1, max: 500 }, - BLOOD_PRESSURE_SYS: { min: 40, max: 300 }, - BLOOD_PRESSURE_DIA: { min: 20, max: 200 }, - PULSE: { min: 20, max: 300 }, - BODY_FAT: { min: 1, max: 80 }, - // Minutes (v1.4.23 unit change). Per-stage rows can run from a few - // seconds (a brief awakening) to 600+ minutes (a long IN_BED block), - // so the upper bound covers a 24-hour day. - SLEEP_DURATION: { min: 0, max: 1440 }, - ACTIVITY_STEPS: { min: 0, max: 200000 }, - BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia - TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg - BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) - // Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the - // healthy resting range at 95–100%. We accept down to 50% so a faulty-sensor - // critically-low reading still gets logged for the doctor to see; below 50% - // is incompatible with sustained life and almost certainly a sensor glitch. - OXYGEN_SATURATION: { min: 50, max: 100 }, - // ── v1.4.23 Apple Health ranges ── - // HRV SDNN ms — Apple's own "high HRV" threshold sits ~80 ms; lows can - // reach 5–10 ms in stressed/sick samples. 200 ms is a generous upper - // bound for unusually relaxed athletic windows. - HEART_RATE_VARIABILITY: { min: 1, max: 200 }, - // Resting HR bpm — endurance athletes can reach the high 30s; severe - // tachycardia caps below 220. - RESTING_HEART_RATE: { min: 25, max: 220 }, - // Active energy kcal/sample — a daily-rollup sample tops out around - // 6–8000 kcal even for ultra-endurance days. - ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 }, - // Flights — vertical-feet/3 ≈ flights; cap generous. - FLIGHTS_CLIMBED: { min: 0, max: 1000 }, - // Walking/running distance per sample (metres). 200 km covers an - // ultra-marathon or a multi-stage day. - WALKING_RUNNING_DISTANCE: { min: 0, max: 200000 }, - // VO2 max mL/(kg·min) — elite endurance hovers ~85; sedentary floor ~10. - VO2_MAX: { min: 5, max: 100 }, - // Body temperature °C — survivable lows ~28; severe hyperthermia ~45. - BODY_TEMPERATURE: { min: 28, max: 45 }, - // ── v1.4.25 W5d Withings full coverage ── - // Fat-free mass kg — adult plausibility, ~30 kg (small adult) to 120 kg - // (large lean athlete). Same bounds as weight minus a fat-mass floor. - FAT_FREE_MASS: { min: 10, max: 250 }, - // Fat mass kg — pairs with FAT_FREE_MASS so totals reconcile to weight. - FAT_MASS: { min: 0, max: 250 }, - // Muscle mass kg — sub-component of fat-free mass; widest sensible bound. - MUSCLE_MASS: { min: 5, max: 200 }, - // Skin temperature °C — surface temps run cooler than core. ScanWatch's - // dermal sensor reports a relative offset that lands in the 25–40 °C - // band; treat anything outside as a sensor glitch. - SKIN_TEMPERATURE: { min: 20, max: 45 }, - // Pulse-wave velocity m/s — clinical ranges sit ~4–15 m/s. Higher - // numbers indicate stiffer arteries (cardiovascular risk). - PULSE_WAVE_VELOCITY: { min: 1, max: 30 }, - // Vascular age in years — Withings derives this from PWV + chronological - // age; the value is a "biological age" not a chronological one. Hard cap - // at 130 because that's the human longevity record. - VASCULAR_AGE: { min: 10, max: 130 }, - // Visceral fat rating 1–12 (Withings' own scale; not a percent). - VISCERAL_FAT: { min: 0, max: 30 }, - // ── v1.4.25 W8d Apple Health server-prep ── - // Audio exposure dBA — Apple's "loud audio" warning sits at 80 dBA; - // concerts run 100–115 dBA; 140 dBA is the pain threshold. The floor - // of 30 dBA covers a quiet bedroom; anything below is silence and - // almost certainly a sensor artefact. - AUDIO_EXPOSURE_ENV: { min: 30, max: 140 }, - // Headphone audio caps slightly below the open-air upper edge because - // sealed-driver listening physically cannot reach a concert PA's SPL. - AUDIO_EXPOSURE_HEADPHONE: { min: 30, max: 140 }, - // Time in daylight (minutes/day) — full 24-hour window. Outdoor work - // tops the range; sedentary indoor days sit near 0. - TIME_IN_DAYLIGHT: { min: 0, max: 1440 }, - // ── v1.5.5 iOS-coord additions ── - // Respiratory rate breaths/min — adult range ~12-20 at rest; severe - // distress can spike past 40; bradypnea floor ~4. Generous bounds. - RESPIRATORY_RATE: { min: 3, max: 60 }, - // BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity. - // Outside the band is almost certainly a height-or-weight typo. - BODY_MASS_INDEX: { min: 8, max: 70 }, - // Lean body mass kg — adult plausibility, ~25 kg (small adult) to - // 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS. - LEAN_BODY_MASS: { min: 10, max: 250 }, - // Walking heart rate average bpm — endurance athletes can sit in - // the high 70s while walking; ailing or post-stimulant readings can - // touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE. - WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 }, - // Gait percent (0-100). Apple ships these as 0..1 fractions; after - // the ×100 server-side scaling the canonical band is 0..100. - WALKING_ASYMMETRY: { min: 0, max: 100 }, - WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 }, - // Same ×100-scaled 0..100 percent as its two gait siblings above. The - // entry was simply missing; the map is the app's plausibility domain AND - // (via `value-domain.ts`) the answer to "can this metric be negative", - // so an omission reads as "unknown" on both questions. - WALKING_STEADINESS: { min: 0, max: 100 }, - // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── - // Step length (metres) — adult walking sits around 0.5–0.8 m; the - // 0.1 floor captures shuffling gait and the 2.0 ceiling covers - // unusually tall sprinters without catching obvious sensor noise. - WALKING_STEP_LENGTH: { min: 0.1, max: 2.0 }, - // Walking speed (m/s) — healthy adult casual gait ≈ 1.2–1.4 m/s; - // brisk walking tops out near 2.2 m/s before transitioning to a - // run. The 0.1 floor captures a very slow shuffle; 3.0 covers - // race-walking record territory. - WALKING_SPEED: { min: 0.1, max: 3.0 }, - // ── v1.10.0 — additive HealthKit signals (WX-A) ── - // Cardio recovery (bpm) — the one-minute HR drop after peak exertion. - // Trained athletes can drop 50+ bpm; an unfit or autonomically - // impaired recovery sits in the single digits. 0 is a flat (poor) - // recovery; 100 is a generous ceiling beyond any plausible reading. - CARDIO_RECOVERY: { min: 0, max: 100 }, - // Wrist temperature (°C) — overnight skin-side reading. Runs cooler - // than core; the 30–42 band covers the plausible nighttime range and - // rejects obvious sensor glitches. Same shape as SKIN_TEMPERATURE. - WRIST_TEMPERATURE: { min: 30, max: 42 }, - // Fall count — a daily tally. A handful in a bad day is plausible; - // 50 is a generous ceiling that still catches a runaway sensor. - FALL_COUNT: { min: 0, max: 50 }, - // Six-minute-walk distance (metres). Healthy adults walk ~400–700 m; - // the 50 m floor captures severe impairment and the 1000 m ceiling - // covers an unusually fit reading without admitting sensor noise. - SIX_MINUTE_WALK_DISTANCE: { min: 50, max: 1000 }, - // Stair gait speed (m/s) — slower than level walking. Adult stair - // ascent sits around 0.4–0.7 m/s, descent slightly faster. The 0.05 - // floor captures a very slow climb; 2.0 is well above any sustained - // stair pace and rejects a free-fall artefact. - STAIR_ASCENT_SPEED: { min: 0.05, max: 2.0 }, - STAIR_DESCENT_SPEED: { min: 0.05, max: 2.0 }, - // Breathing-disturbance index (count). A per-night index; 0 is an - // undisturbed night and the 1000 ceiling is generous headroom over - // any plausible severe-apnea night. - BREATHING_DISTURBANCES: { min: 0, max: 1000 }, - // ── v1.10.0 — categorical events (WX-B) ── - // EVENT rows carry a fixed `value = 1` (one fired event). The range pins - // the value so a malformed ingest (value 0, or a stray sensor number) - // is rejected rather than stored as a phantom event. - IRREGULAR_RHYTHM_NOTIFICATION: { min: 1, max: 1 }, - HIGH_HEART_RATE_EVENT: { min: 1, max: 1 }, - LOW_HEART_RATE_EVENT: { min: 1, max: 1 }, - WALKING_STEADINESS_EVENT: { min: 1, max: 1 }, - BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 }, - // Loud-listening event flag (env + headphone). One fired event per row, so - // the floor is 1 — a non-event is not stored. The entry was missing from - // the map entirely, which let ANY number through ingest, including a - // negative one. The ceiling stays generous rather than pinned at 1 like the - // five events above: this type is written only by the iOS client and - // nothing in this repo asserts the exact value, so the floor is the part - // that is certain. - AUDIO_EXPOSURE_EVENT: { min: 1, max: 1000 }, - // ── v1.10.0 — computed scores (WX-C) ── - // Server-derived 0–100 scores. The plausibility band pins them to the - // score range so a malformed store (a stray negative or > 100) is rejected. - RECOVERY_SCORE: { min: 0, max: 100 }, - STRESS_SCORE: { min: 0, max: 100 }, - STRAIN_SCORE: { min: 0, max: 100 }, - // ── v1.11.0 — WHOOP-native score classes ── - // RMSSD HRV (ms). Lows reach single digits in stressed samples. RMSSD runs - // higher than SDNN in relaxed, well-trained sleepers, and nightly readings - // above 200 ms are real (#1110), so the ceiling sits at 300 ms. - HRV_RMSSD: { min: 1, max: 300 }, - // Day / workout strain on WHOOP's bounded 0–21 scale. - DAY_STRAIN: { min: 0, max: 21 }, - WORKOUT_STRAIN: { min: 0, max: 21 }, - // Sleep quality percentages (0–100). - SLEEP_PERFORMANCE: { min: 0, max: 100 }, - SLEEP_EFFICIENCY: { min: 0, max: 100 }, - SLEEP_CONSISTENCY: { min: 0, max: 100 }, - // Recommended sleep duration in minutes — 0..1440 covers the 24-hour day. - SLEEP_NEED: { min: 0, max: 1440 }, - // Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous - // ceiling over any plausible ultra-endurance day. - ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 }, - // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── - // Daily average / max heart rate bpm — same plausibility band as the - // spot PULSE: endurance athletes touch the 30s at rest, severe - // tachycardia caps below 300. - AVERAGE_HEART_RATE: { min: 20, max: 300 }, - MAX_HEART_RATE: { min: 20, max: 300 }, - // Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a - // generous ceiling over any plausible severely-fragmented night. - SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 }, - // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── - // ANS charge is a baseline-relative autonomic deviation that can run - // negative; a generous symmetric ±100 band covers any plausible reading. - // Cardio Load is a non-negative cumulative-strain figure; 1000 is a generous - // ceiling over any plausible single-day training load. - ANS_CHARGE: { min: -100, max: 100 }, - CARDIO_LOAD: { min: 0, max: 1000 }, - // ── v1.17.1 — Oura coverage completion ── - // Sleep Score (0–100), same band as the other score classes. - SLEEP_SCORE: { min: 0, max: 100 }, - // Body-temperature deviation (°C) — a signed nightly offset centred on 0. - // Oura clamps its own display near ±1 °C; ±5 °C is a generous band that - // rejects an obvious sensor glitch while keeping every real illness / - // luteal-phase swing. - BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 }, - // ── v1.19.0 — Oura resilience ── - // Ordinal level scale: 1 (limited) … 5 (exceptional). The closed encoding - // never falls outside this band. - RESILIENCE: { min: 1, max: 5 }, - // ── v1.25 — clinical-signals wave ── - // PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener. - PHQ9_SCORE: { min: 0, max: 27 }, - GAD7_SCORE: { min: 0, max: 21 }, - // ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4), - // SCI total 0–32 — both server-derived, higher = better. - WHO5_SCORE: { min: 0, max: 100 }, - SCI_SCORE: { min: 0, max: 32 }, - // ── v1.44 — the minute rings and mindful minutes are bounded by the day; - // a row is one sample or one day's total, never more than 1440 minutes. - EXERCISE_MINUTES: { min: 0, max: 1440 }, - STAND_MINUTES: { min: 0, max: 1440 }, - MINDFUL_MINUTES: { min: 0, max: 1440 }, - // Stood hours: 1 per hour sample, at most 24 on a day's total. - STAND_HOURS: { min: 0, max: 24 }, - // State of Mind valence: HealthKit's closed −1..1 scale. - STATE_OF_MIND_VALENCE: { min: -1, max: 1 }, - // AFib burden as a percent of the week, after the ×100 scaling. A client - // that already scaled sends 2 for 2 %, which lands at 200 and is refused. - AFIB_BURDEN: { min: 0, max: 100 }, - // ── v1.44 — room climate. CO2: outdoor air sits near 420 ppm and a closed - // bedroom can pass 3000; consumer sensors top out at 5000–10000 ppm. - INDOOR_CO2: { min: 0, max: 10000 }, - // Indoor air °C: a cold cellar to an overheated attic. - INDOOR_TEMPERATURE: { min: -20, max: 60 }, - // Relative humidity, percent. - INDOOR_HUMIDITY: { min: 0, max: 100 }, - // Grip strength kg — a child can read in the single digits, an elite athlete - // tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise. - GRIP_STRENGTH: { min: 0, max: 120 }, - // Pain NRS — the validated 0–10 scale; the closed band pins the integer. - PAIN_NRS: { min: 0, max: 10 }, - // Waist circumference cm — paediatric lows ~30, severe obesity ~250. - WAIST_CIRCUMFERENCE: { min: 30, max: 250 }, - // Waist-to-height ratio — dimensionless; healthy ~0.4–0.5, the band is - // generous either side to admit any plausible body habitus. - WAIST_TO_HEIGHT: { min: 0.2, max: 1.5 }, -}; - -export function validateMeasurementRange( - type: string, - value: number, -): string | null { - const range = VALUE_RANGES[type]; - if (range && (value < range.min || value > range.max)) { - return `Value must be between ${range.min} and ${range.max}`; - } - return null; -} +// The plausibility band per type, and its check, live in a Zod-free module so +// the client's value-domain reads (the chart floor, the integer set) do not +// pull Zod into every metric page. +import { validateMeasurementRange } from "@/lib/measurements/value-ranges"; +export { + VALUE_RANGES, + validateMeasurementRange, +} from "@/lib/measurements/value-ranges"; export const createMeasurementSchema = z .object({ From 04c9139ca645d8a46cec15e74b790337f6a7de9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:36:06 +0200 Subject: [PATCH 91/98] perf(settings): load only the link card on the arrangement pages The arrangement page imported its link to the module's own page from the module pages file, which also holds the labs, mood and injection-site settings, so every arrangement page shipped all three. The card lives in its own module. --- src/app/settings/layout/[module]/page.tsx | 2 +- .../settings/module-settings-link-card.tsx | 43 +++++++++++++++++++ .../settings/module-settings-pages.tsx | 29 +------------ 3 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 src/components/settings/module-settings-link-card.tsx diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx index a220b1265..1eabf8bdc 100644 --- a/src/app/settings/layout/[module]/page.tsx +++ b/src/app/settings/layout/[module]/page.tsx @@ -9,7 +9,7 @@ import { isLayoutGroupId, } from "@/components/settings/layout-groups"; import { SettingsShell } from "@/components/settings/settings-shell"; -import { ModuleSettingsLinkCard } from "@/components/settings/module-settings-pages"; +import { ModuleSettingsLinkCard } from "@/components/settings/module-settings-link-card"; import { isModuleSettingsPageId } from "@/components/settings/module-settings-page-ids"; import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; diff --git a/src/components/settings/module-settings-link-card.tsx b/src/components/settings/module-settings-link-card.tsx new file mode 100644 index 000000000..380c1c275 --- /dev/null +++ b/src/components/settings/module-settings-link-card.tsx @@ -0,0 +1,43 @@ +"use client"; + +/** + * The way from a module's arrangement page (`/settings/layout/`) to its + * own page under Modules, for the modules that have one. + * + * Kept apart from `module-settings-pages.tsx` so the arrangement route ships + * the card it renders, not the labs, mood and injection-site settings that + * module holds. + */ + +import Link from "next/link"; +import { ChevronRight, SlidersHorizontal } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleSettingsPageId } from "./module-settings-page-ids"; + +export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) { + const { t } = useTranslations(); + return ( + + + + + + + ); +} diff --git a/src/components/settings/module-settings-pages.tsx b/src/components/settings/module-settings-pages.tsx index 4447a344c..bd06c6f9c 100644 --- a/src/components/settings/module-settings-pages.tsx +++ b/src/components/settings/module-settings-pages.tsx @@ -15,7 +15,7 @@ */ import Link from "next/link"; -import { ArrowUpDown, ChevronRight, SlidersHorizontal } from "lucide-react"; +import { ArrowUpDown, ChevronRight } from "lucide-react"; import { LabsModuleSettings } from "@/components/labs/labs-settings"; import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; @@ -88,30 +88,3 @@ export function ModuleSettingsPageBody({ id }: { id: ModuleSettingsPageId }) { const Body = MODULE_SETTINGS_PAGE_BODIES[id]; return ; } - -/** - * The way from a module's arrangement page to its own page under Modules, - * for the modules that have one. - */ -export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) { - const { t } = useTranslations(); - return ( - - - - - - - ); -} From 173646c613a7fca35619ad280b5b5f94b7e7841e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:37:14 +0200 Subject: [PATCH 92/98] chore(bundle): raise the aggregate budget to 3900 on the measured v1.44 build The release branch measured 3875 KB gz without the lazy catalogs against the 3755 cap (main 3737). About 15 KB is new client code; the rest is the aggregate's per-chunk-group double count of the shared confirmation dialog and of the two new routes' own chunks. The summed first load of the 108 pages fell from 39998 to 35895 KB gz with the metric pages off Zod; the watched routes keep their baselines. --- bundle-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle-budget.json b/bundle-budget.json index bc3ed7437..2fdc452d6 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,5 +1,5 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged. Split at v1.43.7 for the eighth interface language (Dutch). The aggregate counted every lazy message catalog, although a reader downloads only the one for their language, so each new language read as a regression of about 190 KB and the cap had to be raised for copy nobody else loads. The aggregate now covers the client chunks without the lazy catalogs (3670 KB gz measured at v1.43.3, cap 3700, the same headroom as before), and each catalog is held to its own ceiling (about 187 KB gz each at v1.43.3, cap 210). The static-catalog guard, the route baselines and the one-Recharts-chunk guard are unchanged. Raised to 3755 at v1.43.8 on a measured delta, and the /insights baseline re-based. The release branch measured 3729 KB gz without the lazy catalogs against the 3700 cap (v1.43.7: 3665). Bundling the client entry graph of v1.43.7 and of the release branch once each, deduplicated, measured 3462.6 and 3462.8 KB gz: no dependency, schema or catalog entered the client. The rise is the double count described at v1.37.27 and v1.40.0. The release replaces hand-written controls with shared primitives (the segmented toggle and its roving focus hook, the inline action, the score pill, the page header with its back link, the display-unit resolver), each well under a kilobyte, and the aggregate counts a copy in every chunk group that uses them, up to 88 of them. The /insights baseline still stated the 304 measured when Zod left the page at v1.42.0, although the page measured 320 at v1.43.0 and 321 at v1.43.3, so it had been running on a 4 KB allowance for three releases; it moves to the 323 measured on the runner now, protected by the same 20 KB allowance. The other four watched routes moved by 1.8 to 2.5 KB and keep their baselines. The aggregate cap moves from 3700 to 3755, about 26 KB above the build, the same headroom as before.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged. Split at v1.43.7 for the eighth interface language (Dutch). The aggregate counted every lazy message catalog, although a reader downloads only the one for their language, so each new language read as a regression of about 190 KB and the cap had to be raised for copy nobody else loads. The aggregate now covers the client chunks without the lazy catalogs (3670 KB gz measured at v1.43.3, cap 3700, the same headroom as before), and each catalog is held to its own ceiling (about 187 KB gz each at v1.43.3, cap 210). The static-catalog guard, the route baselines and the one-Recharts-chunk guard are unchanged. Raised to 3755 at v1.43.8 on a measured delta, and the /insights baseline re-based. The release branch measured 3729 KB gz without the lazy catalogs against the 3700 cap (v1.43.7: 3665). Bundling the client entry graph of v1.43.7 and of the release branch once each, deduplicated, measured 3462.6 and 3462.8 KB gz: no dependency, schema or catalog entered the client. The rise is the double count described at v1.37.27 and v1.40.0. The release replaces hand-written controls with shared primitives (the segmented toggle and its roving focus hook, the inline action, the score pill, the page header with its back link, the display-unit resolver), each well under a kilobyte, and the aggregate counts a copy in every chunk group that uses them, up to 88 of them. The /insights baseline still stated the 304 measured when Zod left the page at v1.42.0, although the page measured 320 at v1.43.0 and 321 at v1.43.3, so it had been running on a 4 KB allowance for three releases; it moves to the 323 measured on the runner now, protected by the same 20 KB allowance. The other four watched routes moved by 1.8 to 2.5 KB and keep their baselines. The aggregate cap moves from 3700 to 3755, about 26 KB above the build, the same headroom as before. Raised to 3900 at v1.44.0 on a measured delta, with two first-load reductions in the same release. Main measured 3737 KB gz without the lazy catalogs and the release branch 3863, the same figure the CI runner reported. Bundling every client module of each build once, deduplicated, measured 1494 and 1509 KB gz: the release adds about 15 KB of new client code (the CSV import card, the activity calendar and its goals sheet, the source provenance view, the efficacy summary, the training load and lab trend cards, the settings sheet on phones) and no dependency. The rest is the double count described at v1.37.27 and v1.40.0, attributed module by module from source maps: the shared destructive-control confirmation now used by fifteen more settings sections sits in 26 chunks instead of 11, and the two new routes (/settings/modules/[module] and /insights/sources) each carry their own copy of the record-settings gate's on-demand chunks, the date field and its popover, the settings shell and the type label map. Two reductions land beside the raise. The value domain read the plausibility band out of the measurement validation module, which put Zod into the first load of every metric sub-page; the band lives in a Zod-free module now and about fifty metric routes fell by 88.5 KB gz each (/insights/pulse 428 to 339). The arrangement pages imported their module link card from the file that holds the labs, mood and injection-site settings and shipped all three; the card has its own module (/settings/layout/[module] 391 to 380). Across the 108 pages the summed first load fell from 39998 to 35895 KB gz; 48 pages fell by more than 1 KB, 13 grew by more than 1 KB and none by more than 9 (/insights/workouts with the training load card, /enroll-mfa with the sessions and trusted devices that moved into the security section). The aggregate moved the other way, 3863 to 3875: the band is a small module now copied into each chunk group that reads it, where it used to ride in the one shared schema chunk. The five watched routes stay inside their allowances (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311 KB gz). The aggregate cap moves from 3755 to 3900, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged.", "routeBaselineKbGz": { "/page": 355, "/insights/page": 323, @@ -8,7 +8,7 @@ "/settings/[section]/page": 307 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3755, + "totalClientKbGz": 3900, "maxRechartsChunks": 1, "maxCatalogKbGz": 210 } From 695bb1b10a3f03b3059b4e30ca7fa46664a0385d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 01:44:00 +0200 Subject: [PATCH 93/98] docs(changelog): note the lighter metric pages --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d339d5e..7e0f73c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ - **Imports from Apple Health insert large batches faster**, and the health check answers "starting" while the background jobs come up, so a slow boot no longer marks the container unhealthy. +- **The metric pages load about 90 KB less JavaScript** on first visit. ### Fixed From c26f21fb4f5185a74cf17d048ce14ef092e7311a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 02:31:55 +0200 Subject: [PATCH 94/98] test(e2e): import the insights cache cipher statically in the AI-optional fixture A dynamic import() of app code skips the runner's @/ path mapping for that module's own imports, so sealing the seeded briefing failed with "Cannot find module '@/lib/crypto'". --- e2e/setup/ai-optional-fixture.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/setup/ai-optional-fixture.ts b/e2e/setup/ai-optional-fixture.ts index 3b930fc38..8458667ea 100644 --- a/e2e/setup/ai-optional-fixture.ts +++ b/e2e/setup/ai-optional-fixture.ts @@ -18,6 +18,8 @@ */ import pg from "pg"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; + import { E2E_AI_OPTIONAL } from "./global-setup"; function connect(): pg.Pool { @@ -81,8 +83,8 @@ export async function seedStoredBriefing(marker: string): Promise { briefingGeneratedAt: new Date().toISOString(), }; // Sealed the way the app writes it; there is no readable column to seed. - const { sealInsightsCachedText } = - await import("@/lib/insights/cached-text-cipher"); + // The import is static on purpose: a dynamic `import()` of app code bypasses + // the test runner's `@/` path mapping for that module's own imports. const sealed = Buffer.from(sealInsightsCachedText(JSON.stringify(insights))); await withAccount(async (pool, userId) => { await pool.query( From 25d7a01d9dbc2e4440f165830638fcc9f6c69fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 02:31:55 +0200 Subject: [PATCH 95/98] test(e2e): expect the two HRV rows in the standard report set The standard template carries SDNN and RMSSD since the wearable leaves landed, and the cardio group has fifteen leaves. Assert the untouched mobility group at zero instead. --- e2e/settings-export.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index 8fb99c4b2..4374d8594 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -149,8 +149,9 @@ test.describe("Settings → Export & Import", () => { await page.getByTestId("report-apply-standard-export").click(); await expect(generate).toBeEnabled(); - // Exactly the shipped set: the three classic vitals, weight and BMI, and - // nothing at all from the groups the template leaves alone. + // Exactly the shipped set: the three classic vitals, weight and BMI, the + // two heart-rate-variability rows, and nothing at all from the groups the + // template leaves alone. await expect( page.getByTestId("report-group-row-vitals-export"), ).toContainText("3/7"); @@ -159,7 +160,10 @@ test.describe("Settings → Export & Import", () => { ).toContainText("2/12"); await expect( page.getByTestId("report-group-row-cardio-export"), - ).toContainText("0/14"); + ).toContainText("2/15"); + await expect( + page.getByTestId("report-group-row-mobility-export"), + ).toContainText("0/11"); // The fenced tier is not part of any one-click set. await expect( page.getByTestId("report-scope-summary-export"), From 11cd7fbeb637758d21d3875b7584403fcf10cc21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 02:31:56 +0200 Subject: [PATCH 96/98] fix(integrations): name a managed profile without a display name by its username The connect cards read the display name alone, so a profile created without one rendered "Connect for" with nobody after it. Use the account label the switcher and the banner use. --- .../managed-integration-status.test.tsx | 27 ++++++++++++++----- .../settings/managed-integration-status.tsx | 10 +++---- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx index f782d0d55..d7126b5dd 100644 --- a/src/components/settings/__tests__/managed-integration-status.test.tsx +++ b/src/components/settings/__tests__/managed-integration-status.test.tsx @@ -7,17 +7,19 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; +const NAMED_RECORD = { + accountId: "record-1", + recordKind: "managed", + username: "managed-1", + displayName: "Managed record" as string | null, + fullName: null as string | null, +}; +let activeRecord = NAMED_RECORD; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { id: "guardian", - accountAccess: { - active: { - accountId: "record-1", - recordKind: "managed", - displayName: "Managed record", - }, - }, + accountAccess: { active: activeRecord }, }, isAuthenticated: true, isLoading: false, @@ -78,6 +80,7 @@ describe("", () => { beforeEach(() => { payload = undefined; loading = false; + activeRecord = NAMED_RECORD; }); it("renders one shared card per provider from health, with controls on the OAuth ones", () => { @@ -140,6 +143,16 @@ describe("", () => { ); }); + it("names a profile without a display name by its username", () => { + activeRecord = { ...NAMED_RECORD, displayName: null }; + payload = withHealth({}); + const html = render(); + expect(html.match(/Connects for managed-1Connect for managed-1 { loading = true; const html = render(); diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index 4ce08a958..6f84aa551 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -28,6 +28,7 @@ import { queryKeys, } from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response"; +import { accountLabel } from "@/lib/sharing/account-access-view"; type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES; @@ -90,7 +91,9 @@ export function ManagedIntegrationStatus() { enabled: activeRecord?.recordKind === "managed" && recordId !== null, }); - const recordName = activeRecord?.displayName ?? ""; + // Named the way the switcher and the banner name it: a profile created + // without a display name still has a name to connect for. + const recordName = activeRecord ? accountLabel(activeRecord) : ""; const invalidateStatus = () => queryClient.invalidateQueries({ @@ -248,10 +251,7 @@ export function ManagedIntegrationStatus() {