From 40200b55a79a8240afaf89ccaa57f2b1b235db06 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 16:54:14 +0100
Subject: [PATCH 01/55] fix(records): let the smoking history fields take
keystrokes
The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.
Refs #1334
---
CHANGELOG.md | 14 ++++
e2e/life-circumstances-typing.spec.ts | 46 ++++++++++
.../records/life-circumstances-manager.tsx | 84 +++++++++++++++----
3 files changed, 126 insertions(+), 18 deletions(-)
create mode 100644 e2e/life-circumstances-typing.spec.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83a4bc8c9..3dc9b4ba1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
## [Unreleased]
+### Fixed
+
+- **Typing into the smoking history.** "When you stopped" only took text
+ that was already a whole YYYY-MM month, so every keystroke was dropped
+ and only a paste got in. The start year clamped each keystroke into its
+ range, so the first digit turned into 1900. Both fields now keep what is
+ typed and commit it once it is a valid value, and leaving the month field
+ pads "2019-3" to "2019-03". (#1334)
+
+### Changed
+
+- Dependabot no longer proposes a new Node major for the image. The major
+ is tied to `engines`, `.nvmrc` and `.node-version` and moves by hand.
+
## [1.47.0] — 2026-11-04
### Added
diff --git a/e2e/life-circumstances-typing.spec.ts b/e2e/life-circumstances-typing.spec.ts
new file mode 100644
index 000000000..f0fc05069
--- /dev/null
+++ b/e2e/life-circumstances-typing.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from "./setup/test";
+
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+
+/**
+ * The tobacco section's typed fields take what a person types one key at a
+ * time. Through v1.47.0 neither did: "When you stopped" only accepted text
+ * that was already a whole YYYY-MM month, so every keystroke was thrown away
+ * and only a paste got in, and the start year clamped each keystroke into
+ * 1900..this year, so the first digit became 1900.
+ *
+ * The test types and leaves the fields without saving, so the shared
+ * account keeps whatever answers it had.
+ */
+test.describe("life circumstances typed fields", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test("start year and quit month take keystrokes", async ({ page }) => {
+ await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" });
+ const section = page.locator('[data-slot="life-section-tobacco"]');
+ await section.locator("summary").click();
+
+ await section.locator("#life-tobacco-status").selectOption("FORMER");
+
+ const start = section.locator("#life-tobacco-start");
+ await start.fill("");
+ await start.pressSequentially("1985");
+ await expect(start).toHaveValue("1985");
+ await start.blur();
+ await expect(start).toHaveValue("1985");
+
+ const quit = section.locator("#life-tobacco-quit");
+ await quit.fill("");
+ await quit.pressSequentially("2019-03");
+ await expect(quit).toHaveValue("2019-03");
+ await quit.blur();
+ await expect(quit).toHaveValue("2019-03");
+
+ // A single-digit month is padded when the field is left.
+ await quit.fill("");
+ await quit.pressSequentially("2018-7");
+ await expect(quit).toHaveValue("2018-7");
+ await quit.blur();
+ await expect(quit).toHaveValue("2018-07");
+ });
+});
diff --git a/src/components/records/life-circumstances-manager.tsx b/src/components/records/life-circumstances-manager.tsx
index 56db109be..e8a4ced02 100644
--- a/src/components/records/life-circumstances-manager.tsx
+++ b/src/components/records/life-circumstances-manager.tsx
@@ -281,6 +281,11 @@ function NumberField({
onChange: (value: number | null) => void;
disabled: boolean;
}) {
+ // What the person is typing. A year passes through "1", "19" and "198" on
+ // its way to "1985", so clamping each keystroke turned the first digit into
+ // the minimum and the field could not be typed into. A value is committed
+ // once it is in range, and leaving the field puts the committed one back.
+ const [draft, setDraft] = useState(null);
return (
{
const raw = event.target.value;
+ setDraft(raw);
if (raw === "") return onChange(null);
const parsed = Number(raw);
- if (Number.isFinite(parsed)) {
- onChange(Math.min(max, Math.max(min, parsed)));
+ if (Number.isFinite(parsed) && parsed >= min && parsed <= max) {
+ onChange(parsed);
}
}}
+ onBlur={() => setDraft(null)}
/>
);
}
+/**
+ * A month typed as YYYY-MM. Only a complete month is committed, so the field
+ * keeps what is being typed on its own: holding it to the committed value
+ * threw away every keystroke until the text was a whole month, and only a
+ * paste got through. Leaving the field pads "2019-3" to "2019-03" and puts
+ * the committed month back over anything that is not one.
+ */
+function QuitMonthInput({
+ id,
+ value,
+ onChange,
+ disabled,
+}: {
+ id: string;
+ value: string | null;
+ onChange: (value: string | null) => void;
+ disabled: boolean;
+}) {
+ const [draft, setDraft] = useState(null);
+ return (
+ {
+ const raw = event.target.value;
+ setDraft(raw);
+ const month = completeMonth(raw);
+ if (month !== undefined) onChange(month);
+ }}
+ onBlur={() => {
+ if (draft !== null) {
+ const short = /^(\d{4})-([1-9])$/.exec(draft.trim());
+ if (short) onChange(`${short[1]}-0${short[2]}`);
+ }
+ setDraft(null);
+ }}
+ />
+ );
+}
+
+/** A whole month, null for an emptied field, undefined while still typing. */
+function completeMonth(raw: string): string | null | undefined {
+ const text = raw.trim();
+ if (text === "") return null;
+ return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined;
+}
+
function MultiField({
id,
label,
@@ -544,23 +602,13 @@ function SectionBody({
id={id("quit")}
label={t("records.life.fields.quitMonth")}
>
- {
- const raw = event.target.value.trim();
- patch("TOBACCO_DETAIL", {
- quitMonth: /^\d{4}-(0[1-9]|1[0-2])$/.test(raw)
- ? raw
- : raw === ""
- ? null
- : v.quitMonth,
- });
- }}
+ onChange={(quitMonth) =>
+ patch("TOBACCO_DETAIL", { quitMonth })
+ }
/>
) : null}
From d98e16557b562457046cbb37e5e589be94aad9a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 16:54:29 +0100
Subject: [PATCH 02/55] chore(deps): keep Node majors out of the image PRs
Refs #1334
---
.github/dependabot.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c9406d15a..e3d612e8a 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -73,6 +73,12 @@ updates:
# (runtime-package-pins.test.ts fails while the two differ).
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
+ # The Node major in the build and runner stages is the one `engines`,
+ # `.nvmrc` and `.node-version` name, and the native modules are built
+ # against it. Moving it is a release decision, so it arrives by hand.
+ # Digest and minor updates of the pinned major still come as PRs.
+ - dependency-name: "node"
+ update-types: ["version-update:semver-major"]
# The `docker` ecosystem above reads the Dockerfile only. The bundled
# database lives in `docker-compose.yml`, which GitHub treats as its own
# ecosystem, so without this block a new PostgreSQL minor release never
From 41f90bac3f07434f3b99a77656c8162bc8a3e86d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 16:56:20 +0100
Subject: [PATCH 03/55] chore(deps): move @hono/node-server to 2.x and lift
systeminformation
The serveStatic double decode has no fix on the 1.x line, and the MCP
SDK accepts 2.x. The app uses the web-standard transport and never loads
it. systeminformation sits under the shadcn CLI at dev time.
---
CHANGELOG.md | 8 ++++++++
Dockerfile | 2 +-
pnpm-lock.yaml | 21 +++++++++++----------
pnpm-workspace.yaml | 17 ++++++++++-------
4 files changed, 30 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dc9b4ba1..b545980e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,14 @@
- Dependabot no longer proposes a new Node major for the image. The major
is tied to `engines`, `.nvmrc` and `.node-version` and moves by hand.
+### Security
+
+- `@hono/node-server` moves to the 2.x line, which fixes a second decode of
+ the request path in `serveStatic`. It arrives under the MCP SDK; the app
+ uses the SDK's web-standard transport and never loads it, but the copy
+ was in the tree. `systeminformation`, under the shadcn CLI at dev time,
+ moves past a Windows command injection.
+
## [1.47.0] — 2026-11-04
### Added
diff --git a/Dockerfile b/Dockerfile
index e5adabd18..90654a917 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -133,7 +133,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
- npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
+ npm pkg set 'overrides.@hono/node-server=^2.1.3' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 --no-fund --no-audit
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 48d7a9ff6..aace7cce0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,12 +12,13 @@ overrides:
dompurify@<3.4.17: ^3.4.17
postcss@<8.5.23: ^8.5.23
nanoid@<3.3.18: ^3.3.18
- '@hono/node-server@<1.19.15': ^1.19.15
+ '@hono/node-server@<2.1.3': ^2.1.3
picomatch@<4.0.4: ^4.0.4
body-parser@<2.4.0: ^2.4.0
source-map-js@<1.2.2: ^1.2.2
postcss-selector-parser@<7.1.6: ^7.1.6
smol-toml@<1.9.0: ^1.9.0
+ systeminformation@<5.33.16: ^5.33.16
qs@<6.16.0: ^6.16.0
fast-uri@<3.1.9: ^3.1.9
flatted@<3.4.2: ^3.4.2
@@ -870,9 +871,9 @@ packages:
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
- '@hono/node-server@1.19.17':
- resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
- engines: {node: '>=18.14.1'}
+ '@hono/node-server@2.1.6':
+ resolution: {integrity: sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==}
+ engines: {node: '>=20'}
peerDependencies:
hono: ^4.13.11
@@ -6679,8 +6680,8 @@ packages:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
- systeminformation@5.33.15:
- resolution: {integrity: sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw==}
+ systeminformation@5.33.19:
+ resolution: {integrity: sha512-Zm7wGeCG7PqfvzegMJIzrxICopWqYCeLATVmvxOOci1isLcNVKgY4Fr0hhYQzvc7cgtK3377jQVChTFAnznOiA==}
engines: {node: '>=10.0.0'}
os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
hasBin: true
@@ -7925,7 +7926,7 @@ snapshots:
object-treeify: 1.1.33
open: 8.4.2
picomatch: 4.0.7
- systeminformation: 5.33.15
+ systeminformation: 5.33.19
undici: 7.30.1
which: 4.0.0
yocto-spinner: 1.2.2
@@ -8079,7 +8080,7 @@ snapshots:
'@hexagon/base64@1.1.28': {}
- '@hono/node-server@1.19.17(hono@4.13.13)':
+ '@hono/node-server@2.1.6(hono@4.13.13)':
dependencies:
hono: 4.13.13
@@ -8374,7 +8375,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)':
dependencies:
- '@hono/node-server': 1.19.17(hono@4.13.13)
+ '@hono/node-server': 2.1.6(hono@4.13.13)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@@ -14105,7 +14106,7 @@ snapshots:
svg-pathdata@6.0.3:
optional: true
- systeminformation@5.33.15: {}
+ systeminformation@5.33.19: {}
tagged-tag@1.0.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index f716b3daf..0d63e5b6d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -22,13 +22,13 @@ overrides:
# bump; the fix landed in 8.5.23.
"postcss@<8.5.23": "^8.5.23"
"nanoid@<3.3.18": "^3.3.18"
- # GHSA-frvp-7c67-39w9 raised the floor from 1.19.13 to 1.19.15. Mirrored for
- # /opt/prisma-cli in the Dockerfile, which is where the flagged 1.19.11
- # actually landed: prisma pulls it through @prisma/dev, and that install
- # reads npm `overrides`, not this file. The bound stays on the 1.x line
- # because the advisory is fixed there too and 2.x is a major nothing asked
- # for.
- "@hono/node-server@<1.19.15": "^1.19.15"
+ # GHSA-frvp-7c67-39w9 raised the floor from 1.19.13 to 1.19.15. The
+ # serveStatic double decode fixed in 2.1.3 has no fix on the 1.x line, so
+ # the bound moves to 2.x, which the MCP SDK accepts (`^1.19.9 || ^2.0.5`).
+ # The app never calls serveStatic; the copy sits on the MCP path all the
+ # same. Mirrored for /opt/prisma-cli in the Dockerfile, where an older
+ # @prisma/dev once pulled it in.
+ "@hono/node-server@<2.1.3": "^2.1.3"
"picomatch@<4.0.4": "^4.0.4"
# CVE-2026-111181 (HIGH) raised the floor from 2.3.0. The copy arrives
# under express in the MCP SDK, on a production path.
@@ -40,6 +40,9 @@ overrides:
"source-map-js@<1.2.2": "^1.2.2"
"postcss-selector-parser@<7.1.6": "^7.1.6"
"smol-toml@<1.9.0": "^1.9.0"
+ # Command injection through services() on Windows, under shadcn's CLI at
+ # dev time only. 5.33.16 has the fix.
+ "systeminformation@<5.33.16": "^5.33.16"
# Array-limit bypass through bracket-key comma parsing, and a denial of
# service through an attacker-controlled isBuffer. 6.16.0 covers both.
"qs@<6.16.0": "^6.16.0"
From d3401b5b89d0f08f4404c03aed98054218232e6d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 17:59:54 +0100
Subject: [PATCH 04/55] refactor(records): move the life circumstances field
primitives into their own file
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
---
.../baselines/source-file-lines.json | 4 +-
.../baselines/source-function-lines.json | 12 +-
.../records/life-circumstances-fields.tsx | 316 +++++++++++++++++
.../records/life-circumstances-manager.tsx | 320 +-----------------
4 files changed, 334 insertions(+), 318 deletions(-)
create mode 100644 src/components/records/life-circumstances-fields.tsx
diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json
index ff9370204..6fdd36690 100644
--- a/src/__tests__/baselines/source-file-lines.json
+++ b/src/__tests__/baselines/source-file-lines.json
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
- "total": 94309,
+ "total": 94069,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -17,7 +17,7 @@
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
- "components/records/life-circumstances-manager.tsx": 1080,
+ "components/records/life-circumstances-manager.tsx": 828,
"components/timeline/timeline-chart.tsx": 1093,
"components/timeline/timeline-geometry.ts": 1288,
"lib/ai/anthropic-client.ts": 804,
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index f96e8db4a..b4776feb5 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -1,16 +1,16 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
- "total": 111894,
+ "total": 111909,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
- "app/api/auth/login/route.ts :: apiHandler(arg)": 262,
+ "app/api/auth/login/route.ts :: apiHandler(arg)": 260,
"app/api/auth/me/route.ts :: apiHandler(arg)": 378,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
- "app/api/auth/register/route.ts :: apiHandler(arg)": 268,
+ "app/api/auth/register/route.ts :: apiHandler(arg)": 266,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
@@ -187,7 +187,7 @@
"components/records/allergy-form.tsx :: AllergyForm": 225,
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
- "components/records/life-circumstances-manager.tsx :: SectionBody": 482,
+ "components/records/life-circumstances-manager.tsx :: SectionBody": 472,
"components/settings/about-section.tsx :: AboutSection": 236,
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
"components/settings/access/managed-profile-create-form.tsx :: ManagedProfileCreateForm": 235,
@@ -207,7 +207,7 @@
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
- "components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
+ "components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 227,
"components/settings/integrations/document-sources-card.tsx :: SourceForm": 306,
"components/settings/integrations/google-health-card.tsx :: GoogleHealthCard": 300,
"components/settings/integrations/integration-card.tsx :: IntegrationCard": 209,
@@ -300,7 +300,7 @@
"lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292,
"lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222,
"lib/jobs/cycle-reminder.ts :: runCycleReminderTick": 243,
- "lib/jobs/document-summary.ts :: runDocumentSummaryJob": 239,
+ "lib/jobs/document-summary.ts :: runDocumentSummaryJob": 238,
"lib/jobs/google-health-backfill.ts :: runGoogleHealthBackfillForUser": 276,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser": 282,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser > withFeatureCacheScope(arg)": 252,
diff --git a/src/components/records/life-circumstances-fields.tsx b/src/components/records/life-circumstances-fields.tsx
new file mode 100644
index 000000000..eb558914f
--- /dev/null
+++ b/src/components/records/life-circumstances-fields.tsx
@@ -0,0 +1,316 @@
+/**
+ * The small field primitives the life circumstances form is built from.
+ * Each one owns only its own input state; the form owns the answers.
+ */
+import { useState, type ReactNode } from "react";
+
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { useFormatters } from "@/lib/i18n/context";
+
+export type Translate = (
+ key: string,
+ params?: Record,
+) => string;
+
+export function Field({
+ id,
+ label,
+ children,
+}: {
+ id: string;
+ label: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {/* Block, so the colon follows a label that wraps instead of standing
+ alone at the far edge as a flex item. */}
+
+ {children}
+
+ );
+}
+
+export function ChoiceField({
+ id,
+ label,
+ value,
+ options,
+ onChange,
+ t,
+ disabled,
+}: {
+ id: string;
+ label: string;
+ value: string;
+ options: ReadonlyArray<{ value: string; label: string }>;
+ onChange: (value: string) => void;
+ t: Translate;
+ disabled: boolean;
+}) {
+ return (
+
+ onChange(event.target.value)}
+ >
+
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+export function BoolField({
+ id,
+ label,
+ value,
+ onChange,
+ t,
+ disabled,
+}: {
+ id: string;
+ label: string;
+ value: boolean | null;
+ onChange: (value: boolean | null) => void;
+ t: Translate;
+ disabled: boolean;
+}) {
+ return (
+ onChange(next === "" ? null : next === "yes")}
+ t={t}
+ disabled={disabled}
+ />
+ );
+}
+
+export function NumberField({
+ id,
+ label,
+ value,
+ min,
+ max,
+ step = 1,
+ onChange,
+ disabled,
+}: {
+ id: string;
+ label: string;
+ value: number | null;
+ min: number;
+ max: number;
+ step?: number;
+ onChange: (value: number | null) => void;
+ disabled: boolean;
+}) {
+ // What the person is typing. A year passes through "1", "19" and "198" on
+ // its way to "1985", so clamping each keystroke turned the first digit into
+ // the minimum and the field could not be typed into. A value is committed
+ // once it is in range, and leaving the field puts the committed one back.
+ const [draft, setDraft] = useState(null);
+ return (
+
+ {
+ const raw = event.target.value;
+ setDraft(raw);
+ if (raw === "") return onChange(null);
+ const parsed = Number(raw);
+ if (Number.isFinite(parsed) && parsed >= min && parsed <= max) {
+ onChange(parsed);
+ }
+ }}
+ onBlur={() => setDraft(null)}
+ />
+
+ );
+}
+
+/**
+ * A month typed as YYYY-MM. Only a complete month is committed, so the field
+ * keeps what is being typed on its own: holding it to the committed value
+ * threw away every keystroke until the text was a whole month, and only a
+ * paste got through. Leaving the field pads "2019-3" to "2019-03" and puts
+ * the committed month back over anything that is not one.
+ */
+export function QuitMonthInput({
+ id,
+ value,
+ onChange,
+ disabled,
+}: {
+ id: string;
+ value: string | null;
+ onChange: (value: string | null) => void;
+ disabled: boolean;
+}) {
+ const [draft, setDraft] = useState(null);
+ return (
+ {
+ const raw = event.target.value;
+ setDraft(raw);
+ const month = completeMonth(raw);
+ if (month !== undefined) onChange(month);
+ }}
+ onBlur={() => {
+ if (draft !== null) {
+ const short = /^(\d{4})-([1-9])$/.exec(draft.trim());
+ if (short) onChange(`${short[1]}-0${short[2]}`);
+ }
+ setDraft(null);
+ }}
+ />
+ );
+}
+
+/** A whole month, null for an emptied field, undefined while still typing. */
+function completeMonth(raw: string): string | null | undefined {
+ const text = raw.trim();
+ if (text === "") return null;
+ return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined;
+}
+
+export function MultiField({
+ id,
+ label,
+ hint,
+ options,
+ value,
+ onChange,
+ max,
+ disabled,
+}: {
+ id: string;
+ label: string;
+ hint?: string;
+ options: ReadonlyArray<{ value: T; label: string }>;
+ value: readonly T[];
+ onChange: (value: T[]) => void;
+ max?: number;
+ disabled: boolean;
+}) {
+ return (
+
+ );
+}
+
+/** Month and year of "valid since", as `YYYY-MM`. */
+export function MonthField({
+ id,
+ value,
+ onChange,
+ t,
+ disabled,
+}: {
+ id: string;
+ value: string;
+ onChange: (value: string) => void;
+ t: Translate;
+ disabled: boolean;
+}) {
+ const fmt = useFormatters();
+ const [year, month] = value.split("-");
+ const thisYear = new Date().getFullYear();
+ const years = Array.from({ length: 80 }, (_, i) => String(thisYear - i));
+ return (
+
+ );
+}
diff --git a/src/components/records/life-circumstances-manager.tsx b/src/components/records/life-circumstances-manager.tsx
index e8a4ced02..01381d92f 100644
--- a/src/components/records/life-circumstances-manager.tsx
+++ b/src/components/records/life-circumstances-manager.tsx
@@ -19,13 +19,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2 } from "lucide-react";
import { AuditCPanel } from "@/components/records/audit-c-panel";
+import {
+ BoolField,
+ ChoiceField,
+ Field,
+ MonthField,
+ MultiField,
+ NumberField,
+ QuitMonthInput,
+ type Translate,
+} from "@/components/records/life-circumstances-fields";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -57,10 +63,6 @@ import {
type Section = LifeCircumstancesDto["sections"][number];
type Fact = Section["facts"][number];
-type Translate = (
- key: string,
- params?: Record,
-) => string;
const EMPTY = {
TOBACCO_DETAIL: {
@@ -172,308 +174,6 @@ async function writeKind(
}
}
-// ── Small field primitives ───────────────────────────────────────────────────
-
-function Field({
- id,
- label,
- children,
-}: {
- id: string;
- label: string;
- children: ReactNode;
-}) {
- return (
-
- {/* Block, so the colon follows a label that wraps instead of standing
- alone at the far edge as a flex item. */}
-
- {children}
-
- );
-}
-
-function ChoiceField({
- id,
- label,
- value,
- options,
- onChange,
- t,
- disabled,
-}: {
- id: string;
- label: string;
- value: string;
- options: ReadonlyArray<{ value: string; label: string }>;
- onChange: (value: string) => void;
- t: Translate;
- disabled: boolean;
-}) {
- return (
-
- onChange(event.target.value)}
- >
-
- {options.map((option) => (
-
- ))}
-
-
- );
-}
-
-function BoolField({
- id,
- label,
- value,
- onChange,
- t,
- disabled,
-}: {
- id: string;
- label: string;
- value: boolean | null;
- onChange: (value: boolean | null) => void;
- t: Translate;
- disabled: boolean;
-}) {
- return (
- onChange(next === "" ? null : next === "yes")}
- t={t}
- disabled={disabled}
- />
- );
-}
-
-function NumberField({
- id,
- label,
- value,
- min,
- max,
- step = 1,
- onChange,
- disabled,
-}: {
- id: string;
- label: string;
- value: number | null;
- min: number;
- max: number;
- step?: number;
- onChange: (value: number | null) => void;
- disabled: boolean;
-}) {
- // What the person is typing. A year passes through "1", "19" and "198" on
- // its way to "1985", so clamping each keystroke turned the first digit into
- // the minimum and the field could not be typed into. A value is committed
- // once it is in range, and leaving the field puts the committed one back.
- const [draft, setDraft] = useState(null);
- return (
-
- {
- const raw = event.target.value;
- setDraft(raw);
- if (raw === "") return onChange(null);
- const parsed = Number(raw);
- if (Number.isFinite(parsed) && parsed >= min && parsed <= max) {
- onChange(parsed);
- }
- }}
- onBlur={() => setDraft(null)}
- />
-
- );
-}
-
-/**
- * A month typed as YYYY-MM. Only a complete month is committed, so the field
- * keeps what is being typed on its own: holding it to the committed value
- * threw away every keystroke until the text was a whole month, and only a
- * paste got through. Leaving the field pads "2019-3" to "2019-03" and puts
- * the committed month back over anything that is not one.
- */
-function QuitMonthInput({
- id,
- value,
- onChange,
- disabled,
-}: {
- id: string;
- value: string | null;
- onChange: (value: string | null) => void;
- disabled: boolean;
-}) {
- const [draft, setDraft] = useState(null);
- return (
- {
- const raw = event.target.value;
- setDraft(raw);
- const month = completeMonth(raw);
- if (month !== undefined) onChange(month);
- }}
- onBlur={() => {
- if (draft !== null) {
- const short = /^(\d{4})-([1-9])$/.exec(draft.trim());
- if (short) onChange(`${short[1]}-0${short[2]}`);
- }
- setDraft(null);
- }}
- />
- );
-}
-
-/** A whole month, null for an emptied field, undefined while still typing. */
-function completeMonth(raw: string): string | null | undefined {
- const text = raw.trim();
- if (text === "") return null;
- return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined;
-}
-
-function MultiField({
- id,
- label,
- hint,
- options,
- value,
- onChange,
- max,
- disabled,
-}: {
- id: string;
- label: string;
- hint?: string;
- options: ReadonlyArray<{ value: T; label: string }>;
- value: readonly T[];
- onChange: (value: T[]) => void;
- max?: number;
- disabled: boolean;
-}) {
- return (
-
- );
-}
-
-/** Month and year of "valid since", as `YYYY-MM`. */
-function MonthField({
- id,
- value,
- onChange,
- t,
- disabled,
-}: {
- id: string;
- value: string;
- onChange: (value: string) => void;
- t: Translate;
- disabled: boolean;
-}) {
- const fmt = useFormatters();
- const [year, month] = value.split("-");
- const thisYear = new Date().getFullYear();
- const years = Array.from({ length: 80 }, (_, i) => String(thisYear - i));
- return (
-
- );
-}
-
/** The kinds a section writes, in the order it writes them. */
const SECTION_KINDS: Record> = {
tobacco: ["SMOKING_STATUS", "TOBACCO_DETAIL"],
From a812fb699f1e6c275463238f28f51ec3605bb4ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 21:02:02 +0100
Subject: [PATCH 05/55] fix(mental-health): read the country from the region
subtag for every language
A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.
Refs #1338
---
CHANGELOG.md | 6 ++
.../__tests__/crisis-resources.test.ts | 43 ++++++++++
src/lib/mental-health/crisis-resources.ts | 79 +++++++++++++------
3 files changed, 106 insertions(+), 22 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b545980e5..778a927c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,12 @@
range, so the first digit turned into 1900. Both fields now keep what is
typed and commit it once it is a valid value, and leaving the month field
pads "2019-3" to "2019-03". (#1334)
+- **Crisis lines for the country you are in.** A questionnaire answered
+ from a phone set to English in Belgium (`en-BE`), French in Switzerland,
+ or English in Austria got the international crisis set, because the
+ country was only read for some languages. The region in the language tag
+ now picks the country's lines whatever the language; a bare language
+ code keeps the combined sets as before. (#1338)
### Changed
diff --git a/src/lib/mental-health/__tests__/crisis-resources.test.ts b/src/lib/mental-health/__tests__/crisis-resources.test.ts
index 657ff6cd6..0c6504d32 100644
--- a/src/lib/mental-health/__tests__/crisis-resources.test.ts
+++ b/src/lib/mental-health/__tests__/crisis-resources.test.ts
@@ -86,6 +86,49 @@ describe("crisisResourcesForLocale", () => {
}
});
+ it("resolves the country from the region subtag whatever the language", () => {
+ const cases: Array<[string, string]> = [
+ ["en-BE", "zelfmoordlijn1813"],
+ ["fr-BE", "zelfmoordlijn1813"],
+ ["en-NL", "zelfmoordpreventie113"],
+ ["en-AT", "telefonSeelsorgeAt"],
+ ["fr-CH", "dargeboteneHand"],
+ ["it_CH", "dargeboteneHand"],
+ ["en-DE", "telefonSeelsorge"],
+ ["de-DE", "telefonSeelsorge"],
+ ["en-PT", "sns24"],
+ ["en-BR", "cvv"],
+ ["es-US", "lifeline988"],
+ ["zh-Hant-CH", "dargeboteneHand"],
+ ];
+ for (const [locale, first] of cases) {
+ const set = crisisResourcesForLocale(locale);
+ expect(set.resources[0]?.id, locale).toBe(first);
+ }
+ // A country set never carries another country's lines.
+ expect(
+ crisisResourcesForLocale("en-DE").resources.map((r) => r.id),
+ ).not.toContain("dargeboteneHand");
+ expect(crisisResourcesForLocale("en-AT").emergencyNumber).toBe("112");
+ expect(crisisResourcesForLocale("es-US").emergencyNumber).toBe("911");
+ });
+
+ it("keeps the language rules when the region has no set of its own", () => {
+ expect(
+ crisisResourcesForLocale("de-LI").resources.map((r) => r.id),
+ ).toEqual(crisisResourcesForLocale("de").resources.map((r) => r.id));
+ expect(crisisResourcesForLocale("nl-SR").resources[0]?.id).toBe(
+ "zelfmoordpreventie113",
+ );
+ expect(crisisResourcesForLocale("nl-SR").resources).toHaveLength(3);
+ for (const locale of ["en", "en-GB", "fr-FR", "", null, undefined]) {
+ expect(
+ crisisResourcesForLocale(locale).resources.map((r) => r.id),
+ String(locale),
+ ).toEqual(crisisResourcesForLocale("xx").resources.map((r) => r.id));
+ }
+ });
+
it("names every crisis resource in every language", () => {
// History re-derives the set from the row's stored locale and renders it
// in whatever language the reader uses now.
diff --git a/src/lib/mental-health/crisis-resources.ts b/src/lib/mental-health/crisis-resources.ts
index 402290cc3..341c57c37 100644
--- a/src/lib/mental-health/crisis-resources.ts
+++ b/src/lib/mental-health/crisis-resources.ts
@@ -195,33 +195,68 @@ const NL_BE: CrisisResourceSet = {
};
/**
- * Resolve a crisis-resource set from the user's locale. Region detection is
- * coarse (locale prefix) and defaults to the international set — the goal is to
- * always surface SOMETHING actionable, never to be exhaustive.
+ * Country sets reached by a region subtag, whatever the language. Someone in
+ * Belgium with the phone in English sends "en-BE" and gets Belgium's line,
+ * not the international set.
+ */
+const BY_REGION: Readonly> = {
+ at: AT,
+ be: BE,
+ br: BR,
+ ch: CH,
+ de: DE,
+ nl: NL,
+ pt: PT,
+ us: US,
+};
+
+/**
+ * The region subtag of a BCP 47 tag ("en-BE" -> "be", "zh-Hant-TW" -> "tw"),
+ * or null for a bare language code. Only a two-letter subtag after the
+ * language counts; a script subtag ("Hant") is skipped.
+ */
+function regionSubtag(tag: string): string | null {
+ const parts = tag.split("-");
+ for (const part of parts.slice(1)) {
+ if (/^[a-z]{2}$/.test(part)) return part;
+ }
+ return null;
+}
+
+/**
+ * Resolve a crisis-resource set from the user's locale. The region subtag
+ * decides first: any language with a region we have a set for ("en-BE",
+ * "fr-CH", "en-AT", "de-DE") gets that country's set. Without a region the
+ * language decides, coarsely, and anything unknown gets the international
+ * set: the goal is to always surface SOMETHING actionable, never to be
+ * exhaustive.
*
- * A region-qualified tag ("de-AT", "de_CH", "pt-PT", …) resolves to the exact
- * country set when the caller has one to give us. The web stores the app
- * locale, which is a bare "de", "nl" or the folded "pt-BR", so it lands on the
- * combined sets: never assume Germany, the Netherlands or Brazil when the
- * region is unknown.
+ * One tag is not a region signal: "pt-BR" is the web's folded Portuguese
+ * bundle (see PT_BR_AMBIGUOUS), so it keeps the ambiguous set. The web stores
+ * the app locale, which is otherwise a bare "de", "nl", "en", so it lands on
+ * the combined sets: never assume Germany, the Netherlands or Brazil when the
+ * region is unknown. A bare country code ("at", "ch", "us", "br", "be")
+ * still resolves to that country.
*/
export function crisisResourcesForLocale(
locale: string | null | undefined,
): CrisisResourceSet {
- const lc = (locale ?? "").toLowerCase().replace("_", "-");
- if (lc === "de-at" || lc === "at") return AT;
- if (lc === "de-ch" || lc === "ch") return CH;
- if (lc.startsWith("de")) return DE_AT_CH;
- if (lc.startsWith("en-us") || lc === "us") return US;
- if (lc === "br") return BR;
- if (lc === "pt-pt") return PT;
- // "pt-BR" from the web is the folded app locale, not a region signal; see
- // PT_BR_AMBIGUOUS. Any other Portuguese tag (pt, pt-AO, pt-MZ) lands here
- // too, and still gets 112 and the worldwide directory first.
- if (lc === "pt" || lc.startsWith("pt-")) return PT_BR_AMBIGUOUS;
- if (lc === "nl-nl") return NL;
- if (lc === "nl-be" || lc === "be") return BE;
+ const lc = (locale ?? "").trim().toLowerCase().replaceAll("_", "-");
+ if (lc === "pt-br") return PT_BR_AMBIGUOUS;
+ const region = regionSubtag(lc);
+ if (region !== null) {
+ const byRegion = BY_REGION[region];
+ if (byRegion) return byRegion;
+ }
+ if (lc === "at" || lc === "ch" || lc === "us" || lc === "br" || lc === "be") {
+ return BY_REGION[lc];
+ }
+ const language = lc.split("-")[0];
+ if (language === "de") return DE_AT_CH;
+ // Any other Portuguese tag (pt, pt-AO, pt-MZ) still gets 112 and the
+ // worldwide directory first.
+ if (language === "pt") return PT_BR_AMBIGUOUS;
// The bare "nl" the web stores, and any other Dutch tag: both countries.
- if (lc === "nl" || lc.startsWith("nl-")) return NL_BE;
+ if (language === "nl") return NL_BE;
return INTERNATIONAL;
}
From 66533497de98fd388a5bcf3be1b64898377bcb84 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 21:04:10 +0100
Subject: [PATCH 06/55] fix(auth): carry purgeAt on the native OIDC
deletion-pending redirect
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.
Refs #1341
---
CHANGELOG.md | 4 ++
docs/api/openapi.yaml | 12 +++---
.../oidc/callback/__tests__/route.test.ts | 37 ++++++++++++++++++
src/app/api/auth/oidc/callback/route.ts | 38 ++++++++++++++-----
src/lib/openapi/routes/auth.ts | 2 +-
5 files changed, 78 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 778a927c4..fac8da7f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,10 @@
country was only read for some languages. The region in the language tag
now picks the country's lines whatever the language; a bare language
code keeps the combined sets as before. (#1338)
+- **SSO sign-in in the app names the deletion date.** Signing in with SSO
+ on the phone to an account pending deletion now carries the date the
+ account is erased, as the web sign-in already did, so the app can say
+ until when an admin can still undo it. (#1341)
### Changed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b9712d115..943986f2c 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -4058,11 +4058,13 @@ paths:
drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with
`code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of
`oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`,
- `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`.
- `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet;
- the owner links it by signing in once with that account's own password or passkey in a browser. The reasons
- are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic
- sign-in failure.
+ `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `account_deletion_pending`,
+ `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is
+ not linked yet; the owner links it by signing in once with that account's own password or passkey in a
+ browser. `account_deletion_pending` means the account is locked pending deletion; when the purge date is
+ known it rides beside the reason as `purgeAt=` on both legs, and nothing else about the
+ account does. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does
+ not know to a generic sign-in failure.
/api/auth/passkey/register-options:
post:
tags:
diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts
index 161f0e687..2ae3f8081 100644
--- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts
+++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts
@@ -653,6 +653,9 @@ describe("GET /api/auth/oidc/callback — duplicate-callback idempotency", () =>
});
});
+/** The two columns `pendingDeletionOf` reads off the user row. */
+type PendingRow = Awaited>;
+
describe("GET /api/auth/oidc/callback — native branch", () => {
beforeEach(() => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
@@ -751,6 +754,40 @@ describe("GET /api/auth/oidc/callback — native branch", () => {
);
});
+ it("carries purgeAt, and nothing else, on a native sign-in to an account pending deletion", async () => {
+ mockIdentity();
+ mockUserLookups({
+ byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
+ });
+ vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
+ deletionRequestedAt: new Date("2026-11-01T10:00:00.000Z"),
+ deletionPurgeAt: new Date("2026-12-01T10:00:00.000Z"),
+ } as PendingRow);
+
+ const res = await GET(nativeRequest());
+ expect(res.headers.get("location")).toBe(
+ "healthlog://oidc-callback?error=account_deletion_pending&purgeAt=2026-12-01T10%3A00%3A00.000Z",
+ );
+ expect(mintNativeHandoff).not.toHaveBeenCalled();
+ expect(createSession).not.toHaveBeenCalled();
+ });
+
+ it("omits purgeAt on the native leg when the purge date is unknown", async () => {
+ mockIdentity();
+ mockUserLookups({
+ byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
+ });
+ vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
+ deletionRequestedAt: new Date("2026-11-01T10:00:00.000Z"),
+ deletionPurgeAt: null,
+ } as PendingRow);
+
+ const res = await GET(nativeRequest());
+ expect(res.headers.get("location")).toBe(
+ "healthlog://oidc-callback?error=account_deletion_pending",
+ );
+ });
+
it("delivers an IdP denial to the custom scheme", async () => {
const res = await GET(
makeRequest({
diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts
index ae12a38b8..c23f81e47 100644
--- a/src/app/api/auth/oidc/callback/route.ts
+++ b/src/app/api/auth/oidc/callback/route.ts
@@ -95,6 +95,26 @@ function secondFactorRedirect(
return response;
}
+/**
+ * A failure on the native leg: the custom scheme with `error` and, at most,
+ * a non-secret detail beside it. An error redirect carries no code, ticket,
+ * or session.
+ */
+function nativeErrorRedirect(params: Record): NextResponse {
+ const response = NextResponse.redirect(buildNativeCallbackUrl(params));
+ deleteStateCookie(response);
+ return response;
+}
+
+/**
+ * The purge date as the `purgeAt` parameter the native leg carries beside
+ * `account_deletion_pending`, mirroring `deletionPendingRedirect` (#1341).
+ * Only the date: nothing else about the account rides the URL.
+ */
+function purgeAtParam(purgeAt: Date | null): Record {
+ return purgeAt ? { purgeAt: purgeAt.toISOString() } : {};
+}
+
/**
* The web sign-in page for an account pending deletion, with the date it is
* erased, so the page can say until when an admin can undo it.
@@ -180,16 +200,13 @@ export const GET = apiHandler(async (req: NextRequest) => {
return response;
};
- const failRedirect = (reason: string): NextResponse => {
+ const failRedirect = (
+ reason: string,
+ nativeExtra: Record = {},
+ ): NextResponse => {
if (isReproof) return reproofBack(reason);
if (isClaim) return claimRedirect(reason);
- if (isNative) {
- const response = NextResponse.redirect(
- buildNativeCallbackUrl({ error: reason }),
- );
- deleteStateCookie(response);
- return response;
- }
+ if (isNative) return nativeErrorRedirect({ error: reason, ...nativeExtra });
return errorRedirect(reason);
};
@@ -349,7 +366,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
// the caller is this account, so the sign-in page may name the date.
const pendingDeletion = await pendingDeletionOf(user.id);
if (pendingDeletion) {
- if (isNative || isClaim) return failRedirect("account_deletion_pending");
+ if (isNative || isClaim) {
+ const extra = purgeAtParam(pendingDeletion.purgeAt);
+ return failRedirect("account_deletion_pending", extra);
+ }
return deletionPendingRedirect(pendingDeletion.purgeAt);
}
diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts
index 05a213b7e..d42f61ea1 100644
--- a/src/lib/openapi/routes/auth.ts
+++ b/src/lib/openapi/routes/auth.ts
@@ -2455,7 +2455,7 @@ export const authPaths: NonNullable = {
responses: {
"302": {
description:
- "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser; `/auth/claim?error=` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
+ "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser; `/auth/claim?error=` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `account_deletion_pending`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. `account_deletion_pending` means the account is locked pending deletion; when the purge date is known it rides beside the reason as `purgeAt=` on both legs, and nothing else about the account does. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
},
},
},
From 59434cd463ccfe71dab5ab3f49e406a326df682c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 21:05:51 +0100
Subject: [PATCH 07/55] feat(healthkit): list the accepted sample types on the
integration read
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.
Refs #1340
---
CHANGELOG.md | 7 +++
docs/api/openapi.yaml | 9 ++++
.../healthkit/__tests__/route.test.ts | 21 ++++++++
src/app/api/integrations/healthkit/route.ts | 3 ++
.../healthkit-accepted-types.test.ts | 54 +++++++++++++++++++
.../measurements/healthkit-accepted-types.ts | 17 ++++++
src/lib/openapi/routes/integrations.ts | 6 +++
7 files changed, 117 insertions(+)
create mode 100644 src/lib/measurements/__tests__/healthkit-accepted-types.test.ts
create mode 100644 src/lib/measurements/healthkit-accepted-types.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fac8da7f1..dd9a927a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
## [Unreleased]
+### Added
+
+- `GET /api/integrations/healthkit` lists the HealthKit sample types the
+ server accepts as `acceptedTypes`, taken from the same mapping the batch
+ upload uses. The iOS app can then ask for and send only types the server
+ stores, instead of reading a type the server would skip. (#1340)
+
### Fixed
- **Typing into the smoking history.** "When you stopped" only took text
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 943986f2c..3d075ec93 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -83483,6 +83483,15 @@ components:
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
+ acceptedTypes:
+ description: The HealthKit quantity and category identifiers `POST /api/measurements/batch` maps today (e.g.
+ `HKQuantityTypeIdentifierBodyMass`, `HKCategoryTypeIdentifierSleepAnalysis`), sorted. Derived from the
+ server's mapping table, so a type the server defers is never listed. A client requests and sends only types
+ in this list and its own table; when the field is absent (an older server) it keeps its own table. Present
+ on the GET read; omitted from the PATCH echo.
+ type: array
+ items:
+ type: string
required:
- entries
- lastSyncedAt
diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts
index 57e944359..6b0e52f94 100644
--- a/src/app/api/integrations/healthkit/__tests__/route.test.ts
+++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts
@@ -45,6 +45,10 @@ import { getSession } from "@/lib/auth/session";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
import { auditLog } from "@/lib/auth/audit";
+import {
+ APPLE_HEALTH_TYPE_MAP,
+ HK_QUANTITY_TYPE_DEFERRED,
+} from "@/lib/measurements/apple-health-mapping";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -110,6 +114,23 @@ describe("GET /api/integrations/healthkit", () => {
const bodyMass = body.data.entries.find((e) => e.id === "bodyMass");
expect(bodyMass?.direction).toBe("bidirectional");
});
+
+ it("lists the sample types the batch route maps, never a deferred one", async () => {
+ vi.mocked(getSession).mockResolvedValue(
+ SESSION_OK as Awaited>,
+ );
+ const res = await callGet(makeGetReq());
+ const body = (await res.json()) as { data: { acceptedTypes: string[] } };
+ expect(body.data.acceptedTypes).toEqual(
+ Object.keys(APPLE_HEALTH_TYPE_MAP).sort(),
+ );
+ expect(body.data.acceptedTypes).toContain(
+ "HKCategoryTypeIdentifierSleepAnalysis",
+ );
+ for (const id of HK_QUANTITY_TYPE_DEFERRED) {
+ expect(body.data.acceptedTypes).not.toContain(id);
+ }
+ });
});
describe("PATCH /api/integrations/healthkit", () => {
diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts
index 62a991514..8fd0ca1ff 100644
--- a/src/app/api/integrations/healthkit/route.ts
+++ b/src/app/api/integrations/healthkit/route.ts
@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
+import { acceptedHealthKitTypes } from "@/lib/measurements/healthkit-accepted-types";
import {
getHealthKitTypeArrivals,
healthKitWriteBackTypes,
@@ -241,6 +242,8 @@ export const GET = apiHandler(async () => {
),
syncProgress,
health,
+ // #1340 — what the batch route maps, so the app asks only for those.
+ acceptedTypes: acceptedHealthKitTypes(),
});
});
diff --git a/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts b/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts
new file mode 100644
index 000000000..dce617d84
--- /dev/null
+++ b/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ APPLE_HEALTH_TYPE_MAP,
+ HK_QUANTITY_TYPE_DEFERRED,
+ mapAppleHealthEntry,
+} from "../apple-health-mapping";
+import { acceptedHealthKitTypes } from "../healthkit-accepted-types";
+
+describe("acceptedHealthKitTypes", () => {
+ it("is exactly the mapping table's identifiers, sorted", () => {
+ const accepted = acceptedHealthKitTypes();
+ expect(accepted.length).toBeGreaterThan(0);
+ expect([...accepted]).toEqual(Object.keys(APPLE_HEALTH_TYPE_MAP).sort());
+ });
+
+ it("carries quantity and category identifiers, never a deferred one", () => {
+ const accepted = acceptedHealthKitTypes();
+ expect(accepted).toContain("HKQuantityTypeIdentifierBodyMass");
+ expect(accepted).toContain("HKCategoryTypeIdentifierSleepAnalysis");
+ for (const id of accepted) {
+ expect(HK_QUANTITY_TYPE_DEFERRED.has(id), id).toBe(false);
+ }
+ expect(accepted).not.toContain("HKQuantityTypeIdentifierHeight");
+ expect(accepted).not.toContain("HKQuantityTypeIdentifierBasalEnergyBurned");
+ });
+
+ it("lists only identifiers the batch mapper turns into a row", () => {
+ for (const id of acceptedHealthKitTypes()) {
+ const out = mapAppleHealthEntry({
+ hkIdentifier: id,
+ value: 1,
+ unit: APPLE_HEALTH_TYPE_MAP[id]!.hkUnit,
+ startDate: "2026-01-01T10:00:00.000Z",
+ endDate: "2026-01-01T10:00:00.000Z",
+ sleepStage: 1,
+ categoryValue: 1,
+ });
+ expect(out, id).not.toBeNull();
+ }
+ for (const id of HK_QUANTITY_TYPE_DEFERRED) {
+ expect(
+ mapAppleHealthEntry({
+ hkIdentifier: id,
+ value: 1,
+ unit: "count",
+ startDate: "2026-01-01T10:00:00.000Z",
+ endDate: "2026-01-01T10:00:00.000Z",
+ }),
+ id,
+ ).toBeNull();
+ }
+ });
+});
diff --git a/src/lib/measurements/healthkit-accepted-types.ts b/src/lib/measurements/healthkit-accepted-types.ts
new file mode 100644
index 000000000..a2e60be19
--- /dev/null
+++ b/src/lib/measurements/healthkit-accepted-types.ts
@@ -0,0 +1,17 @@
+/**
+ * The HealthKit sample types `POST /api/measurements/batch` maps today,
+ * derived from `APPLE_HEALTH_TYPE_MAP` and nothing else, so the list the
+ * iOS app reads on `GET /api/integrations/healthkit` cannot drift from what
+ * the batch route accepts (#1340). A type in `HK_QUANTITY_TYPE_DEFERRED` is
+ * by construction absent: the mapping test refuses a double-booking.
+ */
+import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping";
+
+const ACCEPTED: readonly string[] = Object.freeze(
+ Object.keys(APPLE_HEALTH_TYPE_MAP).sort(),
+);
+
+/** Quantity and category identifiers the batch route maps, sorted. */
+export function acceptedHealthKitTypes(): readonly string[] {
+ return ACCEPTED;
+}
diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts
index e8876da72..48b64bc3d 100644
--- a/src/lib/openapi/routes/integrations.ts
+++ b/src/lib/openapi/routes/integrations.ts
@@ -378,6 +378,12 @@ const healthKitConfigResponse = z
.describe(
"The unified Apple Health connection state. Null when the phone has never delivered anything (or the read failed). Present on the GET read; omitted from the PATCH echo.",
),
+ acceptedTypes: z
+ .array(z.string())
+ .optional()
+ .describe(
+ "The HealthKit quantity and category identifiers `POST /api/measurements/batch` maps today (e.g. `HKQuantityTypeIdentifierBodyMass`, `HKCategoryTypeIdentifierSleepAnalysis`), sorted. Derived from the server's mapping table, so a type the server defers is never listed. A client requests and sends only types in this list and its own table; when the field is absent (an older server) it keeps its own table. Present on the GET read; omitted from the PATCH echo.",
+ ),
})
.meta({
id: "HealthKitConfigResponse",
From a79fc4168ae3d0836ca02b7e4a29d7979d1b80b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 21:09:04 +0100
Subject: [PATCH 08/55] feat(sync): let a client start the delta feed at the
present
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.
Refs #1339
---
CHANGELOG.md | 4 +
docs/api/openapi.yaml | 13 ++
.../sync/changes/__tests__/start-now.test.ts | 100 ++++++++++++
src/app/api/sync/changes/route.ts | 28 +++-
src/lib/openapi/routes/sync.ts | 6 +
src/lib/sync/__tests__/cursor.test.ts | 18 +++
src/lib/sync/changes/pull.ts | 21 +++
src/lib/sync/cursor.ts | 16 ++
.../sync-changes-start-now.test.ts | 150 ++++++++++++++++++
9 files changed, 351 insertions(+), 5 deletions(-)
create mode 100644 src/app/api/sync/changes/__tests__/start-now.test.ts
create mode 100644 tests/integration/sync-changes-start-now.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd9a927a4..ddee70bfc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@
server accepts as `acceptedTypes`, taken from the same mapping the batch
upload uses. The iOS app can then ask for and send only types the server
stores, instead of reading a type the server would skip. (#1340)
+- `GET /api/sync/changes?start=now` answers an empty page with a cursor
+ that starts at the present. The iOS app can page forward from it to
+ write readings to Apple Health in the background, without first walking
+ years of history. (#1339)
### Fixed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 3d075ec93..e0fd36a86 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -4323,6 +4323,19 @@ paths:
maxLength: 2048
description: Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit
for the initial sync.
+ - in: query
+ name: start
+ schema:
+ description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in
+ every domain, in the same opaque format as any other page. Paging forward from it yields only what changes
+ from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with
+ `cursor` (422). A server without it ignores the parameter and starts from the oldest row."
+ type: string
+ const: now
+ description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in
+ every domain, in the same opaque format as any other page. Paging forward from it yields only what changes
+ from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with
+ `cursor` (422). A server without it ignores the parameter and starts from the oldest row."
- in: query
name: limit
schema:
diff --git a/src/app/api/sync/changes/__tests__/start-now.test.ts b/src/app/api/sync/changes/__tests__/start-now.test.ts
new file mode 100644
index 000000000..240fd51fa
--- /dev/null
+++ b/src/app/api/sync/changes/__tests__/start-now.test.ts
@@ -0,0 +1,100 @@
+/**
+ * `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
+ *
+ * The answer is an empty page whose cursor sits at `serverNow` in every
+ * domain, in the feed's own opaque format, and nothing is read: the point is
+ * to skip the history walk, so a read here would be the bug. The database is
+ * deliberately not mocked and not reachable: any read would fail the request,
+ * so the 200 below is itself the proof that none happened.
+ */
+import { beforeEach, describe, expect, it, vi } 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/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET } from "../route";
+import { getSession } from "@/lib/auth/session";
+import { checkRateLimit } from "@/lib/rate-limit";
+import {
+ decodeCursor,
+ decodeCursorIssuedAt,
+ SYNC_DOMAINS,
+} from "@/lib/sync/cursor";
+
+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 req(query: string): NextRequest {
+ return new NextRequest(`http://localhost/api/sync/changes${query}`);
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSession).mockResolvedValue(
+ SESSION_OK as Awaited>,
+ );
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 119,
+ resetAt: Date.now() + 60_000,
+ } as Awaited>);
+});
+
+describe("GET /api/sync/changes?start=now", () => {
+ it("answers an empty page with a cursor at serverNow in every domain", async () => {
+ const before = Date.now();
+ const res = await GET(req("?start=now"));
+ const after = Date.now();
+ expect(res.status).toBe(200);
+ const { data } = (await res.json()) as {
+ data: {
+ serverNow: string;
+ cursor: string;
+ hasMore: boolean;
+ cursorExpired: boolean;
+ changes: Record;
+ };
+ };
+ expect(data.hasMore).toBe(false);
+ expect(data.cursorExpired).toBe(false);
+ expect(Object.keys(data.changes).sort()).toEqual([...SYNC_DOMAINS].sort());
+ for (const block of Object.values(data.changes)) {
+ expect(block).toEqual({ upserts: [], tombstones: [] });
+ }
+ const nowMs = Date.parse(data.serverNow);
+ expect(nowMs).toBeGreaterThanOrEqual(before);
+ expect(nowMs).toBeLessThanOrEqual(after);
+ const cursor = decodeCursor(data.cursor);
+ expect(cursor).not.toBeNull();
+ for (const domain of SYNC_DOMAINS) {
+ expect(cursor![domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
+ }
+ expect(decodeCursorIssuedAt(data.cursor)).toBe(nowMs);
+ });
+
+ it("refuses start together with a cursor, and any start value but now", async () => {
+ const both = await GET(req("?start=now&cursor=abc"));
+ expect(both.status).toBe(422);
+ const other = await GET(req("?start=oldest"));
+ expect(other.status).toBe(422);
+ });
+
+ it("still requires a session", async () => {
+ vi.mocked(getSession).mockResolvedValue(null);
+ const res = await GET(req("?start=now"));
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts
index d714e7fff..76f0fbc8a 100644
--- a/src/app/api/sync/changes/route.ts
+++ b/src/app/api/sync/changes/route.ts
@@ -43,6 +43,11 @@
* cycle tracking off gets both blocks empty (never a 403 — see the
* gate note in the handler) and their cursor watermarks untouched.
*
+ * `start=now` (#1339) answers an empty page whose cursor sits at
+ * `serverNow` in every domain, in the same opaque format, so a client that
+ * only wants what changes from now on (the Apple Health write-back) pages
+ * forward from there instead of draining the whole history first.
+ *
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
* Read-only — no idempotency, no write side-effect (unlike the legacy
* `/api/sync/state` checkpoint bump). The cursor is owned by the client.
@@ -59,15 +64,23 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
-import { pullSyncChanges } from "@/lib/sync/changes/pull";
+import { pullSyncChanges, syncChangesFromNow } from "@/lib/sync/changes/pull";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
-const querySchema = z.object({
- cursor: z.string().min(1).max(2048).optional(),
- limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
-});
+// `start=now` (#1339) asks for a cursor at the present instead of a page;
+// it contradicts an echoed cursor, so the two together are refused.
+const querySchema = z
+ .object({
+ cursor: z.string().min(1).max(2048).optional(),
+ limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
+ start: z.literal("now").optional(),
+ })
+ .refine((q) => !(q.start && q.cursor), {
+ message: "start and cursor are mutually exclusive",
+ path: ["start"],
+ });
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -83,6 +96,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = querySchema.safeParse({
cursor: url.searchParams.get("cursor") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
+ start: url.searchParams.get("start") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
@@ -92,6 +106,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
+ if (parsed.data.start === "now") {
+ return apiSuccess(syncChangesFromNow(new Date()));
+ }
+
return apiSuccess(
await pullSyncChanges({
userId: user.id,
diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts
index 5444c6aac..4293a2285 100644
--- a/src/lib/openapi/routes/sync.ts
+++ b/src/lib/openapi/routes/sync.ts
@@ -252,6 +252,12 @@ const syncChangesQuery = z
.describe(
"Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit for the initial sync.",
),
+ start: z
+ .literal("now")
+ .optional()
+ .describe(
+ "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields only what changes from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
+ ),
limit: z.coerce
.number()
.int()
diff --git a/src/lib/sync/__tests__/cursor.test.ts b/src/lib/sync/__tests__/cursor.test.ts
index 793a9f3c6..80e9bad68 100644
--- a/src/lib/sync/__tests__/cursor.test.ts
+++ b/src/lib/sync/__tests__/cursor.test.ts
@@ -7,6 +7,8 @@ import {
encodeCursor,
decodeCursor,
decodeCursorIssuedAt,
+ encodeCursorAtNow,
+ SYNC_DOMAINS,
type SyncCursor,
} from "../cursor";
@@ -134,3 +136,19 @@ describe("cursor issue time (sync reset)", () => {
expect(decodeCursorIssuedAt("not a token")).toBeNull();
});
});
+
+describe("encodeCursorAtNow", () => {
+ it("places every domain at the instant, before any id in that millisecond", () => {
+ const nowMs = 1_790_000_000_123;
+ const token = encodeCursorAtNow(nowMs);
+ const cursor = decodeCursor(token);
+ for (const domain of SYNC_DOMAINS) {
+ expect(cursor?.[domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
+ }
+ expect(decodeCursorIssuedAt(token)).toBe(nowMs);
+ // Every cuid sorts after the tie-breaker, so a row in the same
+ // millisecond is delivered, not skipped.
+ expect("0" < "c").toBe(true);
+ expect("0" < "a").toBe(true);
+ });
+});
diff --git a/src/lib/sync/changes/pull.ts b/src/lib/sync/changes/pull.ts
index cae7aed19..9a81488ef 100644
--- a/src/lib/sync/changes/pull.ts
+++ b/src/lib/sync/changes/pull.ts
@@ -13,6 +13,7 @@ import {
decodeCursor,
decodeCursorIssuedAt,
encodeCursor,
+ encodeCursorAtNow,
SYNC_DOMAINS,
type SyncCursor,
type SyncDomain,
@@ -108,6 +109,26 @@ function emptyChanges(): SyncChangesBody["changes"] {
};
}
+/**
+ * `start=now` (#1339): an empty page with a cursor at `serverNow` in every
+ * domain, for a client that wants changes from now on (the Apple Health
+ * write-back) without paging through the history first. `hasMore` is false:
+ * the client is caught up as of `serverNow` by definition.
+ */
+export function syncChangesFromNow(serverNow: Date): SyncChangesBody {
+ annotate({
+ action: { name: "sync.changes.start_now" },
+ meta: { returned: 0 },
+ });
+ return {
+ serverNow: serverNow.toISOString(),
+ cursor: encodeCursorAtNow(serverNow.getTime()),
+ hasMore: false,
+ cursorExpired: false,
+ changes: emptyChanges(),
+ };
+}
+
export async function pullSyncChanges(input: {
userId: string;
/** The opaque cursor the client echoed, if any. */
diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts
index 4d8d1c161..7b3d12d4a 100644
--- a/src/lib/sync/cursor.ts
+++ b/src/lib/sync/cursor.ts
@@ -102,6 +102,22 @@ export function encodeCursor(cursor: SyncCursor, issuedAtMs?: number): string {
return Buffer.from(json, "utf-8").toString("base64url");
}
+/**
+ * A token positioned at `nowMs` in every domain (#1339), the same opaque
+ * format a drained page hands out: paging forward from it yields only what
+ * changes from now on, with no walk through the history first. The id `"0"`
+ * sorts before every cuid, so a row stamped in the very millisecond is
+ * delivered rather than skipped; the client's upserts are idempotent, so an
+ * overlap costs nothing and a gap would.
+ */
+export function encodeCursorAtNow(nowMs: number): string {
+ const cursor: SyncCursor = {};
+ for (const domain of SYNC_DOMAINS) {
+ cursor[domain] = { updatedAtMs: nowMs, id: "0" };
+ }
+ return encodeCursor(cursor, nowMs);
+}
+
function parseWatermark(raw: unknown): DomainWatermark | null {
if (typeof raw !== "object" || raw === null) return null;
const u = (raw as { u?: unknown }).u;
diff --git a/tests/integration/sync-changes-start-now.test.ts b/tests/integration/sync-changes-start-now.test.ts
new file mode 100644
index 000000000..b29f9b9bd
--- /dev/null
+++ b/tests/integration/sync-changes-start-now.test.ts
@@ -0,0 +1,150 @@
+/**
+ * `GET /api/sync/changes?start=now` (#1339), against Postgres.
+ *
+ * A client that only wants what changes from now on (the Apple Health
+ * write-back) asks for a cursor at the present, then pages forward. The
+ * history before that instant must not come back on the measurement walk,
+ * and switching a token's write-back on afterwards must still deliver all of
+ * that token's readings on `measurementMirror`, because the switch happens
+ * after the cursor.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { GET as syncChanges } from "@/app/api/sync/changes/route";
+import { PATCH as patchToken } from "@/app/api/tokens/[id]/route";
+import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
+
+import { callRoute, type RouteAuth } from "../kit/call-route";
+import {
+ makeMeasurement,
+ makeSession,
+ makeToken,
+ makeUser,
+ type BearerFixture,
+} from "../kit/factories";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+const prisma = getPrismaClient();
+
+interface Block {
+ upserts: Array<{ id: string; appleHealthMirror?: boolean }>;
+ tombstones: unknown[];
+}
+interface Page {
+ serverNow: string;
+ cursor: string | null;
+ hasMore: boolean;
+ cursorExpired: boolean;
+ changes: Record;
+}
+
+let userId: string;
+let owner: RouteAuth;
+let scale: BearerFixture;
+
+beforeEach(async () => {
+ await truncateAllTables(prisma);
+ const user = await makeUser({ timezone: "UTC" });
+ userId = user.id;
+ owner = await makeSession(user);
+ scale = await makeToken(user, {
+ permissions: [MEASUREMENTS_WRITE_SCOPE],
+ name: "Scale",
+ });
+});
+
+async function pull(query: Record) {
+ const res = await callRoute(syncChanges, {
+ method: "GET",
+ route: "/api/sync/changes",
+ query,
+ auth: owner,
+ });
+ expect(res.status).toBe(200);
+ return res.data;
+}
+
+async function pastMillisecond(): Promise {
+ const start = Date.now();
+ while (Date.now() <= start + 1) await new Promise((r) => setTimeout(r, 2));
+}
+
+describe("a cursor that starts now", () => {
+ it("skips the history and pages forward to what changes after it", async () => {
+ const old = [];
+ for (let i = 0; i < 3; i += 1) {
+ old.push(
+ await makeMeasurement(
+ { id: userId },
+ {
+ source: "EXTERNAL",
+ ingestTokenId: scale.tokenId,
+ measuredAt: new Date(Date.UTC(2026, 9, 1 + i, 6)),
+ externalId: `old-${i}`,
+ },
+ ),
+ );
+ }
+ await pastMillisecond();
+
+ const start = await pull({ start: "now" });
+ expect(start.hasMore).toBe(false);
+ expect(start.cursorExpired).toBe(false);
+ expect(start.cursor).toEqual(expect.any(String));
+ for (const block of Object.values(start.changes)) {
+ expect(block.upserts).toEqual([]);
+ }
+
+ // Nothing changed yet: the next page is empty in every domain.
+ const quiet = await pull({ cursor: start.cursor! });
+ for (const block of Object.values(quiet.changes)) {
+ expect(block.upserts).toEqual([]);
+ expect(block.tombstones).toEqual([]);
+ }
+
+ await pastMillisecond();
+ const fresh = await makeMeasurement(
+ { id: userId },
+ {
+ source: "EXTERNAL",
+ ingestTokenId: scale.tokenId,
+ measuredAt: new Date(Date.UTC(2026, 10, 4, 6)),
+ externalId: "fresh",
+ },
+ );
+ const switched = await callRoute(patchToken, {
+ method: "PATCH",
+ route: "/api/tokens/{id}",
+ params: { id: scale.tokenId },
+ body: { mirrorToAppleHealth: true },
+ auth: owner,
+ });
+ expect(switched.status).toBe(200);
+
+ const next = await pull({ cursor: start.cursor! });
+ expect(next.cursorExpired).toBe(false);
+ expect(next.changes.measurements.upserts.map((r) => r.id)).toEqual([
+ fresh.id,
+ ]);
+ const mirrored = next.changes.measurementMirror.upserts;
+ expect(mirrored.map((r) => r.id).sort()).toEqual(
+ [...old.map((m) => m.id), fresh.id].sort(),
+ );
+ expect(mirrored.every((r) => r.appleHealthMirror === true)).toBe(true);
+
+ // And the cursor it hands back is a normal one: draining further is quiet.
+ const after = await pull({ cursor: next.cursor! });
+ expect(after.changes.measurements.upserts).toEqual([]);
+ expect(after.changes.measurementMirror.upserts).toEqual([]);
+ });
+
+ it("refuses start=now beside a cursor", async () => {
+ const res = await callRoute(syncChanges, {
+ method: "GET",
+ route: "/api/sync/changes",
+ query: { start: "now", cursor: "abc" },
+ auth: owner,
+ });
+ expect(res.status).toBe(422);
+ });
+});
From a35b77c147698ec1e473f478b218103b1687472c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 21:18:29 +0100
Subject: [PATCH 09/55] feat(labs): serve one marker's lab line under the labs
module
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.
Refs #1342
---
CHANGELOG.md | 3 +
docs/api/openapi.yaml | 522 +++++++++++-------
src/__tests__/sharing-surface-guard.test.ts | 6 +-
src/app/api/biomarkers/[id]/series/route.ts | 98 ++++
src/lib/day/wire-schemas.ts | 90 +--
src/lib/labs/biomarker-series.ts | 61 ++
src/lib/openapi/error-codes.ts | 1 +
src/lib/openapi/routes/biomarkers.ts | 54 ++
.../timeline/__tests__/resolve-window.test.ts | 44 ++
src/lib/timeline/load-timeline.ts | 33 +-
tests/integration/biomarker-series.test.ts | 191 +++++++
11 files changed, 869 insertions(+), 234 deletions(-)
create mode 100644 src/app/api/biomarkers/[id]/series/route.ts
create mode 100644 src/lib/labs/biomarker-series.ts
create mode 100644 src/lib/timeline/__tests__/resolve-window.test.ts
create mode 100644 tests/integration/biomarker-series.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddee70bfc..97946122c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@
that starts at the present. The iOS app can page forward from it to
write readings to Apple Health in the background, without first walking
years of history. (#1339)
+- `GET /api/biomarkers/{id}/series` returns one lab marker's line, the same
+ points and window the timeline draws for it, under the Labs module. The
+ iOS app can show a marker's chart with the timeline switched off. (#1342)
### Fixed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index e0fd36a86..1d896a3de 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -17617,6 +17617,87 @@ paths:
"404": *a35
"422": *a4
"429": *a5
+ /api/biomarkers/{id}/series:
+ get:
+ tags:
+ - Labs
+ summary: One marker's lab line
+ description: "The line `labSeries` on `GET /api/timeline` draws for this marker, cut by the same `zoom`, `from` and `to`
+ rules: every numeric reading in the window, oldest first, one point each (never averaged), readings in a unit
+ that does not convert counted in `excluded`, and the band to draw behind them. `all` starts at the marker's
+ first reading. Gated on the `labs` module, not on `timeline`. A point names the report it was read from only
+ when the caller may see documents. Cross-user ids answer 404; a merged marker answers 404 with
+ `meta.mergedInto`."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ - in: query
+ name: zoom
+ schema:
+ default: all
+ description: "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to`
+ (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points
+ are never averaged, whatever the zoom."
+ type: string
+ enum:
+ - all
+ - year
+ - quarter
+ - range
+ description: "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to`
+ (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points
+ are never averaged, whatever the zoom."
+ - in: query
+ name: from
+ schema:
+ type: string
+ description: A local calendar date, `YYYY-MM-DD`.
+ - in: query
+ name: to
+ schema:
+ type: string
+ description: A local calendar date, `YYYY-MM-DD`.
+ responses:
+ "200":
+ description: The marker's line over the window.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BiomarkerSeriesEnvelope"
+ "401": *a1
+ "403":
+ description: >-
+ `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the
+ generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with
+ `meta.module = labs`: the record has the labs module switched off, or the operator turned it off
+ server-wide.
+
+
+ Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`).
+ Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections
+ do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it
+ is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's
+ audit trail instead. On the cookie transport the same code answers one further case: a session that has
+ entered a shared record and sends a request without its record-context assertion, which is a client older
+ than the fence — leave the record and reload. A request that names no record but the caller's own never
+ reaches this response, so a client that never switches and never sends the per-request account selector will
+ not see it.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "404": *a35
+ "422":
+ description: "`labs.series.invalid`: an unknown `zoom`, a malformed date, a `range` without both ends or longer than 15
+ years, or `from` after `to`."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429": *a5
/api/biomarkers/{id}/merge:
post:
tags:
@@ -39393,74 +39474,75 @@ components:
insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid,
labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch,
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
- labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed,
- labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit,
- labs.ocr.pdfRendererUnavailable, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.ocr.tooLarge,
- labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, labs.update.invalid,
- labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected,
- labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event —
- life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate,
- managed_profile.guardian.managed_invitee, managed_profile.guardian.required,
- managed_profile.handover.no_pending, managed_profile.handover.unknown_guardian,
- managed_profile.not_found; maintenance — maintenance.pulseDayMean.busy,
- maintenance.pulseDayMean.dryRunRequired; measurement — measurement.batch.invalid,
- measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid,
- measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large,
- measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source;
- medication — medication.intake.import.invalid_format, medication.intake.notTracked,
- medication.link.invalid; medications — medications.category.invalid, medications.category.limitReached,
- medications.category.unknown, medications.course.currentOpen, medications.course.invalid,
- medications.course.invalidRange, medications.course.notFound, medications.course.notRunning,
- medications.course.oneShot, medications.course.overlap, medications.course.windowRequired,
- medications.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed,
- medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType,
- medications.ingredients.extract.rateLimited, medications.ingredients.invalid,
- medications.ingredients.unitMismatch, medications.intake.bulk.apple_health_not_mirrored,
- medications.intake.bulk.invalid, medications.intake.bulk.too_large,
- medications.intake.force_slot.invalid, medications.intake.force_slot.occupied,
- medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start,
- medications.mirror.limit_exceeded, medications.notFound; mentalHealth — mentalHealth.rateLimited; module
- — module.disabled; modules — modules.body.invalid_json, modules.invalid; mood —
- mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable,
- mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid,
- mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid;
- notification-prefs — notification-prefs.body.invalid_json; notification —
- notification.action.already_used, notification.action.expired, notification.action.invalid,
- notification.action.module_disabled; nutrient — nutrient.batch.invalid, nutrient.batch.too_large,
- nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day;
- onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid,
- onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch,
- onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
- onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown,
- passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner —
- practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
- profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
- profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported,
- profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited; recovery —
- recovery.kit.stale; report-selection — report-selection.body.invalid_json,
- report-selection.body.invalid_shape, report-selection.leaves.unknown; request — request.cross_site;
- route — route.retired; search — search.invalid; security_activity — security_activity.invalid;
- share-link — share-link.document.decryptFailed, share-link.document.notFound,
- share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
- share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
- sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
- sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
- sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
- sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
- sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
- sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
- source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
- symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
- symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
- tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
- tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
- vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
- vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
- vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
- vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
- vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
- workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
- — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
+ labs.create.invalid, labs.series.invalid, labs.link.invalid, labs.ocr.budgetExceeded,
+ labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled,
+ labs.ocr.outputLimit, labs.ocr.pdfRendererUnavailable, labs.ocr.providerUnsupported,
+ labs.ocr.rateLimited, labs.ocr.tooLarge, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch,
+ labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
+ labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
+ labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found;
+ managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
+ managed_profile.guardian.required, managed_profile.handover.no_pending,
+ managed_profile.handover.unknown_guardian, managed_profile.not_found; maintenance —
+ maintenance.pulseDayMean.busy, maintenance.pulseDayMean.dryRunRequired; measurement —
+ measurement.batch.invalid, measurement.batch.source_not_permitted, measurement.batch.too_large,
+ measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted,
+ measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid,
+ measurement.update.server_owned_source; medication — medication.intake.import.invalid_format,
+ medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid,
+ medications.category.limitReached, medications.category.unknown, medications.course.currentOpen,
+ medications.course.invalid, medications.course.invalidRange, medications.course.notFound,
+ medications.course.notRunning, medications.course.oneShot, medications.course.overlap,
+ medications.course.windowRequired, medications.ingredients.extract.budgetExceeded,
+ medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge,
+ medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited,
+ medications.ingredients.invalid, medications.ingredients.unitMismatch,
+ medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid,
+ medications.intake.bulk.too_large, medications.intake.force_slot.invalid,
+ medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed,
+ medications.intake.taken_at.before_start, medications.mirror.limit_exceeded, medications.notFound;
+ mentalHealth — mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json,
+ modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large,
+ mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp,
+ mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range,
+ mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json;
+ notification — notification.action.already_used, notification.action.expired,
+ notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid,
+ nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid,
+ nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited,
+ onboarding.complete.invalid, onboarding.complete.recordNotFound,
+ onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid,
+ onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired,
+ passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records —
+ personal_records.invalid_query; practitioner — practitioner.invalid; profile —
+ profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired,
+ profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
+ profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
+ record_write.rate_limited; recovery — recovery.kit.stale; report-selection —
+ report-selection.body.invalid_json, report-selection.body.invalid_shape,
+ report-selection.leaves.unknown; request — request.cross_site; route — route.retired; search —
+ search.invalid; security_activity — security_activity.invalid; share-link —
+ share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
+ share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
+ share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
+ sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
+ sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
+ sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
+ sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
+ sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
+ sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
+ source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
+ symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
+ timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
+ tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token,
+ tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid,
+ vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found,
+ vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found,
+ vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
+ vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
+ workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
+ workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
+ ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
@@ -77874,6 +77956,185 @@ components:
- data
- error
additionalProperties: false
+ BiomarkerSeriesEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/BiomarkerSeries"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ BiomarkerSeries:
+ type: object
+ properties:
+ zoom:
+ type: string
+ enum:
+ - all
+ - year
+ - quarter
+ - range
+ range:
+ type: object
+ properties:
+ from:
+ type: string
+ description: A local calendar date, `YYYY-MM-DD`.
+ to:
+ type: string
+ description: A local calendar date, `YYYY-MM-DD`.
+ dataFrom:
+ anyOf:
+ - type: string
+ description: A local calendar date, `YYYY-MM-DD`.
+ - type: "null"
+ description: The local day of the marker's first numeric reading; null when it has none. `all` starts here.
+ required:
+ - from
+ - to
+ - dataFrom
+ additionalProperties: false
+ series:
+ $ref: "#/components/schemas/TimelineLabSeries"
+ required:
+ - zoom
+ - range
+ - series
+ additionalProperties: false
+ TimelineLabSeries:
+ type: object
+ properties:
+ key:
+ type: string
+ description: "`lab:`, as named in `values`."
+ biomarkerId:
+ type: string
+ name:
+ type: string
+ description: The marker's name, as the labs list shows it.
+ panel:
+ type:
+ - string
+ - "null"
+ unit:
+ type: string
+ description: The marker's own unit; every point is in it.
+ band:
+ anyOf:
+ - type: object
+ properties:
+ low:
+ type:
+ - number
+ - "null"
+ high:
+ type:
+ - number
+ - "null"
+ required:
+ - low
+ - high
+ additionalProperties: false
+ - type: "null"
+ description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest
+ reading in the window. Null when neither is known."
+ points:
+ type: array
+ items:
+ $ref: "#/components/schemas/TimelineLabPoint"
+ description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A
+ day with two readings has two points."
+ excluded:
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points`
+ rather than drawn on a scale they are not in.
+ required:
+ - key
+ - biomarkerId
+ - name
+ - panel
+ - unit
+ - band
+ - points
+ - excluded
+ additionalProperties: false
+ TimelineLabPoint:
+ type: object
+ properties:
+ id:
+ type: string
+ description: The lab result's id.
+ date:
+ type: string
+ description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under.
+ value:
+ type: number
+ description: In the series' `unit`.
+ sourceValue:
+ description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision
+ `value` is shown with. Null for a reading stored as stated.
+ type:
+ - number
+ - "null"
+ rangeStatus:
+ type: string
+ enum:
+ - in-range
+ - below
+ - above
+ - unknown
+ description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.
+ referenceLow:
+ description: "The window this reading is judged against, in the series' unit: the one its report printed, else the
+ marker's own."
+ type:
+ - number
+ - "null"
+ referenceHigh:
+ type:
+ - number
+ - "null"
+ report:
+ anyOf:
+ - type: object
+ properties:
+ id:
+ type: string
+ title:
+ description: The document's title, else its file name.
+ type:
+ - string
+ - "null"
+ href:
+ type: string
+ required:
+ - id
+ - title
+ - href
+ additionalProperties: false
+ - type: "null"
+ description: The document the reading was read from, when one is linked and the caller may see documents.
+ required:
+ - id
+ - date
+ - value
+ - sourceValue
+ - rangeStatus
+ - referenceLow
+ - referenceHigh
+ - report
+ additionalProperties: false
MergeBiomarkersResponse:
type: object
properties:
@@ -93687,131 +93948,6 @@ components:
- unit
- points
additionalProperties: false
- TimelineLabSeries:
- type: object
- properties:
- key:
- type: string
- description: "`lab:`, as named in `values`."
- biomarkerId:
- type: string
- name:
- type: string
- description: The marker's name, as the labs list shows it.
- panel:
- type:
- - string
- - "null"
- unit:
- type: string
- description: The marker's own unit; every point is in it.
- band:
- anyOf:
- - type: object
- properties:
- low:
- type:
- - number
- - "null"
- high:
- type:
- - number
- - "null"
- required:
- - low
- - high
- additionalProperties: false
- - type: "null"
- description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest
- reading in the window. Null when neither is known."
- points:
- type: array
- items:
- $ref: "#/components/schemas/TimelineLabPoint"
- description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A
- day with two readings has two points."
- excluded:
- type: integer
- minimum: 0
- maximum: 9007199254740991
- description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points`
- rather than drawn on a scale they are not in.
- required:
- - key
- - biomarkerId
- - name
- - panel
- - unit
- - band
- - points
- - excluded
- additionalProperties: false
- TimelineLabPoint:
- type: object
- properties:
- id:
- type: string
- description: The lab result's id.
- date:
- type: string
- description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under.
- value:
- type: number
- description: In the series' `unit`.
- sourceValue:
- description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision
- `value` is shown with. Null for a reading stored as stated.
- type:
- - number
- - "null"
- rangeStatus:
- type: string
- enum:
- - in-range
- - below
- - above
- - unknown
- description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.
- referenceLow:
- description: "The window this reading is judged against, in the series' unit: the one its report printed, else the
- marker's own."
- type:
- - number
- - "null"
- referenceHigh:
- type:
- - number
- - "null"
- report:
- anyOf:
- - type: object
- properties:
- id:
- type: string
- title:
- description: The document's title, else its file name.
- type:
- - string
- - "null"
- href:
- type: string
- required:
- - id
- - title
- - href
- additionalProperties: false
- - type: "null"
- description: The document the reading was read from, when one is linked and the caller may see documents.
- required:
- - id
- - date
- - value
- - sourceValue
- - rangeStatus
- - referenceLow
- - referenceHigh
- - report
- additionalProperties: false
TimelineLabMarker:
type: object
properties:
diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts
index 70ccac8ca..0d4cbf032 100644
--- a/src/__tests__/sharing-surface-guard.test.ts
+++ b/src/__tests__/sharing-surface-guard.test.ts
@@ -940,6 +940,10 @@ const DELEGABLE_ROUTES: Record = {
domain: "labs",
why: "One biomarker of the record, fetch-then-guard against the resolved user.",
},
+ "app/api/biomarkers/[id]/series/route.ts": {
+ domain: "labs",
+ why: "One marker's lab line (#1342), the timeline's `labSeries` for that marker: the marker is looked up under the resolved user, and the report behind a point is named only when the grant also covers documents. Read only.",
+ },
"app/api/biomarkers/[id]/merge/route.ts": {
domain: "labs",
why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.",
@@ -2292,7 +2296,7 @@ const ACTOR_ROUTES: Record = {
* manage literal: it was refused under a switch while the medication's PUT,
* which used to carry the end, admitted a manager. 272 -> 274.
*/
-const FROZEN_ENTRY_COUNT = 275;
+const FROZEN_ENTRY_COUNT = 276;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
diff --git a/src/app/api/biomarkers/[id]/series/route.ts b/src/app/api/biomarkers/[id]/series/route.ts
new file mode 100644
index 000000000..e472bbb53
--- /dev/null
+++ b/src/app/api/biomarkers/[id]/series/route.ts
@@ -0,0 +1,98 @@
+/**
+ * `GET /api/biomarkers/{id}/series?zoom=&from=&to=` — one lab marker's line
+ * (#1342).
+ *
+ * The same line `labSeries` on `/api/timeline` draws for the marker, by the
+ * same zoom and window rules, for a client that shows it on the Labs screen:
+ * a lab line belongs to Labs, so it is gated on the `labs` module rather
+ * than on the opt-in `timeline`. Admission is the labs read every other
+ * biomarker read declares; a delegate's grant must cover labs, and the
+ * report behind a point is named only when it also covers documents.
+ * Cross-user ids answer 404, and a merged marker names where it went, as
+ * `GET /api/biomarkers/{id}` does.
+ */
+import type { NextRequest } from "next/server";
+
+import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
+import { labSeriesQuerySchema } from "@/lib/day/wire-schemas";
+import { prisma } from "@/lib/db";
+import { loadBiomarkerSeries } from "@/lib/labs/biomarker-series";
+import { mergedInto } from "@/lib/labs/biomarker-merge";
+import { annotate } from "@/lib/logging/context";
+import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
+import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
+import { resolveUserTimezone } from "@/lib/tz/resolver";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+export const GET = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user, grantId } = await requireRecordAuth("read", "labs");
+ const gate = await requireModuleEnabled(user.id, "labs");
+ if (!gate.enabled) return gate.response;
+ const { id } = await params;
+
+ const query = new URL(request.url).searchParams;
+ const parsed = labSeriesQuerySchema.safeParse({
+ zoom: query.get("zoom") ?? undefined,
+ from: query.get("from") ?? undefined,
+ to: query.get("to") ?? undefined,
+ });
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "labs.series.invalid",
+ });
+ }
+ if (
+ parsed.data.from &&
+ parsed.data.to &&
+ parsed.data.from > parsed.data.to
+ ) {
+ return apiError("`from` must not be after `to`", 422, {
+ errorCode: "labs.series.invalid",
+ });
+ }
+
+ const marker = await prisma.biomarker.findFirst({
+ where: { id, userId: user.id },
+ select: { id: true },
+ });
+ if (!marker) {
+ const into = await mergedInto(user.id, id);
+ return apiError("Biomarker not found", 404, {
+ errorCode: "labs.biomarker.notFound",
+ ...(into ? { mergedInto: into } : {}),
+ });
+ }
+
+ const [modules, domainVisible, tz] = await Promise.all([
+ resolveModuleMap(user.id),
+ actingDomainVisibility(prisma, grantId),
+ resolveUserTimezone(user.id),
+ ]);
+ const result = await loadBiomarkerSeries({
+ recordId: user.id,
+ biomarkerId: id,
+ query: parsed.data,
+ access: { modules, domainVisible, owner: grantId === null },
+ tz,
+ });
+ if (!result) {
+ return apiError("Biomarker not found", 404, {
+ errorCode: "labs.biomarker.notFound",
+ });
+ }
+
+ annotate({
+ action: { name: "labs.biomarker.series" },
+ meta: {
+ biomarkerId: id,
+ zoom: result.zoom,
+ points: result.series.points.length,
+ excluded: result.series.excluded,
+ },
+ });
+ return apiSuccess(result);
+ },
+);
diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts
index eba8ef32c..fde86360f 100644
--- a/src/lib/day/wire-schemas.ts
+++ b/src/lib/day/wire-schemas.ts
@@ -356,43 +356,69 @@ export const dayNotableResponseSchema = z
/* ─── GET /api/timeline ───────────────────────────────────────────────────── */
-export const timelineQuerySchema = z
- .object({
- zoom: z
- .enum(TIMELINE_ZOOMS)
- .default("all")
- .describe(
- "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.",
- ),
- from: dateKeySchema.optional(),
- to: dateKeySchema.optional(),
+const timelineWindowShape = {
+ zoom: z
+ .enum(TIMELINE_ZOOMS)
+ .default("all")
+ .describe(
+ "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.",
+ ),
+ from: dateKeySchema.optional(),
+ to: dateKeySchema.optional(),
+};
+
+/** The `range` rules every timeline-shaped window shares. */
+function withWindowRules<
+ S extends z.ZodType<{ zoom: string; from?: string; to?: string }>,
+>(schema: S): S {
+ return schema
+ .refine((q) => q.zoom !== "range" || (q.from && q.to), {
+ message: "`range` needs both `from` and `to`",
+ path: ["from"],
+ })
+ .refine(
+ (q) =>
+ q.zoom !== "range" ||
+ !q.from ||
+ !q.to ||
+ // Both ends as the same instant of their calendar day, so the
+ // difference counts calendar days whatever the process zone.
+ (dateOnlyAtNoonUtc(q.to).getTime() -
+ dateOnlyAtNoonUtc(q.from).getTime()) /
+ 86_400_000 <
+ TIMELINE_RANGE_MAX_DAYS,
+ {
+ message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`,
+ path: ["to"],
+ },
+ );
+}
+
+export const timelineQuerySchema = withWindowRules(
+ z.object({
+ ...timelineWindowShape,
values: z
.string()
.optional()
.describe(
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out of `labSeries`, so a stored choice never breaks the page.",
),
- })
- .refine((q) => q.zoom !== "range" || (q.from && q.to), {
- message: "`range` needs both `from` and `to`",
- path: ["from"],
- })
- .refine(
- (q) =>
- q.zoom !== "range" ||
- !q.from ||
- !q.to ||
- // Both ends as the same instant of their calendar day, so the
- // difference counts calendar days whatever the process zone.
- (dateOnlyAtNoonUtc(q.to).getTime() -
- dateOnlyAtNoonUtc(q.from).getTime()) /
- 86_400_000 <
- TIMELINE_RANGE_MAX_DAYS,
- {
- message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`,
- path: ["to"],
- },
- );
+ }),
+);
+
+/**
+ * The window of `GET /api/biomarkers/{id}/series` (#1342): the timeline's
+ * own zoom, `from` and `to`, under the same rules, so one marker's line is
+ * cut exactly as `labSeries` on the timeline cuts it.
+ */
+export const labSeriesQuerySchema = withWindowRules(
+ z.object({
+ ...timelineWindowShape,
+ zoom: timelineWindowShape.zoom.describe(
+ "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to` (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points are never averaged, whatever the zoom.",
+ ),
+ }),
+);
const timelineItem = z
.object({
@@ -509,7 +535,7 @@ const timelineLabPoint = z
})
.meta({ id: "TimelineLabPoint" });
-const timelineLabSeries = z
+export const timelineLabSeries = z
.object({
key: z.string().describe("`lab:`, as named in `values`."),
biomarkerId: z.string(),
diff --git a/src/lib/labs/biomarker-series.ts b/src/lib/labs/biomarker-series.ts
new file mode 100644
index 000000000..c4df87d79
--- /dev/null
+++ b/src/lib/labs/biomarker-series.ts
@@ -0,0 +1,61 @@
+/**
+ * One lab marker's line outside the timeline (#1342).
+ *
+ * `GET /api/biomarkers/{id}/series` answers what `labSeries` on
+ * `/api/timeline` answers for that one marker, under the labs module rather
+ * than the opt-in timeline. Nothing is re-implemented here: the window is
+ * the timeline's own (`resolveTimelineWindow`), the line is `loadLabSeries`,
+ * so the points, the unit handling, the band and the `excluded` count are
+ * the timeline's to the byte. The only difference is where `all` starts:
+ * at the marker's first numeric reading, since this line shows nothing else.
+ */
+import type { TimelineLabSeries, TimelineZoom } from "@/lib/day/contract";
+import { prisma } from "@/lib/db";
+import type { TimelineAccess } from "@/lib/timeline/lanes";
+import { loadLabSeries } from "@/lib/timeline/lab-series";
+import { resolveTimelineWindow } from "@/lib/timeline/load-timeline";
+import { userDayKey } from "@/lib/tz/format";
+
+export interface BiomarkerSeries {
+ zoom: TimelineZoom;
+ range: { from: string; to: string; dataFrom: string | null };
+ series: TimelineLabSeries;
+}
+
+/**
+ * The line of `biomarkerId` in the record, or null when the record has no
+ * such marker (or the caller may not see labs).
+ */
+export async function loadBiomarkerSeries(args: {
+ recordId: string;
+ biomarkerId: string;
+ query: { zoom: TimelineZoom; from?: string; to?: string };
+ access: TimelineAccess;
+ tz: string;
+ now?: Date;
+}): Promise {
+ const { recordId, biomarkerId, query, access, tz } = args;
+ const first = await prisma.labResult.findFirst({
+ where: {
+ userId: recordId,
+ biomarkerId,
+ deletedAt: null,
+ value: { not: null },
+ },
+ orderBy: { takenAt: "asc" },
+ select: { takenAt: true },
+ });
+ const dataFrom = first ? userDayKey(first.takenAt, tz) : null;
+ const today = userDayKey(args.now ?? new Date(), tz);
+ const { from, to } = resolveTimelineWindow(query, today, dataFrom);
+ const [series] = await loadLabSeries({
+ recordId,
+ ids: [biomarkerId],
+ from,
+ to,
+ tz,
+ access,
+ });
+ if (!series) return null;
+ return { zoom: query.zoom, range: { from, to, dataFrom }, series };
+}
diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts
index 3b2ceb3f2..4867385e8 100644
--- a/src/lib/openapi/error-codes.ts
+++ b/src/lib/openapi/error-codes.ts
@@ -273,6 +273,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> =
"labs.biomarker.update.invalid",
"labs.biomarker.update.referenceRangeInvalid",
"labs.create.invalid",
+ "labs.series.invalid",
"labs.link.invalid",
"labs.ocr.budgetExceeded",
"labs.ocr.extractFailed",
diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts
index 893724720..1e611356b 100644
--- a/src/lib/openapi/routes/biomarkers.ts
+++ b/src/lib/openapi/routes/biomarkers.ts
@@ -15,6 +15,12 @@ import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
+import {
+ dateKeySchema,
+ labSeriesQuerySchema,
+ timelineLabSeries,
+} from "@/lib/day/wire-schemas";
+import { TIMELINE_ZOOMS } from "@/lib/day/contract";
import {
createBiomarkerSchema,
@@ -173,6 +179,22 @@ const notFound = {
},
};
+const biomarkerSeries = z
+ .object({
+ zoom: z.enum(TIMELINE_ZOOMS),
+ range: z.object({
+ from: dateKeySchema,
+ to: dateKeySchema,
+ dataFrom: dateKeySchema
+ .nullable()
+ .describe(
+ "The local day of the marker's first numeric reading; null when it has none. `all` starts here.",
+ ),
+ }),
+ series: timelineLabSeries,
+ })
+ .meta({ id: "BiomarkerSeries" });
+
const biomarkerMergeRequest = biomarkerMergeSchema.meta({
id: "BiomarkerMergeRequest",
description:
@@ -325,6 +347,38 @@ export const biomarkerPaths: NonNullable = {
},
},
},
+ "/api/biomarkers/{id}/series": {
+ get: {
+ tags: ["Labs"],
+ summary: "One marker's lab line",
+ description:
+ "The line `labSeries` on `GET /api/timeline` draws for this marker, cut by the same `zoom`, `from` and `to` rules: every numeric reading in the window, oldest first, one point each (never averaged), readings in a unit that does not convert counted in `excluded`, and the band to draw behind them. `all` starts at the marker's first reading. Gated on the `labs` module, not on `timeline`. A point names the report it was read from only when the caller may see documents. Cross-user ids answer 404; a merged marker answers 404 with `meta.mergedInto`.",
+ requestParams: {
+ path: z.object({ id: z.string() }),
+ query: labSeriesQuerySchema,
+ },
+ responses: {
+ ...recordRefusal(
+ "`module.disabled` with `meta.module = labs`: the record has the labs module switched off, or the operator turned it off server-wide.",
+ ),
+ "200": {
+ description: "The marker's line over the window.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(biomarkerSeries, "BiomarkerSeriesEnvelope"),
+ },
+ },
+ },
+ ...notFound,
+ ...stdResponses,
+ "422": {
+ description:
+ "`labs.series.invalid`: an unknown `zoom`, a malformed date, a `range` without both ends or longer than 15 years, or `from` after `to`.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+ },
+ },
+ },
"/api/biomarkers/{id}/merge": {
post: {
tags: ["Labs"],
diff --git a/src/lib/timeline/__tests__/resolve-window.test.ts b/src/lib/timeline/__tests__/resolve-window.test.ts
new file mode 100644
index 000000000..412ce0bec
--- /dev/null
+++ b/src/lib/timeline/__tests__/resolve-window.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveTimelineWindow } from "../load-timeline";
+
+describe("resolveTimelineWindow", () => {
+ const today = "2026-11-04";
+
+ it("starts `all` at the first day with data, or a year back without any", () => {
+ expect(resolveTimelineWindow({ zoom: "all" }, today, "2019-03-02")).toEqual(
+ { from: "2019-03-02", to: today },
+ );
+ expect(resolveTimelineWindow({ zoom: "all" }, today, null)).toEqual({
+ from: "2025-11-05",
+ to: today,
+ });
+ // Data that starts after `to` does not invert the window.
+ expect(
+ resolveTimelineWindow(
+ { zoom: "all", to: "2020-01-01" },
+ today,
+ "2021-01-01",
+ ),
+ ).toEqual({ from: "2019-01-02", to: "2020-01-01" });
+ });
+
+ it("reaches back the fixed span for `year` and `quarter`", () => {
+ expect(
+ resolveTimelineWindow({ zoom: "year" }, today, "2001-01-01"),
+ ).toEqual({ from: "2025-11-05", to: today });
+ expect(
+ resolveTimelineWindow({ zoom: "quarter" }, today, "2001-01-01"),
+ ).toEqual({ from: "2026-08-06", to: today });
+ });
+
+ it("keeps a chosen `range` exactly", () => {
+ expect(
+ resolveTimelineWindow(
+ { zoom: "range", from: "2024-02-01", to: "2024-03-01" },
+ today,
+ "2001-01-01",
+ ),
+ ).toEqual({ from: "2024-02-01", to: "2024-03-01" });
+ });
+});
diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts
index 8de5bc5c1..de0d49f63 100644
--- a/src/lib/timeline/load-timeline.ts
+++ b/src/lib/timeline/load-timeline.ts
@@ -106,6 +106,30 @@ const ZOOM_DAYS: Readonly<
quarter: 91,
};
+/**
+ * The `[from, to]` a zoom reads, as local day keys. `to` defaults to today;
+ * `all` (and a `range`, which names both ends) starts at `dataFrom`, the
+ * earliest day there is anything to show, or a year back when there is
+ * nothing; `year` and `quarter` reach back their fixed span from `to`. The
+ * timeline and one marker's lab line (`/api/biomarkers/{id}/series`, #1342)
+ * both cut their window here.
+ */
+export function resolveTimelineWindow(
+ query: { zoom: TimelineZoom; from?: string; to?: string },
+ today: string,
+ dataFrom: string | null,
+): { from: string; to: string } {
+ const to = query.to ?? today;
+ const from =
+ query.from ??
+ (query.zoom === "all" || query.zoom === "range"
+ ? dataFrom !== null && dataFrom < to
+ ? dataFrom
+ : shiftDateKey(to, -364)
+ : shiftDateKey(to, -(ZOOM_DAYS[query.zoom] - 1)));
+ return { from, to };
+}
+
const MEASUREMENT_TYPE_SET: ReadonlySet = new Set(
Object.values(MeasurementTypes),
);
@@ -241,14 +265,7 @@ export async function loadTimeline(args: {
if (firstReading) starts.push(userDayKey(firstReading.measuredAt, tz));
const dataFrom = starts.length > 0 ? starts.sort()[0] : null;
- const to = query.to ?? today;
- const from =
- query.from ??
- (query.zoom === "all" || query.zoom === "range"
- ? dataFrom !== null && dataFrom < to
- ? dataFrom
- : shiftDateKey(to, -364)
- : shiftDateKey(to, -(ZOOM_DAYS[query.zoom] - 1)));
+ const { from, to } = resolveTimelineWindow(query, today, dataFrom);
const lanes: TimelineResponse["lanes"] = [];
const standing: StandingItem[] = [];
diff --git a/tests/integration/biomarker-series.test.ts b/tests/integration/biomarker-series.test.ts
new file mode 100644
index 000000000..2ba76c6bc
--- /dev/null
+++ b/tests/integration/biomarker-series.test.ts
@@ -0,0 +1,191 @@
+/**
+ * `GET /api/biomarkers/{id}/series` (#1342), against Postgres.
+ *
+ * The marker's line must be the timeline's `labSeries` for that marker to
+ * the byte, under the same window, because both come from one loader; it
+ * must answer with the labs module on and the timeline off (the reason the
+ * route exists), refuse with labs off, and seal another record's marker.
+ * Every call goes through the contract check. All data invented.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { GET as getSeries } from "@/app/api/biomarkers/[id]/series/route";
+import { GET as getTimeline } from "@/app/api/timeline/route";
+import type { TimelineLabSeries, TimelineResponse } from "@/lib/day/contract";
+
+import { callRoute, type RouteAuth } from "../kit/call-route";
+import { makeSession, makeUser } from "../kit/factories";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+const prisma = getPrismaClient();
+const DAY_MS = 86_400_000;
+
+function daysAgo(n: number): string {
+ return new Date(Date.now() - n * DAY_MS).toISOString().slice(0, 10);
+}
+
+interface SeriesBody {
+ zoom: string;
+ range: { from: string; to: string; dataFrom: string | null };
+ series: TimelineLabSeries;
+}
+
+async function seedFerritin(userId: string) {
+ const marker = await prisma.biomarker.create({
+ data: {
+ userId,
+ name: "Ferritin",
+ unit: "ng/mL",
+ lowerBound: 30,
+ upperBound: 400,
+ panel: "Iron",
+ analyteKey: "ferritin",
+ },
+ });
+ const at = (day: string, value: number, unit = "ng/mL") =>
+ prisma.labResult.create({
+ data: {
+ userId,
+ biomarkerId: marker.id,
+ analyte: "Ferritin",
+ value,
+ unit,
+ takenAt: new Date(`${day}T12:00:00.000Z`),
+ },
+ });
+ await at(daysAgo(900), 18);
+ await at(daysAgo(500), 85);
+ await at(daysAgo(60), 120);
+ // A unit that does not convert into ng/mL: counted, not drawn.
+ await at(daysAgo(59), 260, "pmol/L");
+ return marker;
+}
+
+async function series(
+ auth: RouteAuth,
+ id: string,
+ query: Record = {},
+) {
+ return callRoute(getSeries, {
+ method: "GET",
+ route: "/api/biomarkers/{id}/series",
+ params: { id },
+ query,
+ auth,
+ });
+}
+
+beforeEach(async () => {
+ await truncateAllTables(prisma);
+});
+
+describe("one marker's lab line", () => {
+ it("is the timeline's labSeries for the marker, window for window", async () => {
+ const user = await makeUser({
+ timezone: "Europe/Berlin",
+ modulePreferencesJson: { timeline: true, labs: true },
+ });
+ const owner = await makeSession(user);
+ const marker = await seedFerritin(user.id);
+
+ const windows: Array> = [
+ { zoom: "range", from: daysAgo(1000), to: daysAgo(1) },
+ { zoom: "year" },
+ { zoom: "quarter" },
+ ];
+ for (const query of windows) {
+ const timeline = await callRoute(getTimeline, {
+ method: "GET",
+ route: "/api/timeline",
+ query: { ...query, values: `lab:${marker.id}` },
+ auth: owner,
+ });
+ expect(timeline.status).toBe(200);
+ const res = await series(owner, marker.id, query);
+ expect(res.status, query.zoom).toBe(200);
+ expect(res.data.series, query.zoom).toEqual(timeline.data.labSeries[0]);
+ expect(res.data.range.from, query.zoom).toBe(timeline.data.range.from);
+ expect(res.data.range.to, query.zoom).toBe(timeline.data.range.to);
+ }
+
+ const year = await series(owner, marker.id, { zoom: "year" });
+ expect(year.data.series.points.map((p) => p.value)).toEqual([120]);
+ expect(year.data.series.excluded).toBe(1);
+ });
+
+ it("starts `all` at the marker's first reading and draws every one", async () => {
+ const user = await makeUser({
+ timezone: "Europe/Berlin",
+ modulePreferencesJson: { labs: true },
+ });
+ const owner = await makeSession(user);
+ const marker = await seedFerritin(user.id);
+
+ const res = await series(owner, marker.id);
+ expect(res.status).toBe(200);
+ expect(res.data.zoom).toBe("all");
+ expect(res.data.range.dataFrom).toBe(daysAgo(900));
+ expect(res.data.range.from).toBe(daysAgo(900));
+ expect(res.data.series.points.map((p) => p.value)).toEqual([18, 85, 120]);
+ expect(res.data.series.band).toEqual({ low: 30, high: 400 });
+ });
+
+ it("answers with the timeline off, and refuses with labs off", async () => {
+ const user = await makeUser({
+ timezone: "Europe/Berlin",
+ modulePreferencesJson: { timeline: false, labs: true },
+ });
+ const owner = await makeSession(user);
+ const marker = await seedFerritin(user.id);
+
+ const timeline = await callRoute(getTimeline, {
+ method: "GET",
+ route: "/api/timeline",
+ query: { values: `lab:${marker.id}` },
+ auth: owner,
+ });
+ expect(timeline.status).toBe(403);
+ expect((await series(owner, marker.id)).status).toBe(200);
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { modulePreferencesJson: { timeline: false, labs: false } },
+ });
+ const off = await series(owner, marker.id);
+ expect(off.status).toBe(403);
+ expect(off.meta).toMatchObject({
+ errorCode: "module.disabled",
+ module: "labs",
+ });
+ });
+
+ it("seals another record's marker and refuses a malformed window", async () => {
+ const user = await makeUser({ modulePreferencesJson: { labs: true } });
+ const other = await makeUser({ modulePreferencesJson: { labs: true } });
+ const owner = await makeSession(user);
+ const theirs = await seedFerritin(other.id);
+ const mine = await seedFerritin(user.id);
+
+ const sealed = await series(owner, theirs.id);
+ expect(sealed.status).toBe(404);
+ expect(sealed.meta).toMatchObject({
+ errorCode: "labs.biomarker.notFound",
+ });
+ expect((await series(owner, "does-not-exist")).status).toBe(404);
+
+ const noEnd = await series(owner, mine.id, {
+ zoom: "range",
+ from: daysAgo(10),
+ });
+ expect(noEnd.status).toBe(422);
+ const backwards = await series(owner, mine.id, {
+ zoom: "range",
+ from: daysAgo(1),
+ to: daysAgo(10),
+ });
+ expect(backwards.status).toBe(422);
+ expect(backwards.meta).toMatchObject({
+ errorCode: "labs.series.invalid",
+ });
+ });
+});
From 148af765c93715ae069ea07aceaed7b3c94d4b2f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 22:02:48 +0100
Subject: [PATCH 10/55] fix(cycle): open cycles from Garmin and Oura period
days
Both wearables wrote their period days with a bare create and so skipped
the seam every other cycle writer goes through: a first bleeding day never
opened its cycle and no day was attributed to one. An account whose
periods came from a watch or a ring held day-logs only, with no cycle
start, no lengths and no forecast.
The create now goes through one shared helper that keeps the
create-only-if-absent rule and then runs the same attribution and
`ensureCycleForBleedingDay` inference as the shared upsert. The hourly
legs and the newest-first Garmin backfill both use it; a finished Garmin
period also stamps its last day on a cycle that has no period end yet.
Rows imported before this change are repaired by a one-shot job sent from
boot discovery while candidates exist. It opens or attributes cycles
oldest first per account, touches no day-log content, and converges to an
empty candidate set.
Refs #1333
---
CHANGELOG.md | 6 +
src/lib/cycle/subordinate-period-days.ts | 113 ++++++++
src/lib/garmin/__tests__/map.test.ts | 23 +-
src/lib/garmin/cycle-sync.ts | 75 ++---
.../__tests__/queue-policy-tables.test.ts | 12 +-
src/lib/jobs/garmin-backfill.ts | 4 +-
src/lib/jobs/queue-runtime.ts | 3 +
.../jobs/registry/maintenance-discovery.ts | 9 +
src/lib/jobs/registry/maintenance-repairs.ts | 14 +
src/lib/jobs/subsystem-surface.ts | 1 +
src/lib/jobs/wearable-cycle-repair-queue.ts | 9 +
src/lib/jobs/wearable-cycle-repair.ts | 271 ++++++++++++++++++
src/lib/oura/__tests__/cycle-sync.test.ts | 63 +++-
src/lib/oura/cycle-sync.ts | 74 +----
.../__golden__/queue-registration.json | 24 +-
tests/integration/garmin-sync.test.ts | 131 +++++++++
.../integration/wearable-cycle-repair.test.ts | 200 +++++++++++++
17 files changed, 901 insertions(+), 131 deletions(-)
create mode 100644 src/lib/cycle/subordinate-period-days.ts
create mode 100644 src/lib/jobs/wearable-cycle-repair-queue.ts
create mode 100644 src/lib/jobs/wearable-cycle-repair.ts
create mode 100644 tests/integration/wearable-cycle-repair.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97946122c..45d96f795 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,12 @@
### Fixed
+- **Periods from Garmin and Oura open their cycles.** Period days imported
+ from a watch or a ring were stored as days only, so the cycle tracker saw
+ no cycle start, no lengths and no forecast. They now open and fill their
+ cycles like a period logged by hand, a finished Garmin period also marks
+ its last day, and periods imported earlier are repaired once on the next
+ start. A day you logged yourself is never changed. (#1333)
- **Typing into the smoking history.** "When you stopped" only took text
that was already a whole YYYY-MM month, so every keystroke was dropped
and only a paste got in. The start year clamped each keystroke into its
diff --git a/src/lib/cycle/subordinate-period-days.ts b/src/lib/cycle/subordinate-period-days.ts
new file mode 100644
index 000000000..569451520
--- /dev/null
+++ b/src/lib/cycle/subordinate-period-days.ts
@@ -0,0 +1,113 @@
+/**
+ * Period days a wearable reports (Garmin, Oura), written as a SUBORDINATE
+ * source.
+ *
+ * `CycleDayLog` holds one canonical row per `(userId, date)`, and the shared
+ * upsert (`./day-log-write.ts`) partial-merges a re-post over whatever is
+ * stored regardless of source. An hourly poll going through it would demote a
+ * day the person logged themselves (their HEAVY re-tagged LIGHT on the next
+ * tick). So a wearable day is only ever CREATED, when no row exists for that
+ * date at all, and never updated.
+ *
+ * What the shared upsert does beyond the row itself happens here too, through
+ * the same functions: a created day is attributed to the cycle it falls in,
+ * and a first bleeding day opens its cycle (`ensureCycleForBleedingDay`),
+ * which closes the cycle before it and takes its own end from the one after
+ * it. Without that a period imported from a watch was a run of rows the
+ * engine never read: no cycle start, no lengths, no forecast.
+ */
+import { prisma } from "@/lib/db";
+import { getEvent } from "@/lib/logging/context";
+import type { FlowLevel } from "@/generated/prisma/client";
+
+import { ensureCycleForBleedingDay } from "./cycle-boundaries";
+import { findOwningCycleId } from "./cycle-attribution";
+
+/** The wearables that write period days under this rule. */
+export type SubordinateCycleSource = "GARMIN" | "OURA";
+
+/**
+ * The conservative "logged, intensity unknown" level: a detected bleeding day
+ * is at least light, never invented as MEDIUM or HEAVY.
+ */
+export const SUBORDINATE_PERIOD_FLOW: FlowLevel = "LIGHT";
+
+function isUniqueViolation(err: unknown): boolean {
+ return (
+ typeof err === "object" &&
+ err !== null &&
+ "code" in err &&
+ (err as { code?: unknown }).code === "P2002"
+ );
+}
+
+/**
+ * Create the period days no row covers yet, oldest first, each attributed to
+ * its cycle, and open the cycle a first bleeding day starts. Returns how many
+ * rows were created.
+ */
+export async function createSubordinatePeriodDays(
+ userId: string,
+ source: SubordinateCycleSource,
+ days: readonly string[],
+): Promise {
+ if (days.length === 0) return 0;
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { timezone: true },
+ });
+ const tz = user?.timezone ?? null;
+ let created = 0;
+ for (const day of [...new Set(days)].sort()) {
+ try {
+ await prisma.cycleDayLog.create({
+ data: {
+ userId,
+ date: day,
+ tz,
+ source,
+ flow: SUBORDINATE_PERIOD_FLOW,
+ cycleId: await findOwningCycleId(userId, day),
+ },
+ });
+ } catch (err) {
+ // A row already exists: the person's own, an import's, or this
+ // source's earlier write. A wearable never overwrites.
+ if (isUniqueViolation(err)) continue;
+ getEvent()?.addWarning(
+ `${source.toLowerCase()}: cycle day-log create failed for ${userId} ${day}: ${err}`,
+ );
+ continue;
+ }
+ created += 1;
+ // After the create, so the opened cycle claims the row just written.
+ await ensureCycleForBleedingDay(userId, day, tz, {
+ flow: SUBORDINATE_PERIOD_FLOW,
+ intermenstrualBleeding: false,
+ });
+ }
+ return created;
+}
+
+/**
+ * Stamp the last day of a finished period on the cycle that opens at
+ * `start`, when that cycle has no period end yet. A person's own end, or one
+ * an earlier pass stamped, stays. Returns whether a stamp was written.
+ */
+export async function stampSubordinatePeriodEnd(
+ userId: string,
+ start: string,
+ lastDay: string,
+): Promise {
+ if (lastDay < start) return false;
+ const stamped = await prisma.menstrualCycle.updateMany({
+ where: {
+ userId,
+ startDate: start,
+ deletedAt: null,
+ periodEndDate: null,
+ },
+ data: { periodEndDate: lastDay, syncVersion: { increment: 1 } },
+ });
+ return stamped.count > 0;
+}
diff --git a/src/lib/garmin/__tests__/map.test.ts b/src/lib/garmin/__tests__/map.test.ts
index 6a01d2e10..83050b5ee 100644
--- a/src/lib/garmin/__tests__/map.test.ts
+++ b/src/lib/garmin/__tests__/map.test.ts
@@ -370,13 +370,24 @@ describe("cycle summary", () => {
"2026-10-13",
"2026-10-14",
],
+ ended: true,
});
- expect(
- periodFromGarminCycleSummary(
- { startDate: "2026-11-03", periodLength: 5 },
- "2026-11-04",
- )?.days,
- ).toEqual(["2026-11-03", "2026-11-04"]);
+ const ongoing = periodFromGarminCycleSummary(
+ { startDate: "2026-11-03", periodLength: 5 },
+ "2026-11-04",
+ );
+ expect(ongoing?.days).toEqual(["2026-11-03", "2026-11-04"]);
+ expect(ongoing?.ended).toBe(false);
+ });
+
+ it("calls a period ended only once its last day is past", () => {
+ const summary = { startDate: "2026-11-01", periodLength: 4 };
+ expect(periodFromGarminCycleSummary(summary, "2026-11-04")?.ended).toBe(
+ false,
+ );
+ expect(periodFromGarminCycleSummary(summary, "2026-11-05")?.ended).toBe(
+ true,
+ );
});
it("imports nothing from a predicted cycle or a pregnancy", () => {
diff --git a/src/lib/garmin/cycle-sync.ts b/src/lib/garmin/cycle-sync.ts
index b76c78f21..a826847e0 100644
--- a/src/lib/garmin/cycle-sync.ts
+++ b/src/lib/garmin/cycle-sync.ts
@@ -4,7 +4,10 @@
* import (`src/lib/oura/cycle-sync.ts`): a day is only ever CREATED when no
* `CycleDayLog` row exists for it at all (any source), and never updated, so
* a person's own entry always wins and an hourly poll can never demote it.
- * No `MenstrualCycle` span is created; cycle spans stay an explicit action.
+ * The write goes through `createSubordinatePeriodDays`, which attributes each
+ * created day to its cycle and opens the cycle a first bleeding day starts,
+ * as every other cycle writer does. A finished period also stamps its last
+ * day on that cycle, when the cycle has no period end yet.
*
* `GET /periodichealth-service/menstrualcycle/summary/{date}` describes the
* cycle a date falls in: its start and its period length. A cycle Garmin
@@ -16,15 +19,15 @@
* Gated behind the cycle module (`isCycleAvailableForUser`), checked before
* Garmin is asked, so an account with cycle tracking off costs no request.
*/
-import { prisma } from "@/lib/db";
-import { getEvent } from "@/lib/logging/context";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
+import {
+ createSubordinatePeriodDays,
+ stampSubordinatePeriodEnd,
+} from "@/lib/cycle/subordinate-period-days";
import { GARMIN_PATHS, shiftGarminDay, type GarminSession } from "./client";
import type { GarminCycleDate, GarminCycleSummary } from "./wire-types";
-const GARMIN_PERIOD_FLOW = "LIGHT" as const;
-
/** The longest period a single summary may claim before it is not believed. */
const MAX_PERIOD_DAYS = 14;
@@ -53,6 +56,8 @@ export interface GarminCyclePeriod {
start: string;
/** The period's days, oldest first, none after `today`. */
days: string[];
+ /** Every day of the period lies before `today`: its last day is known. */
+ ended: boolean;
}
/** The logged period a summary describes, or null when there is none. */
@@ -67,57 +72,33 @@ export function periodFromGarminCycleSummary(
const length = summary.periodLength;
if (typeof length !== "number" || !Number.isFinite(length)) return null;
const n = Math.round(length);
- if (n < 1 || n > MAX_PERIOD_DAYS) return { start, days: [] };
+ if (n < 1 || n > MAX_PERIOD_DAYS) return { start, days: [], ended: false };
const days: string[] = [];
for (let i = 0; i < n; i++) {
const day = shiftGarminDay(start, i);
if (day > today) break;
days.push(day);
}
- return { start, days };
+ const ended = days.length === n && days[n - 1]! < today;
+ return { start, days, ended };
}
-function isUniqueViolation(err: unknown): boolean {
- return (
- typeof err === "object" &&
- err !== null &&
- "code" in err &&
- (err as { code?: unknown }).code === "P2002"
- );
-}
-
-/** Create the period days no row covers yet. Returns how many were created. */
-export async function writeGarminPeriodDays(
+/**
+ * Write one period: create the days no row covers yet (opening and
+ * attributing its cycle), then stamp the period's end once it is over.
+ * Returns how many days were created.
+ */
+export async function writeGarminPeriod(
userId: string,
- days: readonly string[],
+ period: GarminCyclePeriod,
): Promise {
- if (days.length === 0) return 0;
- const user = await prisma.user.findUnique({
- where: { id: userId },
- select: { timezone: true },
- });
- const tz = user?.timezone ?? null;
- let created = 0;
- for (const day of days) {
- try {
- await prisma.cycleDayLog.create({
- data: {
- userId,
- date: day,
- tz,
- source: "GARMIN",
- flow: GARMIN_PERIOD_FLOW,
- },
- });
- created += 1;
- } catch (err) {
- // A row already exists: the person's own, an import's, or this
- // module's earlier write. Garmin never overwrites.
- if (isUniqueViolation(err)) continue;
- getEvent()?.addWarning(
- `garmin: cycle day-log create failed for ${userId} ${day}: ${err}`,
- );
- }
+ const created = await createSubordinatePeriodDays(
+ userId,
+ "GARMIN",
+ period.days,
+ );
+ if (period.ended) {
+ await stampSubordinatePeriodEnd(userId, period.start, period.days.at(-1)!);
}
return created;
}
@@ -147,5 +128,5 @@ export async function syncGarminCycle(
const summary = await readGarminCycleSummary(session, today);
const period = periodFromGarminCycleSummary(summary, today);
if (!period) return 0;
- return writeGarminPeriodDays(userId, period.days);
+ return writeGarminPeriod(userId, period);
}
diff --git a/src/lib/jobs/__tests__/queue-policy-tables.test.ts b/src/lib/jobs/__tests__/queue-policy-tables.test.ts
index 41c5b2f3d..b3e6004b6 100644
--- a/src/lib/jobs/__tests__/queue-policy-tables.test.ts
+++ b/src/lib/jobs/__tests__/queue-policy-tables.test.ts
@@ -114,12 +114,12 @@ describe("maintenance group queue policies", () => {
// One-shot repairs on a fixed singleton key: a boot during a run must be
// able to queue the next pass, so only queued duplicates collapse.
- it.each([["lab-document-date-repair", "short"]])(
- "%s is %s",
- (queue, policy) => {
- expectPolicy("maintenance", queue, policy);
- },
- );
+ it.each([
+ ["lab-document-date-repair", "short"],
+ ["wearable-cycle-repair", "short"],
+ ])("%s is %s", (queue, policy) => {
+ expectPolicy("maintenance", queue, policy);
+ });
it("environment-fetch is deliberately left on standard", () => {
// Its explicit-range backfill sends with no key at all, on purpose, so any
diff --git a/src/lib/jobs/garmin-backfill.ts b/src/lib/jobs/garmin-backfill.ts
index 7409a03d8..c9b37b029 100644
--- a/src/lib/jobs/garmin-backfill.ts
+++ b/src/lib/jobs/garmin-backfill.ts
@@ -44,7 +44,7 @@ import {
import {
periodFromGarminCycleSummary,
readGarminCycleSummary,
- writeGarminPeriodDays,
+ writeGarminPeriod,
} from "@/lib/garmin/cycle-sync";
import { garminUnavailableCode } from "@/lib/garmin/unavailable";
import {
@@ -212,7 +212,7 @@ async function readStepWindow(
const period = periodFromGarminCycleSummary(summary, progress.anchor);
// No logged cycle at or before this day: the walk is over.
if (!period || period.start > state.before) return null;
- await writeGarminPeriodDays(userId, period.days);
+ await writeGarminPeriod(userId, period);
state.before = shiftDateKey(period.start, -1);
return 0;
}
diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts
index 94b0107d0..3f65887bc 100644
--- a/src/lib/jobs/queue-runtime.ts
+++ b/src/lib/jobs/queue-runtime.ts
@@ -493,6 +493,9 @@ export const QUEUE_RUNTIME: Readonly> = {
"lab-document-date-repair": short(
"Reads the document lab values still filed at their confirmation moment, a handful per account, and moves each with one update.",
),
+ "wearable-cycle-repair": short(
+ "Reads the wearable period days still without a cycle, a few hundred per account at most, and opens or attributes each with a short transaction.",
+ ),
"measurement-maintenance": {
runtime: "long",
expireInSeconds: MEASUREMENT_MAINTENANCE_SEND_OPTIONS.expireInSeconds,
diff --git a/src/lib/jobs/registry/maintenance-discovery.ts b/src/lib/jobs/registry/maintenance-discovery.ts
index 2de44262a..d4dbbe866 100644
--- a/src/lib/jobs/registry/maintenance-discovery.ts
+++ b/src/lib/jobs/registry/maintenance-discovery.ts
@@ -86,6 +86,15 @@ export const MAINTENANCE_DISCOVERY: readonly BootDiscovery[] = [
await import("@/lib/jobs/lab-document-date-repair")
).enqueueBootTimeLabDocumentDateRepair(),
},
+ // Converges to nothing once the wearable period days have their cycles; a
+ // boot with none costs one query.
+ {
+ name: "wearable-cycle-repair",
+ run: async () =>
+ (
+ await import("@/lib/jobs/wearable-cycle-repair")
+ ).enqueueBootTimeWearableCycleRepair(),
+ },
// Self-limiting and idempotent; a boot with no backlog costs one query.
{
name: "compaction-tombstone-purge",
diff --git a/src/lib/jobs/registry/maintenance-repairs.ts b/src/lib/jobs/registry/maintenance-repairs.ts
index 2d3224e86..d76cf7b14 100644
--- a/src/lib/jobs/registry/maintenance-repairs.ts
+++ b/src/lib/jobs/registry/maintenance-repairs.ts
@@ -5,6 +5,7 @@
* something to repair.
*/
import { LAB_DOCUMENT_DATE_REPAIR_QUEUE } from "@/lib/jobs/lab-document-date-repair-queue";
+import { WEARABLE_CYCLE_REPAIR_QUEUE } from "@/lib/jobs/wearable-cycle-repair-queue";
import { defineJob, type JobDefinition } from "./define-job";
@@ -22,4 +23,17 @@ export const MAINTENANCE_REPAIR_JOBS: readonly JobDefinition[] = [
(await import("@/lib/jobs/lab-document-date-repair"))
.handleLabDocumentDateRepair,
}),
+ // Wearable period days imported without the cycles they open.
+ defineJob({
+ queue: WEARABLE_CYCLE_REPAIR_QUEUE,
+ policy: {
+ policy: "short",
+ reason:
+ "Fixed singleton key; collapse queued duplicates only, so a boot during a run can queue the next one.",
+ },
+ work: { localConcurrency: 1 },
+ load: async () =>
+ (await import("@/lib/jobs/wearable-cycle-repair"))
+ .handleWearableCycleRepair,
+ }),
];
diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts
index b94351e76..5296c702d 100644
--- a/src/lib/jobs/subsystem-surface.ts
+++ b/src/lib/jobs/subsystem-surface.ts
@@ -124,6 +124,7 @@ const subsystemSurface = {
"compaction-tombstone-purge": { audience: "system" },
"measurement-fold-repair": { audience: "system" },
"lab-document-date-repair": { audience: "system" },
+ "wearable-cycle-repair": { audience: "system" },
"measurement-maintenance": { audience: "system" },
"pulse-day-mean": { audience: "system" },
"medication-intake-import": { audience: "system" },
diff --git a/src/lib/jobs/wearable-cycle-repair-queue.ts b/src/lib/jobs/wearable-cycle-repair-queue.ts
new file mode 100644
index 000000000..d106718aa
--- /dev/null
+++ b/src/lib/jobs/wearable-cycle-repair-queue.ts
@@ -0,0 +1,9 @@
+/**
+ * The queue facts of `wearable-cycle-repair.ts`: its name and its one
+ * singleton key. They live apart from the job so the worker registry and the
+ * boot discovery can read them without loading it.
+ */
+export const WEARABLE_CYCLE_REPAIR_QUEUE = "wearable-cycle-repair";
+
+/** One pass covers every account, so there is only ever one job. */
+export const WEARABLE_CYCLE_REPAIR_SINGLETON_KEY = "wearable-cycle-repair";
diff --git a/src/lib/jobs/wearable-cycle-repair.ts b/src/lib/jobs/wearable-cycle-repair.ts
new file mode 100644
index 000000000..f4ed25108
--- /dev/null
+++ b/src/lib/jobs/wearable-cycle-repair.ts
@@ -0,0 +1,271 @@
+/**
+ * One-shot repair: period days Garmin or Oura imported before their write
+ * learned to open and attribute cycles.
+ *
+ * Both wearables wrote their period days as bare rows, past the seam every
+ * other cycle writer goes through (`ensureCycleForBleedingDay`), so an account
+ * whose periods came from a watch or a ring held day-logs and no cycle: no
+ * start, no lengths, no forecast. The write now goes through
+ * `createSubordinatePeriodDays`; this pass gives the rows written before it
+ * the cycles they should have opened, by the same rule, oldest first per
+ * account.
+ *
+ * A row is a candidate when it is a live GARMIN or OURA bleeding day without
+ * a cycle and the rule can still place it: either a cycle starts on or before
+ * it (the row only needs its attribution), or no cycle does and nothing in the
+ * way stops it opening one (`ensureCycleForBleedingDay` declines a day that
+ * lies too close before a later start without being the same period). A day
+ * the rule declines stays unattributed and drops out of the predicate, so the
+ * pass converges: a second run finds nothing, and a boot with nothing to
+ * repair costs one query.
+ *
+ * Only cycles and the `cycleId` link move. No day-log's own content is
+ * touched, a person's own rows included. A Garmin period whose cycle this pass
+ * opened and that is over (a later cycle has started) has its last day stamped
+ * as the period end, from the run of consecutive Garmin days at its start.
+ */
+import type { Job } from "pg-boss";
+
+import { Prisma, type PrismaClient } from "@/generated/prisma/client";
+import { activeAccountSql } from "@/lib/account-deletion/active-account";
+import { ensureCycleForBleedingDay } from "@/lib/cycle/cycle-boundaries";
+import { addDays } from "@/lib/cycle/day-math";
+import { stampSubordinatePeriodEnd } from "@/lib/cycle/subordinate-period-days";
+import { HARD_CYCLE_MIN, PERIOD_MAX } from "@/lib/cycle/types";
+import { prisma as defaultPrisma } from "@/lib/db";
+import { getGlobalBoss } from "@/lib/jobs/boss-instance";
+import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
+import { workerLog } from "@/lib/jobs/reminder/shared";
+import { annotate } from "@/lib/logging/context";
+import { withBackgroundEvent } from "@/lib/logging/background";
+
+import {
+ WEARABLE_CYCLE_REPAIR_QUEUE,
+ WEARABLE_CYCLE_REPAIR_SINGLETON_KEY,
+} from "./wearable-cycle-repair-queue";
+
+/** The longest run of Garmin days read as one period. */
+const MAX_PERIOD_RUN_DAYS = 14;
+
+interface CandidateRow {
+ id: string;
+ userId: string;
+ date: string;
+}
+
+/**
+ * The SQL half of the rule. `limit` turns it into the boot's existence check.
+ * The second arm mirrors `ensureCycleForBleedingDay`: with no cycle on or
+ * before the day, it opens one unless a later start lies within the hard
+ * minimum cycle length without being the same period.
+ */
+function candidateQuery(limit: number | null): Prisma.Sql {
+ return Prisma.sql`
+ SELECT d."id", d."user_id" AS "userId", d."date"
+ FROM "cycle_day_logs" d
+ WHERE d."source" IN ('GARMIN', 'OURA')
+ AND d."deleted_at" IS NULL
+ AND d."cycle_id" IS NULL
+ AND d."flow" IS NOT NULL
+ AND d."flow" NOT IN ('NONE', 'SPOTTING')
+ AND NOT d."intermenstrual_bleeding"
+ AND (
+ EXISTS (
+ SELECT 1 FROM "menstrual_cycles" c
+ WHERE c."user_id" = d."user_id" AND c."deleted_at" IS NULL
+ AND c."start_date" <= d."date"
+ )
+ OR NOT EXISTS (
+ SELECT 1 FROM "menstrual_cycles" c
+ WHERE c."user_id" = d."user_id" AND c."deleted_at" IS NULL
+ AND c."start_date" >= to_char(d."date"::date + ${PERIOD_MAX}::int, 'YYYY-MM-DD')
+ AND c."start_date" < to_char(d."date"::date + ${HARD_CYCLE_MIN}::int, 'YYYY-MM-DD')
+ )
+ )
+ AND ${activeAccountSql('d."user_id"')}
+ ORDER BY d."user_id", d."date"
+ ${limit === null ? Prisma.empty : Prisma.sql`LIMIT ${limit}`}
+ `;
+}
+
+export interface WearableCycleRepairResult {
+ /** Rows the SQL predicate returned. */
+ scanned: number;
+ /** Cycles a row opened. */
+ opened: number;
+ /** Rows given the cycle they fall in. */
+ attributed: number;
+ /** Garmin period ends stamped on an opened cycle. */
+ periodEnds: number;
+ /** Accounts touched. */
+ accounts: number;
+}
+
+/** The last day of the run of consecutive Garmin days starting at `start`. */
+async function garminRunEnd(
+ prisma: PrismaClient,
+ userId: string,
+ start: string,
+): Promise {
+ const rows = await prisma.cycleDayLog.findMany({
+ where: {
+ userId,
+ source: "GARMIN",
+ deletedAt: null,
+ date: { gte: start, lt: addDays(start, MAX_PERIOD_RUN_DAYS) },
+ },
+ orderBy: { date: "asc" },
+ select: { date: true },
+ });
+ let end: string | null = null;
+ for (const row of rows) {
+ if (row.date !== (end === null ? start : addDays(end, 1))) break;
+ end = row.date;
+ }
+ return end;
+}
+
+/** Repair one account's rows, oldest first. */
+async function repairAccount(
+ prisma: PrismaClient,
+ userId: string,
+ rows: readonly CandidateRow[],
+ result: WearableCycleRepairResult,
+): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { timezone: true },
+ });
+ const tz = user?.timezone ?? null;
+ const openedGarminStarts: string[] = [];
+ for (const row of rows) {
+ // Re-read. A cycle opened for an earlier row claims every day after it
+ // until the next start, this one included, so a claimed row is still
+ // asked whether it starts a cycle of its own.
+ const live = await prisma.cycleDayLog.findFirst({
+ where: { id: row.id, deletedAt: null },
+ select: {
+ source: true,
+ flow: true,
+ intermenstrualBleeding: true,
+ cycleId: true,
+ },
+ });
+ if (!live) continue;
+ const opened = await ensureCycleForBleedingDay(userId, row.date, tz, {
+ flow: live.flow,
+ intermenstrualBleeding: live.intermenstrualBleeding,
+ });
+ if (opened) {
+ result.opened += 1;
+ if (live.source === "GARMIN") openedGarminStarts.push(row.date);
+ continue;
+ }
+ if (live.cycleId !== null) continue;
+ const owning = await prisma.menstrualCycle.findFirst({
+ where: { userId, deletedAt: null, startDate: { lte: row.date } },
+ orderBy: { startDate: "desc" },
+ select: { id: true },
+ });
+ if (!owning) continue;
+ const moved = await prisma.cycleDayLog.updateMany({
+ where: { id: row.id, cycleId: null },
+ data: { cycleId: owning.id, syncVersion: { increment: 1 } },
+ });
+ result.attributed += moved.count;
+ }
+ for (const start of openedGarminStarts) {
+ const cycle = await prisma.menstrualCycle.findFirst({
+ where: { userId, startDate: start, deletedAt: null },
+ select: { endDate: true },
+ });
+ // An open cycle may still be bleeding; the hourly sync stamps its end.
+ if (!cycle?.endDate) continue;
+ const end = await garminRunEnd(prisma, userId, start);
+ if (end && (await stampSubordinatePeriodEnd(userId, start, end))) {
+ result.periodEnds += 1;
+ }
+ }
+}
+
+/** The repair itself, across every account. */
+export async function repairWearableCycleDays(
+ prisma: PrismaClient = defaultPrisma,
+): Promise {
+ const rows = await prisma.$queryRaw(candidateQuery(null));
+ const byUser = new Map();
+ for (const row of rows) {
+ const list = byUser.get(row.userId) ?? [];
+ list.push(row);
+ byUser.set(row.userId, list);
+ }
+ const result: WearableCycleRepairResult = {
+ scanned: rows.length,
+ opened: 0,
+ attributed: 0,
+ periodEnds: 0,
+ accounts: byUser.size,
+ };
+ for (const [userId, list] of byUser) {
+ await repairAccount(prisma, userId, list, result);
+ }
+ return result;
+}
+
+/** The queue handler: one run over every account. */
+export async function handleWearableCycleRepair(
+ _jobs: Job>[],
+ prisma: PrismaClient = defaultPrisma,
+): Promise {
+ return withBackgroundEvent("job.wearable_cycle_repair", async () => {
+ let result: WearableCycleRepairResult;
+ try {
+ result = await repairWearableCycleDays(prisma);
+ } catch (err) {
+ workerLog("error", "[wearable-cycle-repair] failed", err);
+ return jobFailed("wearable cycle repair failed", err);
+ }
+ workerLog(
+ "info",
+ `[wearable-cycle-repair] scanned=${result.scanned} opened=${result.opened} attributed=${result.attributed} periodEnds=${result.periodEnds} accounts=${result.accounts}`,
+ );
+ annotate({
+ action: { name: "cycle.wearableDays.repair" },
+ meta: { ...result },
+ });
+ return jobDone({
+ candidates_scanned: result.scanned,
+ // Cycles opened, rows attributed, period ends stamped, accounts.
+ created: result.opened,
+ linked: result.attributed,
+ updated: result.periodEnds,
+ users: result.accounts,
+ });
+ });
+}
+
+/**
+ * Boot discovery: one existence query, and the one job only when there is
+ * something to repair.
+ */
+export async function enqueueBootTimeWearableCycleRepair(
+ prisma: PrismaClient = defaultPrisma,
+): Promise<{ enqueued: boolean; error: string | null }> {
+ const boss = getGlobalBoss();
+ if (!boss) return { enqueued: false, error: null };
+ try {
+ const any = await prisma.$queryRaw(candidateQuery(1));
+ if (any.length === 0) return { enqueued: false, error: null };
+ const id = await boss.send(
+ WEARABLE_CYCLE_REPAIR_QUEUE,
+ {},
+ { singletonKey: WEARABLE_CYCLE_REPAIR_SINGLETON_KEY, retryLimit: 2 },
+ );
+ return { enqueued: id !== null, error: null };
+ } catch (err) {
+ return {
+ enqueued: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+}
diff --git a/src/lib/oura/__tests__/cycle-sync.test.ts b/src/lib/oura/__tests__/cycle-sync.test.ts
index 4319cbfed..ac926f39e 100644
--- a/src/lib/oura/__tests__/cycle-sync.test.ts
+++ b/src/lib/oura/__tests__/cycle-sync.test.ts
@@ -6,13 +6,21 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
-const { fetchCyclePhasesMock, isCycleAvailableMock, findUserMock, createMock } =
- vi.hoisted(() => ({
- fetchCyclePhasesMock: vi.fn(),
- isCycleAvailableMock: vi.fn(),
- findUserMock: vi.fn(),
- createMock: vi.fn(),
- }));
+const {
+ fetchCyclePhasesMock,
+ isCycleAvailableMock,
+ findUserMock,
+ createMock,
+ ensureCycleMock,
+ owningCycleMock,
+} = vi.hoisted(() => ({
+ fetchCyclePhasesMock: vi.fn(),
+ isCycleAvailableMock: vi.fn(),
+ findUserMock: vi.fn(),
+ createMock: vi.fn(),
+ ensureCycleMock: vi.fn(),
+ owningCycleMock: vi.fn(),
+}));
vi.mock("@/lib/db", () => ({
prisma: {
@@ -21,6 +29,14 @@ vi.mock("@/lib/db", () => ({
},
}));
+vi.mock("@/lib/cycle/cycle-boundaries", () => ({
+ ensureCycleForBleedingDay: ensureCycleMock,
+}));
+
+vi.mock("@/lib/cycle/cycle-attribution", () => ({
+ findOwningCycleId: owningCycleMock,
+}));
+
vi.mock("@/lib/cycle/gate", () => ({
isCycleAvailableForUser: isCycleAvailableMock,
}));
@@ -50,6 +66,8 @@ beforeEach(() => {
isCycleAvailableMock.mockReset().mockResolvedValue(true);
findUserMock.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" });
createMock.mockReset().mockResolvedValue({ id: "cdl-1" });
+ ensureCycleMock.mockReset().mockResolvedValue(null);
+ owningCycleMock.mockReset().mockResolvedValue(null);
});
describe("syncUserOuraCyclePhases", () => {
@@ -93,10 +111,41 @@ describe("syncUserOuraCyclePhases", () => {
tz: "Europe/Berlin",
source: "OURA",
flow: "LIGHT",
+ cycleId: null,
},
});
});
+ it("attributes a created day to its cycle and lets a first bleeding day open one", async () => {
+ fetchCyclePhasesMock.mockResolvedValue([
+ { day: "2026-06-05", phase: "menstrual" },
+ ]);
+ owningCycleMock.mockResolvedValue("cycle-before");
+ await syncUserOuraCyclePhases("u1", "tok", WINDOW);
+ expect(createMock).toHaveBeenCalledWith({
+ data: expect.objectContaining({ cycleId: "cycle-before" }),
+ });
+ expect(ensureCycleMock).toHaveBeenCalledWith(
+ "u1",
+ "2026-06-05",
+ "Europe/Berlin",
+ { flow: "LIGHT", intermenstrualBleeding: false },
+ );
+ // The inference runs after the create, so the opened cycle claims it.
+ expect(createMock.mock.invocationCallOrder[0]).toBeLessThan(
+ ensureCycleMock.mock.invocationCallOrder[0]!,
+ );
+ });
+
+ it("infers no cycle from a day it did not create", async () => {
+ fetchCyclePhasesMock.mockResolvedValue([
+ { day: "2026-06-05", phase: "menstrual" },
+ ]);
+ createMock.mockRejectedValue(uniqueViolation());
+ await syncUserOuraCyclePhases("u1", "tok", WINDOW);
+ expect(ensureCycleMock).not.toHaveBeenCalled();
+ });
+
it("skips a day that already has ANY existing row (manual wins) — unique-violation swallowed silently", async () => {
fetchCyclePhasesMock.mockResolvedValue([
{ day: "2026-06-05", phase: "menstrual" },
diff --git a/src/lib/oura/cycle-sync.ts b/src/lib/oura/cycle-sync.ts
index 5a8db8876..b2b33052a 100644
--- a/src/lib/oura/cycle-sync.ts
+++ b/src/lib/oura/cycle-sync.ts
@@ -17,45 +17,26 @@
* So this module never touches the shared upsert helper. It only ever
* CREATEs a day that has NO existing `CycleDayLog` row at all (any source),
* and never updates a row it did not itself create. A day already owned by
- * this module (source = OURA) is left alone on re-sync too — the marker is
- * a fixed single-value hint (`flow: LIGHT`), not a corrigible reading, so
- * there is nothing to refresh. The whole path degenerates to a cheap
- * "create if absent, otherwise no-op".
+ * this module (source = OURA) is left alone on re-sync too: the marker is a
+ * fixed single-value hint (`flow: LIGHT`), not a corrigible reading, so there
+ * is nothing to refresh.
+ *
+ * The create goes through `createSubordinatePeriodDays`, which does what the
+ * shared upsert does around the row: the day is attributed to the cycle it
+ * falls in, and a first bleeding day opens its cycle, the same inference the
+ * single capture, the bulk drain, the Apple Health fold and the Health Connect
+ * import run. Oura reports no period length, so no period end is stamped.
*
* Gated behind the SAME two-layer cycle-module gate every other cycle
* writer respects (`isCycleAvailableForUser`) so an operator-disabled
* instance, or a user who turned cycle tracking off, never accumulates rows
- * from a background Oura poll. No `MenstrualCycle` span is ever created or
- * mutated here — mirrors the Apple Health importer precedent
- * (`src/lib/cycle/import-accumulator.ts`), which attributes day-logs to an
- * EXISTING cycle but never synthesises a new one from flow data either;
- * cycle-span creation stays an explicit user action
- * (`POST /api/cycle/period`).
+ * from a background Oura poll.
*/
-import { prisma } from "@/lib/db";
-import { getEvent } from "@/lib/logging/context";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
+import { createSubordinatePeriodDays } from "@/lib/cycle/subordinate-period-days";
import { fetchDailyCyclePhases } from "./client";
import { derivePeriodDaysFromCyclePhases } from "./map";
-/** The conservative "logged but unspecified intensity" flow level — mirrors
- * the HealthKit `unspecified` → `LIGHT` boundary documented in
- * `healthkit-mapping.ts`: a detected-but-unmeasured bleeding day is at least
- * light flow, never invented as MEDIUM/HEAVY. */
-const OURA_PERIOD_FLOW = "LIGHT" as const;
-
-/** Prisma P2002 (unique constraint) detector, mirrored locally from
- * `day-log-write.ts` rather than imported — a two-line duplicate is cheaper
- * than coupling this module to that helper's internals. */
-function isUniqueViolation(err: unknown): boolean {
- return (
- typeof err === "object" &&
- err !== null &&
- "code" in err &&
- (err as { code?: unknown }).code === "P2002"
- );
-}
-
/**
* Sync one user's Oura `daily_cycle_phases` window into `CycleDayLog`.
* Returns the count of days newly created.
@@ -88,36 +69,5 @@ export async function syncUserOuraCyclePhases(
const available = await isCycleAvailableForUser(userId);
if (!available) return 0;
- const user = await prisma.user.findUnique({
- where: { id: userId },
- select: { timezone: true },
- });
- const tz = user?.timezone ?? null;
-
- let created = 0;
- for (const day of periodDays) {
- try {
- await prisma.cycleDayLog.create({
- data: {
- userId,
- date: day,
- tz,
- source: "OURA",
- flow: OURA_PERIOD_FLOW,
- },
- });
- created += 1;
- } catch (err) {
- if (isUniqueViolation(err)) {
- // A row already exists for this date — the user's own entry, an
- // Apple Health import, or this module's own prior write. Never
- // overwrite: Oura is a subordinate signal here.
- continue;
- }
- getEvent()?.addWarning(
- `oura: cycle day-log create failed for ${userId} ${day}: ${err}`,
- );
- }
- }
- return created;
+ return createSubordinatePeriodDays(userId, "OURA", periodDays);
}
diff --git a/tests/integration/__golden__/queue-registration.json b/tests/integration/__golden__/queue-registration.json
index 0560c4642..5bb1305d3 100644
--- a/tests/integration/__golden__/queue-registration.json
+++ b/tests/integration/__golden__/queue-registration.json
@@ -1,5 +1,5 @@
{
- "registeredQueueCount": 129,
+ "registeredQueueCount": 130,
"registeredQueues": [
"account-deletion-purge",
"achievement-unlock-sweep",
@@ -117,6 +117,7 @@
"supplement-nutrient-recompute",
"system-job-alert",
"tls-pin-monitor",
+ "wearable-cycle-repair",
"whoop-backfill",
"whoop-cycle-sync",
"whoop-oauth-state-cleanup",
@@ -1872,6 +1873,21 @@
"retry_delay_max": null,
"retry_limit": 2
},
+ {
+ "dead_letter": null,
+ "deletion_seconds": 604800,
+ "expire_seconds": 900,
+ "heartbeat_seconds": null,
+ "name": "wearable-cycle-repair",
+ "notify": false,
+ "partition": false,
+ "policy": "short",
+ "retention_seconds": 1209600,
+ "retry_backoff": false,
+ "retry_delay": 0,
+ "retry_delay_max": null,
+ "retry_limit": 2
+ },
{
"dead_letter": null,
"deletion_seconds": 604800,
@@ -3801,6 +3817,12 @@
"pollingIntervalSeconds": 30
}
},
+ {
+ "queue": "wearable-cycle-repair",
+ "options": {
+ "localConcurrency": 1
+ }
+ },
{
"queue": "whoop-backfill",
"options": {
diff --git a/tests/integration/garmin-sync.test.ts b/tests/integration/garmin-sync.test.ts
index 0fadd773c..33a572b80 100644
--- a/tests/integration/garmin-sync.test.ts
+++ b/tests/integration/garmin-sync.test.ts
@@ -77,6 +77,8 @@ const fake = vi.hoisted(() => ({
rateLimitHit: -1,
/** First day of the one logged cycle, relative to today. */
cycleStart: "",
+ /** Several logged cycles instead of the one; when set, `cycleStart` is unused. */
+ cycles: [] as Array<{ start: string; periodLength: number }>,
/**
* The weigh-in shape: `spec` is Garmin's document (kilograms, no
* `samplePk`, a string instant), `live` the shape seen on the live
@@ -260,6 +262,18 @@ function answer(path: string, q: URLSearchParams): unknown {
if (path.startsWith("/periodichealth-service/menstrualcycle/summary/")) {
if (fake.cycleStatus !== 200) return { status: fake.cycleStatus };
const day = path.split("/").pop()!;
+ if (fake.cycles.length > 0) {
+ const at = [...fake.cycles]
+ .sort((a, b) => b.start.localeCompare(a.start))
+ .find((c) => c.start <= day);
+ return at
+ ? {
+ startDate: { localDate: at.start },
+ periodLength: at.periodLength,
+ predictedCycle: false,
+ }
+ : {};
+ }
const cycleStart = fake.cycleStart;
return day >= cycleStart
? { startDate: cycleStart, periodLength: 4, predictedCycle: false }
@@ -362,6 +376,7 @@ beforeEach(async () => {
fake.today = new Date().toISOString().slice(0, 10);
fake.historyFrom = addDays(fake.today, -20);
fake.cycleStart = addDays(fake.today, -15);
+ fake.cycles = [];
fake.weightShape = "spec";
fake.extraWeighDays = [];
fake.cycleStatus = 200;
@@ -596,6 +611,122 @@ describe("Garmin history backfill", () => {
});
});
+describe("Garmin cycle history", () => {
+ async function backfillToEnd() {
+ const { runGarminBackfillForUser } =
+ await import("@/lib/jobs/garmin-backfill");
+ for (let slice = 0; slice < 20; slice++) {
+ const user = await getPrismaClient().user.findUnique({
+ where: { id: USER_ID },
+ select: { garminBackfillCompletedAt: true },
+ });
+ if (user?.garminBackfillCompletedAt) return;
+ await runGarminBackfillForUser(USER_ID, () => false, slice);
+ }
+ throw new Error("backfill did not finish");
+ }
+
+ async function liveCycles() {
+ return getPrismaClient().menstrualCycle.findMany({
+ where: { userId: USER_ID, deletedAt: null },
+ orderBy: { startDate: "asc" },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ lengthDays: true,
+ periodEndDate: true,
+ },
+ });
+ }
+
+ it("opens a cycle per back-filled period, newest first, with lengths and a forecast", async () => {
+ await connectUser();
+ fake.historyFrom = addDays(fake.today, -100);
+ const starts = [-80, -52, -24].map((n) => addDays(fake.today, n));
+ fake.cycles = starts.map((start) => ({ start, periodLength: 5 }));
+ // A day the person logged inside the middle period.
+ const ownDay = addDays(starts[1]!, 2);
+ const prisma = getPrismaClient();
+ await prisma.cycleDayLog.create({
+ data: { userId: USER_ID, date: ownDay, flow: "HEAVY" },
+ });
+
+ await backfillToEnd();
+
+ const cycles = await liveCycles();
+ expect(
+ cycles.map((c) => [
+ c.startDate,
+ c.endDate,
+ c.lengthDays,
+ c.periodEndDate,
+ ]),
+ ).toEqual([
+ [starts[0], addDays(starts[1]!, -1), 28, addDays(starts[0]!, 4)],
+ [starts[1], addDays(starts[2]!, -1), 28, addDays(starts[1]!, 4)],
+ [starts[2], null, null, addDays(starts[2]!, 4)],
+ ]);
+ // Every Garmin day belongs to the period it is part of.
+ const days = await prisma.cycleDayLog.findMany({
+ where: { userId: USER_ID },
+ orderBy: { date: "asc" },
+ select: { date: true, source: true, flow: true, cycleId: true },
+ });
+ expect(days).toHaveLength(15);
+ for (const day of days) {
+ const owner = [...cycles].reverse().find((c) => c.startDate <= day.date);
+ expect(day.cycleId).toBe(owner?.id);
+ }
+ // The person's own day is theirs, as they logged it.
+ expect(days.find((d) => d.date === ownDay)).toMatchObject({
+ source: "MANUAL",
+ flow: "HEAVY",
+ });
+
+ const { getOrCreateCycleProfile } = await import("@/lib/cycle/profile");
+ await getOrCreateCycleProfile(USER_ID);
+ const { refreshPredictionCacheForUser } =
+ await import("@/lib/cycle/prediction-refresh");
+ expect(await refreshPredictionCacheForUser(USER_ID)).toBe("persisted");
+ const forecast = await prisma.cyclePrediction.findUnique({
+ where: { userId: USER_ID },
+ });
+ expect(forecast).not.toBeNull();
+
+ // A second full backfill changes nothing.
+ await prisma.user.update({
+ where: { id: USER_ID },
+ data: {
+ garminBackfillProgress: Prisma.DbNull,
+ garminBackfillCompletedAt: null,
+ },
+ });
+ await backfillToEnd();
+ expect(await liveCycles()).toEqual(cycles);
+ expect(await prisma.cycleDayLog.count({ where: { userId: USER_ID } })).toBe(
+ 15,
+ );
+ });
+
+ it("opens the current cycle from the hourly sync and leaves its end open while bleeding", async () => {
+ await connectUser();
+ const start = addDays(fake.today, -1);
+ fake.cycles = [{ start, periodLength: 5 }];
+ const { syncUserGarmin } = await import("@/lib/garmin/sync");
+ await syncUserGarmin(USER_ID, { lookbackDays: 2 });
+ const cycles = await liveCycles();
+ expect(cycles).toHaveLength(1);
+ expect(cycles[0]).toMatchObject({ startDate: start, periodEndDate: null });
+ const days = await getPrismaClient().cycleDayLog.findMany({
+ where: { userId: USER_ID },
+ select: { cycleId: true },
+ });
+ expect(days).toHaveLength(2);
+ expect(days.every((d) => d.cycleId === cycles[0]!.id)).toBe(true);
+ });
+});
+
describe("Garmin connect and disconnect", () => {
function post(url: string, body?: unknown) {
return new NextRequest(`http://localhost${url}`, {
diff --git a/tests/integration/wearable-cycle-repair.test.ts b/tests/integration/wearable-cycle-repair.test.ts
new file mode 100644
index 000000000..b9d95b54a
--- /dev/null
+++ b/tests/integration/wearable-cycle-repair.test.ts
@@ -0,0 +1,200 @@
+/**
+ * The one-shot repair of wearable period days imported without the cycles
+ * they open (real Postgres).
+ *
+ * Garmin and Oura used to write their period days as bare rows, so an
+ * account whose periods came from a watch or a ring held day-logs and no
+ * cycle. The repair opens the cycles those rows start, attributes the rest,
+ * stamps the end of a finished Garmin period, and touches no day-log's own
+ * content. A day the cycle rule declines stays as it is and drops out of the
+ * candidate set, so the boot check converges; a second run changes nothing.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+const sends = vi.hoisted(() => [] as string[]);
+
+vi.mock("@/lib/jobs/boss-instance", () => ({
+ getGlobalBoss: () => ({
+ send: async (queue: string) => {
+ sends.push(queue);
+ return `job-${sends.length}`;
+ },
+ }),
+}));
+
+import {
+ enqueueBootTimeWearableCycleRepair,
+ repairWearableCycleDays,
+} from "@/lib/jobs/wearable-cycle-repair";
+
+const GARMIN_USER = "user-wearable-repair-garmin";
+const OURA_USER = "user-wearable-repair-oura";
+const BLOCKED_USER = "user-wearable-repair-blocked";
+
+const DAY_MS = 86_400_000;
+const addDays = (day: string, n: number) =>
+ new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS)
+ .toISOString()
+ .slice(0, 10);
+
+const GARMIN_STARTS = ["2026-07-01", "2026-07-29", "2026-08-26"];
+
+async function seedUser(id: string) {
+ await getPrismaClient().user.create({
+ data: {
+ id,
+ username: id,
+ email: `${id}@example.test`,
+ timezone: "UTC",
+ gender: "FEMALE",
+ },
+ });
+}
+
+/** Day-logs as a v1.47.0 import left them: no cycle. */
+async function orphanDays(
+ userId: string,
+ source: "GARMIN" | "OURA",
+ days: string[],
+) {
+ await getPrismaClient().cycleDayLog.createMany({
+ data: days.map((date) => ({
+ userId,
+ date,
+ tz: "UTC",
+ source,
+ flow: "LIGHT" as const,
+ })),
+ });
+}
+
+async function liveCycles(userId: string) {
+ return getPrismaClient().menstrualCycle.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { startDate: "asc" },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ lengthDays: true,
+ periodEndDate: true,
+ syncVersion: true,
+ },
+ });
+}
+
+beforeEach(async () => {
+ const prisma = getPrismaClient();
+ await truncateAllTables(prisma);
+ sends.length = 0;
+ for (const id of [GARMIN_USER, OURA_USER, BLOCKED_USER]) await seedUser(id);
+
+ await orphanDays(
+ GARMIN_USER,
+ "GARMIN",
+ GARMIN_STARTS.flatMap((start) =>
+ [0, 1, 2, 3, 4].map((i) => addDays(start, i)),
+ ).filter((d) => d !== "2026-07-31"),
+ );
+ // The person's own day inside the middle period, logged before any cycle.
+ await prisma.cycleDayLog.create({
+ data: {
+ userId: GARMIN_USER,
+ date: "2026-07-31",
+ flow: "HEAVY",
+ source: "MANUAL",
+ },
+ });
+
+ await orphanDays(OURA_USER, "OURA", ["2026-06-01", "2026-06-29"]);
+
+ // A Garmin day 15 days before a start the person tapped: too close to be a
+ // cycle of its own, too far to be the same period. The rule declines it.
+ await prisma.menstrualCycle.create({
+ data: { userId: BLOCKED_USER, startDate: "2026-09-16", tz: "UTC" },
+ });
+ await orphanDays(BLOCKED_USER, "GARMIN", ["2026-09-01"]);
+});
+
+describe("wearable cycle repair", () => {
+ it("gives orphaned wearable period days their cycles", async () => {
+ expect(await enqueueBootTimeWearableCycleRepair()).toEqual({
+ enqueued: true,
+ error: null,
+ });
+
+ const result = await repairWearableCycleDays();
+ expect(result).toMatchObject({ opened: 5, periodEnds: 2, accounts: 2 });
+
+ const garmin = await liveCycles(GARMIN_USER);
+ expect(
+ garmin.map((c) => [
+ c.startDate,
+ c.endDate,
+ c.lengthDays,
+ c.periodEndDate,
+ ]),
+ ).toEqual([
+ ["2026-07-01", "2026-07-28", 28, "2026-07-05"],
+ // The person's day breaks the Garmin run, so the end is the day before.
+ ["2026-07-29", "2026-08-25", 28, "2026-07-30"],
+ // Still open: the hourly sync stamps its end.
+ ["2026-08-26", null, null, null],
+ ]);
+ const prisma = getPrismaClient();
+ const days = await prisma.cycleDayLog.findMany({
+ where: { userId: GARMIN_USER },
+ orderBy: { date: "asc" },
+ });
+ for (const day of days) {
+ const owner = [...garmin].reverse().find((c) => c.startDate <= day.date);
+ expect(day.cycleId).toBe(owner!.id);
+ }
+ expect(days.find((d) => d.date === "2026-07-31")).toMatchObject({
+ source: "MANUAL",
+ flow: "HEAVY",
+ });
+
+ const oura = await liveCycles(OURA_USER);
+ expect(oura.map((c) => [c.startDate, c.lengthDays])).toEqual([
+ ["2026-06-01", 28],
+ ["2026-06-29", null],
+ ]);
+
+ // The declined day stays as it was and no longer counts as work.
+ const blocked = await prisma.cycleDayLog.findFirst({
+ where: { userId: BLOCKED_USER },
+ });
+ expect(blocked?.cycleId).toBeNull();
+ expect(await liveCycles(BLOCKED_USER)).toHaveLength(1);
+
+ sends.length = 0;
+ expect(await enqueueBootTimeWearableCycleRepair()).toEqual({
+ enqueued: false,
+ error: null,
+ });
+ expect(sends).toEqual([]);
+ });
+
+ it("changes nothing on a second run", async () => {
+ await repairWearableCycleDays();
+ const before = await liveCycles(GARMIN_USER);
+ const daysBefore = await getPrismaClient().cycleDayLog.findMany({
+ where: { userId: GARMIN_USER },
+ orderBy: { date: "asc" },
+ select: { id: true, cycleId: true, syncVersion: true, flow: true },
+ });
+ const again = await repairWearableCycleDays();
+ expect(again).toMatchObject({ scanned: 0, opened: 0, attributed: 0 });
+ expect(await liveCycles(GARMIN_USER)).toEqual(before);
+ expect(
+ await getPrismaClient().cycleDayLog.findMany({
+ where: { userId: GARMIN_USER },
+ orderBy: { date: "asc" },
+ select: { id: true, cycleId: true, syncVersion: true, flow: true },
+ }),
+ ).toEqual(daysBefore);
+ });
+});
From afdb8163ce91da9571b463656119f1bfb9a2f76b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 22:03:08 +0100
Subject: [PATCH 11/55] fix(onboarding): offer Garmin as a source
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.
A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.
Refs #1333
---
CHANGELOG.md | 3 ++
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 +
messages/pt-BR.json | 1 +
messages/ru.json | 1 +
.../__tests__/first-result-config.test.ts | 42 +++++++++++++++++++
src/lib/onboarding/first-result-config.ts | 9 +++-
src/lib/onboarding/needs.ts | 1 +
src/lib/onboarding/wizard-steps.ts | 1 +
16 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45d96f795..bd8159d1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,9 @@
cycles like a period logged by hand, a finished Garmin period also marks
its last day, and periods imported earlier are repaired once on the next
start. A day you logged yourself is never changed. (#1333)
+- **Garmin in the setup questions.** The onboarding question about where
+ your readings come from now offers Garmin, and can end on connecting it.
+ (#1333)
- **Typing into the smoking history.** "When you stopped" only took text
that was already a whole YYYY-MM month, so every keystroke was dropped
and only a paste got in. The start year clamped each keystroke into its
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 1d896a3de..eaf513ca2 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -36694,7 +36694,7 @@ components:
type: string
const: done
sources:
- maxItems: 10
+ maxItems: 11
type: array
items:
type: string
@@ -36705,6 +36705,7 @@ components:
- oura
- whoop
- polar
+ - garmin
- fitbit
- strava
- nightscout
@@ -83103,6 +83104,7 @@ components:
- oura
- whoop
- polar
+ - garmin
- fitbit
- strava
- nightscout
diff --git a/messages/de.json b/messages/de.json
index 5abe7c515..addd1369c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/en.json b/messages/en.json
index 4160fe8fd..6eecabb83 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/es.json b/messages/es.json
index aff7c3bca..663d66f6e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/fr.json b/messages/fr.json
index 348f3d5a4..50e0eb018 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/it.json b/messages/it.json
index ad13867cc..c05731311 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/ko.json b/messages/ko.json
index 98499067d..10ebcb44e 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/nl.json b/messages/nl.json
index 96d427f61..a53d0187c 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/pl.json b/messages/pl.json
index d5fb8cd97..c9b05a0c0 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 7436334e1..eb89e8d03 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/messages/ru.json b/messages/ru.json
index 7ef35a608..6563de908 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -5277,6 +5277,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
+ "garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts
index 5eebd8325..d05ee5f12 100644
--- a/src/lib/onboarding/__tests__/first-result-config.test.ts
+++ b/src/lib/onboarding/__tests__/first-result-config.test.ts
@@ -8,6 +8,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
+import { CONNECTION_KEYS } from "@/lib/integrations/connection-health";
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
@@ -18,8 +19,22 @@ import {
deliveringSources,
SOURCE_INTEGRATION,
} from "../first-result-config";
+import { ONBOARDING_SOURCE_KEYS } from "../needs";
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
+/**
+ * Connections the setup flow deliberately does not ask about, each with its
+ * reason. Every other connection must be a Q4 source; a provider shipped on
+ * Settings and missing here was absent from the flow without anybody deciding
+ * it.
+ */
+const NOT_AN_ONBOARDING_SOURCE: Record = {
+ "google-health":
+ "the successor transport behind the Fitbit answer, which still links the classic Fitbit card",
+ dawarich:
+ "movement context for the environment module, not a source of readings",
+};
+
describe("first-result targets", () => {
it("covers every area with exactly one of a form or a page", () => {
for (const area of ONBOARDING_AREA_KEYS) {
@@ -36,6 +51,33 @@ describe("first-result targets", () => {
}
});
+ it("asks about every connection Settings offers, or says why not", () => {
+ const sources = new Set(ONBOARDING_SOURCE_KEYS);
+ const missing = CONNECTION_KEYS.filter(
+ (key) => !sources.has(key) && !(key in NOT_AN_ONBOARDING_SOURCE),
+ );
+ expect(missing).toEqual([]);
+ for (const key of Object.keys(NOT_AN_ONBOARDING_SOURCE)) {
+ expect(CONNECTION_KEYS as readonly string[]).toContain(key);
+ }
+ });
+
+ it("offers Garmin as a source the browser connects with a pasted key", () => {
+ expect(BROWSER_CONNECTABLE_SOURCES).toContain("garmin");
+ expect(SOURCE_INTEGRATION.garmin).toEqual({
+ anchor: "garmin",
+ statusKey: "garmin",
+ credentials: "self-hosted",
+ });
+ const view = connectSourceView("garmin", {
+ connected: false,
+ configured: false,
+ syncHealth: { verdict: "disconnected", since: null },
+ lastSuccessAt: null,
+ });
+ expect(view?.slot).toBe("connect");
+ });
+
it("names an integration for every source the browser can connect", () => {
for (const source of BROWSER_CONNECTABLE_SOURCES) {
expect(SOURCE_INTEGRATION[source].anchor).toBeTruthy();
diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts
index 304755c59..9c033b761 100644
--- a/src/lib/onboarding/first-result-config.ts
+++ b/src/lib/onboarding/first-result-config.ts
@@ -61,7 +61,8 @@ export const AREA_PAGE_HREF: Readonly<
* a dead end.
* - `shared` — an operator env app is the fallback (`available` says whether
* any usable credentials resolve, own or shared).
- * - `self-hosted` — a URL the person pastes; nothing can be missing up front.
+ * - `self-hosted` — a URL or a key the person pastes; nothing can be missing
+ * up front.
*/
export type SourceCredentialModel = "byo" | "shared" | "self-hosted";
@@ -80,6 +81,12 @@ export const SOURCE_INTEGRATION: Readonly<
oura: { anchor: "oura", statusKey: "oura", credentials: "shared" },
whoop: { anchor: "whoop", statusKey: "whoop", credentials: "byo" },
polar: { anchor: "polar", statusKey: "polar", credentials: "shared" },
+ // The person's own Personal API key, pasted on the card.
+ garmin: {
+ anchor: "garmin",
+ statusKey: "garmin",
+ credentials: "self-hosted",
+ },
fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" },
strava: { anchor: "strava", statusKey: "strava", credentials: "shared" },
nightscout: {
diff --git a/src/lib/onboarding/needs.ts b/src/lib/onboarding/needs.ts
index 5ab044d73..a319b918b 100644
--- a/src/lib/onboarding/needs.ts
+++ b/src/lib/onboarding/needs.ts
@@ -99,6 +99,7 @@ export const ONBOARDING_SOURCE_KEYS = [
"oura",
"whoop",
"polar",
+ "garmin",
"fitbit",
"strava",
"nightscout",
diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts
index d84b8adb4..8f411d264 100644
--- a/src/lib/onboarding/wizard-steps.ts
+++ b/src/lib/onboarding/wizard-steps.ts
@@ -34,6 +34,7 @@ export const BROWSER_CONNECTABLE_SOURCES = [
"oura",
"whoop",
"polar",
+ "garmin",
"fitbit",
"strava",
"nightscout",
From 27a1cd6c2fcafe9ec0b49a9b398e31bfcc170a7a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 22:03:18 +0100
Subject: [PATCH 12/55] feat(integrations): list imported cycle days in the
details
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.
Refs #1333
---
CHANGELOG.md | 3 +
docs/api/openapi.yaml | 28 +++++++---
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 +-
messages/pt-BR.json | 4 +-
messages/ru.json | 4 +-
.../status/__tests__/route.test.ts | 1 +
.../__tests__/garmin-card.test.tsx | 19 +++++++
.../integrations/metric-freshness-list.tsx | 10 +++-
.../__tests__/metric-freshness.test.ts | 45 +++++++++++++++
src/lib/integrations/metric-freshness.ts | 56 +++++++++++++++++--
src/lib/integrations/sync-verdict.ts | 14 ++++-
src/lib/openapi/routes/integrations.ts | 12 +++-
19 files changed, 200 insertions(+), 28 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd8159d1b..f5436773f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,9 @@
- **Garmin in the setup questions.** The onboarding question about where
your readings come from now offers Garmin, and can end on connecting it.
(#1333)
+- **Cycle days in the integration details.** The Garmin and Oura cards list
+ the newest imported cycle day and how many there are under "Data
+ received", next to the other data types. (#1333)
- **Typing into the smoking history.** "When you stopped" only took text
that was already a whole YYYY-MM month, so every keystroke was dropped
and only a paste got in. The start year clamped each keystroke into its
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index eaf513ca2..a83cd903e 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -83834,14 +83834,20 @@ components:
properties:
type:
type: string
- description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only,
- since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or
- skip a type they do not know rather than fail.
+ description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, `CYCLE_DAYS` for the period
+ days a wearable imported (Garmin, Oura), or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for
+ medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than
+ fail.
lastSeenAt:
type: string
format: date-time
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])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- description: Newest recorded reading for this type from this source.
+ description: Newest recorded reading for this type from this source. On `CYCLE_DAYS`, the newest cycle day at noon UTC.
+ count:
+ description: How many live cycle days the source holds. Present on `CYCLE_DAYS` only.
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
stale:
type: boolean
description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single
@@ -86477,14 +86483,20 @@ components:
properties:
type:
type: string
- description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only,
- since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or
- skip a type they do not know rather than fail.
+ description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, `CYCLE_DAYS` for the period
+ days a wearable imported (Garmin, Oura), or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for
+ medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than
+ fail.
lastSeenAt:
type: string
format: date-time
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])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- description: Newest recorded reading for this type from this source.
+ description: Newest recorded reading for this type from this source. On `CYCLE_DAYS`, the newest cycle day at noon UTC.
+ count:
+ description: How many live cycle days the source holds. Present on `CYCLE_DAYS` only.
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
stale:
type: boolean
description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single
diff --git a/messages/de.json b/messages/de.json
index addd1369c..679099eef 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6904,7 +6904,9 @@
"summary": "{count} Datentypen, neuestes {relative}",
"quiet": "{count} still",
"workouts": "Workouts",
- "medicationDoses": "Medikamenteneinnahmen"
+ "medicationDoses": "Medikamenteneinnahmen",
+ "cycleDays": "Zyklustage",
+ "cycleDaysCount": "Zyklustage ({count})"
},
"integrationFallback": {
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte."
diff --git a/messages/en.json b/messages/en.json
index 6eecabb83..9b216d13c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6904,7 +6904,9 @@
"summary": "{count} data types, newest {relative}",
"quiet": "{count} quiet",
"workouts": "Workouts",
- "medicationDoses": "Medication doses"
+ "medicationDoses": "Medication doses",
+ "cycleDays": "Cycle days",
+ "cycleDaysCount": "Cycle days ({count})"
},
"integrationFallback": {
"description": "Configured on this account without a settings card of its own."
diff --git a/messages/es.json b/messages/es.json
index 663d66f6e..11642bb05 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6904,7 +6904,9 @@
"summary": "{count} tipos de datos, más reciente {relative}",
"quiet": "{count} en silencio",
"workouts": "Entrenamientos",
- "medicationDoses": "Tomas de medicación"
+ "medicationDoses": "Tomas de medicación",
+ "cycleDays": "Días del ciclo",
+ "cycleDaysCount": "Días del ciclo ({count})"
},
"integrationFallback": {
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia."
diff --git a/messages/fr.json b/messages/fr.json
index 50e0eb018..e1fc6d686 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6904,7 +6904,9 @@
"summary": "{count} types de données, plus récent {relative}",
"quiet": "{count} silencieux",
"workouts": "Séances",
- "medicationDoses": "Prises de médicaments"
+ "medicationDoses": "Prises de médicaments",
+ "cycleDays": "Jours du cycle",
+ "cycleDaysCount": "Jours du cycle ({count})"
},
"integrationFallback": {
"description": "Configuré sur ce compte sans carte de réglages dédiée."
diff --git a/messages/it.json b/messages/it.json
index c05731311..0bc50c2c7 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6904,7 +6904,9 @@
"summary": "{count} tipi di dati, più recente {relative}",
"quiet": "{count} in silenzio",
"workouts": "Allenamenti",
- "medicationDoses": "Assunzioni di farmaci"
+ "medicationDoses": "Assunzioni di farmaci",
+ "cycleDays": "Giorni del ciclo",
+ "cycleDaysCount": "Giorni del ciclo ({count})"
},
"integrationFallback": {
"description": "Configurato su questo account ma senza una scheda di impostazioni propria."
diff --git a/messages/ko.json b/messages/ko.json
index 10ebcb44e..665a02dd8 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6904,7 +6904,9 @@
"summary": "데이터 유형 {count}개, 최신 {relative}",
"quiet": "{count}개 잠잠",
"workouts": "운동",
- "medicationDoses": "약 복용"
+ "medicationDoses": "약 복용",
+ "cycleDays": "주기 기록일",
+ "cycleDaysCount": "주기 기록일 ({count})"
},
"integrationFallback": {
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
diff --git a/messages/nl.json b/messages/nl.json
index a53d0187c..c029834f3 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -6904,7 +6904,9 @@
"summary": "{count} gegevenstypen, nieuwste {relative}",
"quiet": "{count} stil",
"workouts": "Trainingen",
- "medicationDoses": "Medicatiedoses"
+ "medicationDoses": "Medicatiedoses",
+ "cycleDays": "Cyclusdagen",
+ "cycleDaysCount": "Cyclusdagen ({count})"
},
"integrationFallback": {
"description": "Ingesteld op dit account zonder eigen instellingenkaart."
diff --git a/messages/pl.json b/messages/pl.json
index c9b05a0c0..1bd886c92 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6904,7 +6904,9 @@
"summary": "{count} typów danych, najnowsze {relative}",
"quiet": "{count} bez danych",
"workouts": "Treningi",
- "medicationDoses": "Dawki leków"
+ "medicationDoses": "Dawki leków",
+ "cycleDays": "Dni cyklu",
+ "cycleDaysCount": "Dni cyklu ({count})"
},
"integrationFallback": {
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień."
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index eb89e8d03..7b673743d 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -6904,7 +6904,9 @@
"summary": "{count} tipos de dados, mais recente {relative}",
"quiet": "{count} sem atividade",
"workouts": "Treinos",
- "medicationDoses": "Doses de medicamentos"
+ "medicationDoses": "Doses de medicamentos",
+ "cycleDays": "Dias do ciclo",
+ "cycleDaysCount": "Dias do ciclo ({count})"
},
"integrationFallback": {
"description": "Configurado nesta conta sem um cartão de configurações próprio."
diff --git a/messages/ru.json b/messages/ru.json
index 6563de908..8a0760e91 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -6904,7 +6904,9 @@
"summary": "Типов данных: {count}, новейшие {relative}",
"quiet": "Без новых данных: {count}",
"workouts": "Тренировки",
- "medicationDoses": "Дозы лекарств"
+ "medicationDoses": "Дозы лекарств",
+ "cycleDays": "Дни цикла",
+ "cycleDaysCount": "Дни цикла ({count})"
},
"integrationFallback": {
"description": "Настроено в этом аккаунте без собственной карточки настроек."
diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts
index c25c41cf8..fb5d94b5b 100644
--- a/src/app/api/integrations/status/__tests__/route.test.ts
+++ b/src/app/api/integrations/status/__tests__/route.test.ts
@@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findFirst: vi.fn(async () => null) },
$queryRaw: vi.fn(async () => []),
workout: { groupBy: vi.fn(async () => []) },
+ cycleDayLog: { groupBy: vi.fn(async () => []) },
},
}));
diff --git a/src/components/settings/integrations/__tests__/garmin-card.test.tsx b/src/components/settings/integrations/__tests__/garmin-card.test.tsx
index b089ffe26..e2e73db2c 100644
--- a/src/components/settings/integrations/__tests__/garmin-card.test.tsx
+++ b/src/components/settings/integrations/__tests__/garmin-card.test.tsx
@@ -72,6 +72,25 @@ function connected(keyExpiry: GarminKeyExpiry | null) {
}
describe("GarminCard on the shared card", () => {
+ it("lists the imported cycle days in the details", () => {
+ const html = render({
+ connected: true,
+ configured: true,
+ health: healthFixture(),
+ keyExpiry: null,
+ metricFreshness: [
+ {
+ type: "CYCLE_DAYS",
+ lastSeenAt: "2026-10-20T12:00:00.000Z",
+ count: 42,
+ stale: false,
+ },
+ ],
+ });
+ expect(html).toContain('data-metric="CYCLE_DAYS"');
+ expect(html).toContain("Cycle days (42)");
+ });
+
it("shows the key form inside the shared card while not connected", () => {
const html = render({ connected: false, configured: false, health: null });
expect(html).toContain('data-slot="integration-card"');
diff --git a/src/components/settings/integrations/metric-freshness-list.tsx b/src/components/settings/integrations/metric-freshness-list.tsx
index 6fe93c55d..4742a46cd 100644
--- a/src/components/settings/integrations/metric-freshness-list.tsx
+++ b/src/components/settings/integrations/metric-freshness-list.tsx
@@ -32,6 +32,8 @@ import { cn } from "@/lib/utils";
const WORKOUT_TYPE = "WORKOUTS";
/** The pseudo-type carrying Apple Health medication doses. */
const MEDICATION_DOSE_TYPE = "MEDICATION_DOSE";
+/** The pseudo-type carrying the cycle days a wearable imported. */
+const CYCLE_DAY_TYPE = "CYCLE_DAYS";
/**
* Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS`
@@ -67,7 +69,11 @@ export function MetricFreshnessList<
if (!entries?.length) return null;
const reference = now ?? new Date();
- const label = (type: string): string => {
+ const label = (type: string, count?: number): string => {
+ if (type === CYCLE_DAY_TYPE)
+ return count === undefined
+ ? t("settings.integrationFreshness.cycleDays")
+ : t("settings.integrationFreshness.cycleDaysCount", { count });
if (type === WORKOUT_TYPE)
return t("settings.integrationFreshness.workouts");
if (type === MEDICATION_DOSE_TYPE)
@@ -142,7 +148,7 @@ export function MetricFreshnessList<
extra && !entry.stale && "text-foreground",
)}
>
- {label(entry.type)}
+ {label(entry.type, entry.count)}
+ ,
);
}
diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx
index aa37ccc41..e8bc4d4d0 100644
--- a/src/components/onboarding/getting-started-checklist.tsx
+++ b/src/components/onboarding/getting-started-checklist.tsx
@@ -39,7 +39,7 @@ import {
CHECKLIST_DISMISSED_ITEMS_KEY as DISMISSED_ITEMS_KEY,
CHECKLIST_EXPANDED_KEY as EXPANDED_KEY,
} from "@/lib/onboarding/checklist-storage";
-import { questionOptionLabelKey } from "@/lib/onboarding/question-config";
+import { sourceConnectionLabelKey } from "@/lib/onboarding/first-result-config";
import { isBrowserConnectableSource } from "@/lib/onboarding/wizard-steps";
const ITEM_ICONS: Record = {
@@ -547,9 +547,7 @@ export function GettingStartedChecklist() {
>
{item.id === "dataSource" && namedSource
? t("gettingStarted.items.dataSourceNamedTitle", {
- source: t(
- questionOptionLabelKey("sources", namedSource),
- ),
+ source: t(sourceConnectionLabelKey(namedSource)),
})
: t(labels.title)}
diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx
index 69f44b1b0..35bb1e594 100644
--- a/src/components/onboarding/question-screen.tsx
+++ b/src/components/onboarding/question-screen.tsx
@@ -70,6 +70,7 @@ const OPTION_HINTS: Readonly> = {
"who.both": "onboarding.flow.who.hints.both",
"sources.apple-health": "onboarding.flow.sources.hints.apple-health",
"sources.file": "onboarding.flow.sources.hints.file",
+ "sources.fitbit": "onboarding.flow.sources.hints.fitbit",
};
export function QuestionScreen({
diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts
index d05ee5f12..d018e3103 100644
--- a/src/lib/onboarding/__tests__/first-result-config.test.ts
+++ b/src/lib/onboarding/__tests__/first-result-config.test.ts
@@ -18,9 +18,25 @@ import {
connectSourceView,
deliveringSources,
SOURCE_INTEGRATION,
+ sourceConnectionLabelKey,
} from "../first-result-config";
import { ONBOARDING_SOURCE_KEYS } from "../needs";
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
+const EN: unknown = JSON.parse(
+ readFileSync(
+ join(__dirname, "..", "..", "..", "..", "messages", "en.json"),
+ "utf8",
+ ),
+);
+
+function resolves(key: string): boolean {
+ let node: unknown = EN;
+ for (const part of key.split(".")) {
+ if (typeof node !== "object" || node === null) return false;
+ node = (node as Record)[part];
+ }
+ return typeof node === "string" && node.length > 0;
+}
/**
* Connections the setup flow deliberately does not ask about, each with its
@@ -29,8 +45,11 @@ import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
* it.
*/
const NOT_AN_ONBOARDING_SOURCE: Record = {
+ // Google Health carries Fitbit devices, so its question is the Fitbit
+ // answer: people name the device they own, not the transport. The test
+ // below holds that the Fitbit answer really does lead there.
"google-health":
- "the successor transport behind the Fitbit answer, which still links the classic Fitbit card",
+ "reached through the Fitbit answer, whose task opens the Google Health card",
dawarich:
"movement context for the environment module, not a source of readings",
};
@@ -62,6 +81,47 @@ describe("first-result targets", () => {
}
});
+ it("routes the Fitbit answer to Google Health, not the retired classic card", () => {
+ // Fitbit's classic Web API was shut down on 2026-10-30. The answer stays,
+ // because people still own Fitbit devices; the connection is Google
+ // Health's, on the person's own Google OAuth client.
+ expect(ONBOARDING_SOURCE_KEYS).toContain("fitbit");
+ expect(BROWSER_CONNECTABLE_SOURCES).toContain("fitbit");
+ expect(SOURCE_INTEGRATION.fitbit).toMatchObject({
+ anchor: "google-health",
+ statusKey: "google-health",
+ credentials: "byo",
+ });
+ expect(CONNECTION_KEYS as readonly string[]).toContain(
+ SOURCE_INTEGRATION.fitbit.statusKey,
+ );
+ expect(sourceConnectionLabelKey("fitbit")).toBe("settings.googleHealth");
+ expect(resolves(sourceConnectionLabelKey("fitbit"))).toBe(true);
+ expect(resolves(SOURCE_INTEGRATION.fitbit.through!.noteKey)).toBe(true);
+ // Every other source is named and opened by its own answer.
+ for (const source of BROWSER_CONNECTABLE_SOURCES) {
+ if (source === "fitbit") continue;
+ expect(SOURCE_INTEGRATION[source].through, source).toBeUndefined();
+ expect(sourceConnectionLabelKey(source)).toBe(
+ `onboarding.flow.sources.options.${source}`,
+ );
+ }
+ });
+
+ it("reads a Fitbit answer's delivery from Google Health", () => {
+ const entry = (integration: string) => ({
+ integration,
+ connected: true,
+ configured: true,
+ syncHealth: { verdict: "fresh" as const, since: null },
+ lastSuccessAt: null,
+ });
+ expect(deliveringSources([entry("google-health")]).has("fitbit")).toBe(
+ true,
+ );
+ expect(deliveringSources([entry("fitbit")]).has("fitbit")).toBe(false);
+ });
+
it("offers Garmin as a source the browser connects with a pasted key", () => {
expect(BROWSER_CONNECTABLE_SOURCES).toContain("garmin");
expect(SOURCE_INTEGRATION.garmin).toEqual({
diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts
index 9c033b761..1a8f91ca7 100644
--- a/src/lib/onboarding/first-result-config.ts
+++ b/src/lib/onboarding/first-result-config.ts
@@ -10,6 +10,7 @@
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import type { OnboardingAreaKey } from "@/lib/modules/registry";
+import { questionOptionLabelKey } from "./question-config";
import {
BROWSER_CONNECTABLE_SOURCES,
type BrowserConnectableSource,
@@ -66,16 +67,32 @@ export const AREA_PAGE_HREF: Readonly<
*/
export type SourceCredentialModel = "byo" | "shared" | "self-hosted";
+/**
+ * How a source is connected when that is not the source's own card. The
+ * answer stays the device the person owns; the connection is made, named and
+ * read back through another integration.
+ */
+export interface SourceConnectedThrough {
+ /** The i18n key of the integration the connection is named after. */
+ labelKey: string;
+ /** The i18n key of the sentence saying why it goes through there. */
+ noteKey: string;
+}
+
+export interface SourceIntegrationTarget {
+ anchor: string;
+ statusKey: string;
+ credentials: SourceCredentialModel;
+ through?: SourceConnectedThrough;
+}
+
/**
* The connections panel's anchor for each connectable source — the `id` of
* the provider card on Settings → Integrations, and the key its status is
* published under on `/api/integrations/status`.
*/
export const SOURCE_INTEGRATION: Readonly<
- Record<
- BrowserConnectableSource,
- { anchor: string; statusKey: string; credentials: SourceCredentialModel }
- >
+ Record
> = Object.freeze({
withings: { anchor: "withings", statusKey: "withings", credentials: "byo" },
oura: { anchor: "oura", statusKey: "oura", credentials: "shared" },
@@ -87,7 +104,20 @@ export const SOURCE_INTEGRATION: Readonly<
statusKey: "garmin",
credentials: "self-hosted",
},
- fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" },
+ // Fitbit's classic Web API was shut down on 2026-10-30; Fitbit data now
+ // arrives through Google Health. The answer stays "Fitbit", because that is
+ // the device, and the task opens, reads and names the Google Health card.
+ // Google Health runs on the person's own Google OAuth client.
+ fitbit: {
+ anchor: "google-health",
+ statusKey: "google-health",
+ credentials: "byo",
+ through: {
+ labelKey: "settings.googleHealth",
+ noteKey:
+ "onboarding.flow.first-result.connect-source.throughGoogleHealth",
+ },
+ },
strava: { anchor: "strava", statusKey: "strava", credentials: "shared" },
nightscout: {
anchor: "nightscout",
@@ -96,6 +126,19 @@ export const SOURCE_INTEGRATION: Readonly<
},
});
+/**
+ * The i18n key a source's connection is named with: the integration it goes
+ * through when there is one, otherwise the person's own answer.
+ */
+export function sourceConnectionLabelKey(
+ source: BrowserConnectableSource,
+): string {
+ return (
+ SOURCE_INTEGRATION[source].through?.labelKey ??
+ questionOptionLabelKey("sources", source)
+ );
+}
+
/** Which tile the connect step renders. */
export type ConnectSourceSlot =
/** Nothing is connected and something can be: today's connect card. */
From 6800ff26203a4748fb81531415bd5df9f51a4dbb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 5 Nov 2026 00:00:03 +0100
Subject: [PATCH 16/55] fix(i18n): translate the stray word in the Russian
connect-source hint
---
messages/ru.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/messages/ru.json b/messages/ru.json
index bc55ce2a0..e269253d1 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -5335,7 +5335,7 @@
"connect-source": {
"title": "Подключить {target}",
"body": "Одно подключение, выбранное по вашим ответам. Затем измерения будут поступать сами.",
- "howTo": "Подключение к {target} выполняется на странице интеграций. Вернитесь сюда afterwards; этот экран заметит, когда всё будет готово.",
+ "howTo": "Подключение к {target} выполняется на странице интеграций. Потом вернитесь сюда: этот экран заметит, когда всё будет готово.",
"cta": "Подключить {target}",
"checking": "Проверка подключения.",
"waiting": "Пока не подключено.",
From e9886ee5542a90b359fcb846520db9fcd4e7b050 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 5 Nov 2026 00:52:02 +0100
Subject: [PATCH 17/55] fix(security): let the app frame its own blob previews
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.
Refs #1311
---
CHANGELOG.md | 5 ++++
.../baselines/source-file-lines.json | 4 +--
.../baselines/source-function-lines.json | 6 ++--
src/__tests__/proxy-csp-blob-preview.test.ts | 7 +++--
.../proxy-document-serve-framing.test.ts | 14 ++++-----
src/proxy.ts | 29 ++++++++-----------
6 files changed, 34 insertions(+), 31 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21a095de0..3bc13e6c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,11 @@
### Fixed
+- **PDF previews show in Safari.** On macOS and iOS the preview of an
+ uploaded PDF stayed an empty box. The page's security policy told the
+ browser that nothing may frame it, and Safari applied that to the
+ preview the page itself opens. The policy now allows framing by the app
+ itself and still refuses every other site. (#1311)
- **Periods from Garmin and Oura open their cycles.** Period days imported
from a watch or a ring were stored as days only, so the cycle tracker saw
no cycle start, no lengths and no forecast. They now open and fill their
diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json
index 6fdd36690..649b99be4 100644
--- a/src/__tests__/baselines/source-file-lines.json
+++ b/src/__tests__/baselines/source-file-lines.json
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
- "total": 94069,
+ "total": 94064,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -86,6 +86,6 @@
"lib/validations/inbound-documents.ts": 1018,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 933,
- "proxy.ts": 870
+ "proxy.ts": 865
}
}
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index b4776feb5..453a901c5 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
- "total": 111909,
+ "total": 111902,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -180,7 +180,7 @@
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
- "components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 425,
+ "components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
"components/onboarding/tour.tsx :: OnboardingTour": 544,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
@@ -347,6 +347,6 @@
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 337,
- "proxy.ts :: proxy": 498
+ "proxy.ts :: proxy": 493
}
}
diff --git a/src/__tests__/proxy-csp-blob-preview.test.ts b/src/__tests__/proxy-csp-blob-preview.test.ts
index 06811925f..c312ced2c 100644
--- a/src/__tests__/proxy-csp-blob-preview.test.ts
+++ b/src/__tests__/proxy-csp-blob-preview.test.ts
@@ -11,7 +11,7 @@ import { NextRequest } from "next/server";
* - `frame-src` exists and admits precisely 'self' and blob: — nothing else.
* - `img-src` admits blob: alongside 'self' and data:.
* - The boundaries that make blob: safe stay untouched: `object-src 'none'`,
- * `frame-ancestors 'none'`, `default-src 'self'`, nonce-bound script-src.
+ * `frame-ancestors 'self'`, `default-src 'self'`, nonce-bound script-src.
* - The vault SERVE route keeps its own stricter document CSP unchanged.
*
* Security rationale (mirrors the comment in proxy.ts): a `blob:` URL can only
@@ -73,7 +73,10 @@ describe("proxy.ts blob preview CSP (#787)", () => {
const res = proxy(makeRequest("/documents"));
const csp = res.headers.get("content-security-policy") ?? "";
expect(directive(csp, "object-src")).toEqual(["'none'"]);
- expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]);
+ // 'self', never 'none': the blob: preview inherits this policy, and
+ // WebKit applies the inherited frame-ancestors to the preview's own
+ // parent, so 'none' blanks every PDF preview in Safari.
+ expect(directive(csp, "frame-ancestors")).toEqual(["'self'"]);
expect(directive(csp, "default-src")).toEqual(["'self'"]);
expect(csp).toMatch(/script-src 'self' 'nonce-/);
});
diff --git a/src/__tests__/proxy-document-serve-framing.test.ts b/src/__tests__/proxy-document-serve-framing.test.ts
index a0353575c..cb1b7f263 100644
--- a/src/__tests__/proxy-document-serve-framing.test.ts
+++ b/src/__tests__/proxy-document-serve-framing.test.ts
@@ -4,8 +4,7 @@ import { NextRequest } from "next/server";
/**
* The document vault's decrypt-and-serve route is framed same-origin by the
* detail preview (`