From 3594b549d99f92bde5d11ec0e9aa078eec621b66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:02:37 +0200
Subject: [PATCH 01/79] feat(sleep): place sleep-debt and rhythm cards side by
side when both present
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When both the sleep-debt and chronotype cards carry a settled readout, render
them two-up on large screens instead of stacked; they are compact and read
better paired. A lone data-bearing card — or either card in its learning /
partial state — keeps full width so it never leaves a half-width orphan. The
grid collapses to one column below the lg breakpoint and uses items-start so a
toggled-open chronotype card does not stretch its neighbour.
---
.../__tests__/sleep-rhythm-section.test.tsx | 35 +++++++++++++++++++
.../insights/sleep/sleep-rhythm-section.tsx | 17 ++++++++-
2 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
index 696d6b98..63999282 100644
--- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
+++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
@@ -102,6 +102,41 @@ describe("", () => {
expect(html).toContain("md:py-6");
});
+ it("places the two cards side by side when both carry settled data", () => {
+ rhythmMock.mockReturnValue({
+ data: READY_DTO,
+ isLoading: false,
+ isError: false,
+ });
+ const html = render();
+ // Two-up on large screens, single column below.
+ expect(html).toContain("lg:grid-cols-2");
+ });
+
+ it("keeps a lone data-bearing card full width when the other is still learning", () => {
+ rhythmMock.mockReturnValue({
+ data: {
+ ...READY_DTO,
+ // Sleep-debt settled, chronotype still learning → no two-up grid.
+ chronotype: {
+ state: "learning",
+ msfMinutes: null,
+ msfScMinutes: null,
+ band: null,
+ socialJetlagMinutes: null,
+ freeNightsCounted: 2,
+ workNightsCounted: 5,
+ freeNightsUntilReady: 4,
+ },
+ } satisfies SleepRhythmDto,
+ isLoading: false,
+ isError: false,
+ });
+ const html = render();
+ expect(html).toContain("grid-cols-1");
+ expect(html).not.toContain("lg:grid-cols-2");
+ });
+
it("renders nothing when disabled", () => {
rhythmMock.mockReturnValue({
data: undefined,
diff --git a/src/components/insights/sleep/sleep-rhythm-section.tsx b/src/components/insights/sleep/sleep-rhythm-section.tsx
index 68c57837..a8846696 100644
--- a/src/components/insights/sleep/sleep-rhythm-section.tsx
+++ b/src/components/insights/sleep/sleep-rhythm-section.tsx
@@ -2,6 +2,7 @@
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { SleepDebtCard } from "./sleep-debt-card";
@@ -52,8 +53,22 @@ export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
);
}
+ // Both cards are compact; when each carries a settled (non-learning) readout
+ // they sit two-up. A lone card with data spans full width so it never leaves
+ // a half-width orphan with dead space beside it (mirrors the mood Einordnung
+ // tiles). The cards' own learning/partial states stay stacked full-width.
+ const debtHasData = data.sleepDebt.state === "ready";
+ const chronotypeHasData =
+ data.chronotype.state === "ready" && data.chronotype.band != null;
+ const bothHaveData = debtHasData && chronotypeHasData;
+
return (
-
+
From 8b5b35bbdd4fcdfc2eec146c9ad21307cd1ab29e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:03:28 +0200
Subject: [PATCH 02/79] docs(self-hosting): document the notification channels
and Web Push setup
---
docs/self-hosting/getting-started.md | 1 +
docs/self-hosting/notifications.md | 193 +++++++++++++++++++++++++++
2 files changed, 194 insertions(+)
create mode 100644 docs/self-hosting/notifications.md
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index e2bcdfb4..ca8abe9a 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -147,6 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
| Next step | File |
| --------- | ---- |
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
+| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` |
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md
new file mode 100644
index 00000000..affe53ab
--- /dev/null
+++ b/docs/self-hosting/notifications.md
@@ -0,0 +1,193 @@
+# Notifications — push channels for a self-host
+
+HealthLog delivers reminders (medication doses, mood check-ins, personal
+records) and a few operational alerts through a small cascade of push
+channels. The single message to take away: **you do not need an Apple
+Developer account or APNs to get push.** Three of the four channels are
+free, work in the browser or in a messaging app you already have, and
+cover every platform a self-hoster runs. APNs is only for the optional
+native iOS app.
+
+When an event fires, the dispatcher fans it out to every channel a user
+has enabled, in a fixed order — APNs first, then Telegram, then ntfy,
+then Web Push as the universal fallback
+(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and
+independent: a user can enable one, two, or all four, and a failure on
+one never blocks the others.
+
+## TL;DR — which channel needs what
+
+| Channel | What it needs | Cost | Where it works |
+| --- | --- | --- | --- |
+| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
+| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
+| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
+| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) |
+
+Web Push is the recommended default — it needs no third-party account,
+just a keypair you generate yourself once.
+
+## Web Push (recommended default)
+
+Web Push delivers notifications straight to a browser — desktop or
+mobile — even when the HealthLog tab is closed, as long as the browser
+or installed PWA is alive. It is the broadest-reach channel and the one
+to set up first.
+
+### 1. Generate a VAPID keypair
+
+VAPID is the signing scheme that lets a push service trust your server.
+Generate a keypair once with the bundled `web-push` CLI:
+
+```bash
+npx web-push generate-vapid-keys
+```
+
+That prints a **public key** and a **private key** (both Base64URL). You
+also choose a **subject** — a `mailto:` address the push service can
+contact about your sender, e.g. `mailto:you@example.com`.
+
+### 2. Store the keys
+
+The server loads VAPID config from the database first and falls back to
+environment variables (`src/lib/notifications/vapid-config.ts`). Either
+path works; the admin panel is the easier one because it survives without
+touching `.env`.
+
+**Admin panel (preferred).** Sign in as the admin user, open
+`/admin`, and find the **Web Push VAPID** card
+(`src/components/admin/web-push-vapid-section.tsx`). Paste the public key,
+the private key, and the subject, then save. The private key is encrypted
+at rest with your `ENCRYPTION_KEY`, exactly like the other secrets in the
+admin panel. The card shows a "configured" badge once all three fields
+are set.
+
+**Environment variables (alternative).** If you would rather keep
+secrets out of the database, set them in `.env` / the compose
+`environment:` block instead:
+
+```env
+VAPID_PUBLIC_KEY="BElx...(public key)"
+VAPID_PRIVATE_KEY="...(private key)"
+VAPID_SUBJECT="mailto:you@example.com"
+```
+
+The database values win when both are present. The loader also accepts a
+few aliases (`WEB_PUSH_VAPID_PUBLIC_KEY`, `WEB_PUSH_PUBLIC_KEY`, and the
+private/subject equivalents) so a config copied from another deployment
+keeps working.
+
+### 3. Subscribe a device
+
+Each user enables Web Push for their own browser from
+`/settings/notifications` — the **Browser Push** card has a Subscribe
+button, and a Test button to fire a one-off push once subscribed. The
+browser prompts for notification permission on first subscribe; once
+granted, that browser receives every enabled event.
+
+### iPhone caveat — Add to Home Screen first
+
+Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and
+only for a site that has been **installed to the Home Screen as a PWA**.
+A regular Safari tab cannot subscribe. So on iPhone:
+
+1. Open your HealthLog instance in Safari.
+2. Tap the Share button, then **Add to Home Screen**.
+3. Launch HealthLog from the new Home Screen icon (not from the Safari
+ tab).
+4. Open `/settings/notifications` inside that installed app and tap
+ Subscribe; accept the permission prompt.
+
+Android and desktop browsers have no such step — Subscribe and grant the
+permission, and push works.
+
+## Telegram
+
+Telegram delivers reminders as bot messages, with inline action buttons
+on medication reminders (Taken / snooze / skip). It is a good fit when
+you already live in Telegram or want push without running a browser.
+
+1. In Telegram, open **@BotFather**, create a bot, and copy the **bot
+ token** (it looks like `123456:ABC-DEF...`).
+2. Send `/start` to your new bot so it can message you.
+3. Find your **chat ID** — send a message to **@userinfobot**, or read it
+ from the Bot API.
+4. In HealthLog open `/settings/notifications`, fill the Telegram card's
+ **Bot Token** and **Chat ID**, toggle it on, and save.
+
+One bot can serve every user on the instance; each user pairs their own
+chat ID. The token is encrypted at rest.
+
+## ntfy
+
+[ntfy](https://ntfy.sh) is the most self-hoster-native option: a tiny
+pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You
+subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and
+HealthLog publishes to that topic.
+
+1. Pick a hard-to-guess topic name (the topic *is* the access control on
+ public `ntfy.sh` — anyone who knows the string can read it, so treat
+ it like a secret).
+2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/`.
+3. In HealthLog open `/settings/notifications`, set the ntfy card's
+ **Server URL** (defaults to `https://ntfy.sh`; point it at your own
+ server if you self-host ntfy) and **Topic**, then save.
+
+If your ntfy server requires auth, an access token can be attached; the
+server URL is validated as a public host and dialled through the egress
+guard (`src/lib/notifications/senders/ntfy.ts`).
+
+## APNs — native iOS app only
+
+Apple Push Notification service is used by **only** the native SwiftUI
+iOS app (TestFlight / App Store build). PWA and browser users never touch
+it. Setting it up requires a paid **Apple Developer Program** membership.
+
+You do **not** need APNs to give iPhone users push: the installed PWA
+gets every reminder over Web Push (see the iPhone caveat above). APNs adds
+one Apple-platform exclusive the PWA cannot offer — the lock-screen Live
+Activity — plus the tighter native delivery path. Reminders themselves
+arrive on both.
+
+If you do run the native app and want server-driven push, set the APNs
+manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers
+plus exactly one key source:
+
+| Variable | Purpose |
+| --- | --- |
+| `APNS_KEY_ID` | The `.p8` key's Key ID |
+| `APNS_TEAM_ID` | Your Apple Developer Team ID |
+| `APNS_BUNDLE_ID` | The app's bundle identifier |
+| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
+| `APNS_KEY` | OR the raw PEM body inline |
+| `APNS_KEY_FILE` | OR a path to the `.p8` file |
+
+APNs is **all-or-none**: either set the three IDs plus one key source, or
+leave the whole block unset. A partial config is rejected so the channel
+never silently half-works (`scripts/check-env.ts`). When more than one key
+source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` >
+`APNS_KEY_FILE`.
+
+Two gotchas worth knowing before you blame a stale token:
+
+- **Base64 the key.** Pasting a raw `.p8` PEM through some env-file
+ pipelines mangles its line breaks. `APNS_KEY_B64` (the PEM body,
+ base64-encoded) sidesteps that entirely and is the recommended source.
+- **Scope the key "Sandbox & Production".** In the Apple Developer
+ Portal, a push key defaults to Sandbox-only. A Sandbox-only key fails
+ on production (TestFlight / App Store) device tokens with
+ `BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox &
+ Production**; a wrongly scoped key cannot be reconfigured — re-issue it.
+
+## Which channel should I pick?
+
+| Your setup | Recommended channel |
+| --- | --- |
+| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
+| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. |
+| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. |
+| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
+
+Mixing channels is fine and common — e.g. Web Push on the laptop and
+ntfy on the phone. Each user configures their own set from
+`/settings/notifications`.
From 355fa9a67089cd54d5618243c2844cb85b9adf61 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:04:48 +0200
Subject: [PATCH 03/79] feat(schema): add per-user Polar/Oura credentials,
measurement reminders, lab results
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migration 0162 lands four additive surfaces for v1.17.1, all forward-only and
non-destructive.
- users: Polar and Oura BYO-app client id/secret, encrypted at app level and
stored as text, mirroring the existing WHOOP/Fitbit credential columns 1:1.
Each self-hoster registers their own Polar AccessLink / Oura Cloud app and
pastes the client id/secret into Settings before the OAuth connect flow.
- devices: live_activity_push_token for the iOS Live Activity update/end APNs
channel. The client owns the ActivityKit lifecycle; the server only stores
the most recent push token so the worker can address the update.
- measurement_reminders: preventive-care ("Vorsorge") reminders. Cadence is a
rolling interval_days or an RFC-5545 rrule, so the recurrence engine drives
the server-authoritative next_due_at. The completion target is an optional
measurement_type (auto-resolved when a matching reading lands) or a free-text
label that resolves on a manual check-off. Soft-deleted.
- lab_results: minimal structured lab store — one analyte per row, optional
panel grouping and reference range, optional encrypted note — so a returned
blood panel has somewhere to land alongside the Vorsorge reminder.
No enum change: the Polar/Oura/Nightscout measurement sources already landed in
migration 0160.
---
.../migration.sql | 118 ++++++++++++++++
prisma/schema.prisma | 129 ++++++++++++++++++
2 files changed, 247 insertions(+)
create mode 100644 prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql
diff --git a/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql
new file mode 100644
index 00000000..8a7615be
--- /dev/null
+++ b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql
@@ -0,0 +1,118 @@
+-- v1.17.1 — schema foundation for per-user Polar/Oura BYO-app credentials,
+-- preventive-care (Vorsorge) measurement reminders, a minimal structured lab
+-- store, and the iOS Live Activity push-token channel (#22).
+--
+-- Purely-additive: four new nullable columns on `users` (encrypted client
+-- credentials), one new nullable column on `devices`, and two new tables. No
+-- backfill, no existing row touched, no enum change (Polar/Oura/Nightscout
+-- measurement sources already landed in migration 0160).
+--
+-- 1. `users` += Polar + Oura BYO-app client id/secret (encrypted at app
+-- level, stored as TEXT), mirroring the WHOOP/Fitbit credential columns.
+-- 2. `devices` += `live_activity_push_token` for the iOS Live Activity
+-- update/end APNs channel (#22). NULL when no active Live Activity.
+-- 3. `measurement_reminders` — preventive-care reminders. Cadence is a
+-- rolling `interval_days` OR an RFC-5545 `rrule`; the recurrence engine
+-- drives `next_due_at` (server-authoritative). Completion target is an
+-- optional `measurement_type` (auto-resolve) or a free-text `label`.
+-- Soft-deleted.
+-- 4. `lab_results` — one analyte per row, optional panel grouping + optional
+-- reference range, optional encrypted note. Pairs with the Vorsorge
+-- "annual blood panel" reminder so a result has somewhere to land.
+-- Soft-deleted.
+--
+-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
+--
+-- Reversibility: every column drops with `DROP COLUMN IF EXISTS`; both tables
+-- drop with `DROP TABLE IF EXISTS`. No data is rewritten.
+
+-- ── 1. users — Polar + Oura BYO-app client credentials (encrypted) ─────
+ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "polar_client_id_encrypted" TEXT;
+ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "polar_client_secret_encrypted" TEXT;
+ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "oura_client_id_encrypted" TEXT;
+ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "oura_client_secret_encrypted" TEXT;
+
+-- ── 2. devices — iOS Live Activity push token (#22) ────────────────────
+ALTER TABLE "devices"
+ ADD COLUMN IF NOT EXISTS "live_activity_push_token" TEXT;
+
+-- ── 3. measurement_reminders — preventive-care (Vorsorge) reminders ────
+CREATE TABLE IF NOT EXISTS "measurement_reminders" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "measurement_type" "MeasurementType",
+ "interval_days" INTEGER,
+ "rrule" TEXT,
+ "anchor_date" TIMESTAMP(3),
+ "notify_hour" INTEGER NOT NULL DEFAULT 9,
+ "location" TEXT,
+ "next_due_at" TIMESTAMP(3),
+ "last_satisfied_at" TIMESTAMP(3),
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "deleted_at" TIMESTAMP(3),
+
+ CONSTRAINT "measurement_reminders_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_deleted_at_idx"
+ ON "measurement_reminders" ("user_id", "deleted_at");
+CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_next_due_at_idx"
+ ON "measurement_reminders" ("user_id", "next_due_at");
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'measurement_reminders_user_id_fkey'
+ ) THEN
+ ALTER TABLE "measurement_reminders"
+ ADD CONSTRAINT "measurement_reminders_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users" ("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+-- ── 4. lab_results — minimal structured lab store ──────────────────────
+CREATE TABLE IF NOT EXISTS "lab_results" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "panel" TEXT,
+ "analyte" TEXT NOT NULL,
+ "value" DOUBLE PRECISION NOT NULL,
+ "unit" TEXT NOT NULL,
+ "reference_low" DOUBLE PRECISION,
+ "reference_high" DOUBLE PRECISION,
+ "taken_at" TIMESTAMP(3) NOT NULL,
+ "source" TEXT NOT NULL DEFAULT 'MANUAL',
+ "note_encrypted" BYTEA,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "deleted_at" TIMESTAMP(3),
+
+ CONSTRAINT "lab_results_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX IF NOT EXISTS "lab_results_user_id_analyte_taken_at_idx"
+ ON "lab_results" ("user_id", "analyte", "taken_at");
+CREATE INDEX IF NOT EXISTS "lab_results_user_id_deleted_at_idx"
+ ON "lab_results" ("user_id", "deleted_at");
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'lab_results_user_id_fkey'
+ ) THEN
+ ALTER TABLE "lab_results"
+ ADD CONSTRAINT "lab_results_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users" ("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ba56aa38..715611d1 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -127,12 +127,26 @@ model User {
// tokens are stored encrypted.
polarAccessTokenEncrypted String? @map("polar_access_token_encrypted")
polarUserIdEncrypted String? @map("polar_user_id_encrypted")
+ // v1.17.1 — Polar BYO-app client credentials (W1c, per-user, encrypted at
+ // app level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1
+ // so a self-hoster pastes their own Polar AccessLink app's client id/secret
+ // into Settings before the OAuth connect flow runs. Stored encrypted as a
+ // string via `src/lib/crypto.ts`, matching every other `*Encrypted` column.
+ polarClientIdEncrypted String? @map("polar_client_id_encrypted")
+ polarClientSecretEncrypted String? @map("polar_client_secret_encrypted")
// v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level).
// Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored
// encrypted.
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
+ // v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app
+ // level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a
+ // self-hoster pastes their own Oura Cloud app's client id/secret into Settings
+ // before the OAuth connect flow runs. Stored encrypted as a string via
+ // `src/lib/crypto.ts`, matching every other `*Encrypted` column.
+ ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
+ ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
@@ -527,6 +541,15 @@ model User {
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
insightNarratives InsightNarrative[]
+ // v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
+ // per user-created reminder ("BP every 7 days", "annual blood panel").
+ // Server computes the canonical `nextDueAt`; the cron fires through the
+ // existing notification dispatcher. Soft-deleted.
+ measurementReminders MeasurementReminder[]
+ // v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
+ // that pairs with the Vorsorge "annual blood panel" reminder so a result
+ // has somewhere to land. Soft-deleted.
+ labResults LabResult[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -3090,6 +3113,14 @@ model Device {
/// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to
/// all the user's devices and iOS dedupes locally).
medicationDelivery String? @map("medication_delivery")
+ /// v1.17.1 (#22) — APNs push token for the iOS Live Activity update/end
+ /// channel. ActivityKit issues a per-Activity push token distinct from the
+ /// device APNs token; the server stores the most recent one so the worker
+ /// can push a Live Activity content-state update or an end signal. NULL when
+ /// the device has no active Live Activity. The iOS client owns the
+ /// ActivityKit lifecycle and registers/clears this token; no server-side
+ /// behaviour hangs off it beyond addressing the update push.
+ liveActivityPushToken String? @map("live_activity_push_token")
lastSeen DateTime @default(now()) @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -3105,6 +3136,104 @@ model Device {
@@map("devices")
}
+// ─── Measurement / Vorsorge reminders ────────────────────────
+
+/// v1.17.1 — preventive-care ("Vorsorge") reminders: "measure BP every 7
+/// days", "annual blood panel", "log weight weekly". Reuses the existing
+/// medication-reminder cadence vocabulary (`intervalDays` rolling cadence OR
+/// an RFC-5545 `rrule`) so the recurrence engine drives the due-date math, and
+/// rides the existing notification dispatcher + `push_attempts` ledger. The
+/// completion target is either a `measurementType` (auto-resolved when a
+/// matching measurement lands) or a free-text `label` for non-measurement
+/// Vorsorge that only resolves on a manual check-off. `nextDueAt` is
+/// server-computed + stored (server-authoritative — iOS consumes the resolved
+/// value, never recomputes). Soft-deleted.
+model MeasurementReminder {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ /// User-facing copy: "Blutdruck messen", "Großes Blutbild".
+ label String
+ /// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood
+ /// panel, a dermatology check) with no measurement to match against; those
+ /// resolve only on a manual satisfy. Non-NULL = the cron advances
+ /// `lastSatisfiedAt` when a matching Measurement of this type lands.
+ measurementType MeasurementType? @map("measurement_type")
+ /// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence
+ /// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in
+ /// practice; both NULL is a one-shot reminder anchored on `anchorDate`.
+ intervalDays Int? @map("interval_days")
+ /// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through
+ /// the same recurrence engine the medication schedules use.
+ rrule String?
+ /// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt`
+ /// when NULL.
+ anchorDate DateTime? @map("anchor_date")
+ /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour.
+ /// DST-safe windowing is applied at the cron via the existing TZ helpers.
+ notifyHour Int @default(9) @map("notify_hour")
+ /// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional.
+ location String?
+ /// Server-computed canonical next-due instant (server-authoritative).
+ nextDueAt DateTime? @map("next_due_at")
+ /// When the reminder was last fulfilled (a matching measurement or a manual
+ /// satisfy). Re-anchors the next-due computation.
+ lastSatisfiedAt DateTime? @map("last_satisfied_at")
+ /// Master on/off for this reminder. Distinct from soft-delete: a disabled
+ /// reminder is retained + editable but never fires.
+ enabled Boolean @default(true)
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
+ deletedAt DateTime? @map("deleted_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId, deletedAt])
+ @@index([userId, nextDueAt])
+ @@map("measurement_reminders")
+}
+
+// ─── Lab results ─────────────────────────────────────────────
+
+/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
+/// blood panel" reminder. HealthLog has no first-class blood-biomarker type
+/// beyond glucose, so a self-hoster who gets a panel back had nowhere to record
+/// LDL / HbA1c / ferritin / TSH. This is the lean storage row: one analyte per
+/// row, optional panel grouping, optional reference range. The doctor-report /
+/// charting wiring lands in sibling work — this model only owns the shape.
+/// Soft-deleted.
+model LabResult {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ /// Optional grouping label, e.g. "Großes Blutbild". NULL = standalone value.
+ panel String?
+ /// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin".
+ analyte String
+ value Float
+ unit String
+ /// Optional reference-range bounds as reported by the lab.
+ referenceLow Float? @map("reference_low")
+ referenceHigh Float? @map("reference_high")
+ /// When the sample was taken (UTC instant; wall-clock anchoring is the
+ /// reader's concern per the schema's UTC-only convention).
+ takenAt DateTime @map("taken_at")
+ /// Provenance: "MANUAL" today; import paths set their own source string.
+ source String @default("MANUAL")
+ /// Optional free-text note, AES-256-GCM encrypted at rest via
+ /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per convention).
+ noteEncrypted Bytes? @map("note_encrypted")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
+ deletedAt DateTime? @map("deleted_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId, analyte, takenAt])
+ @@index([userId, deletedAt])
+ @@map("lab_results")
+}
+
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
From bc12633569f322f7bfd9830955f7bc42750bef90 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:16:09 +0200
Subject: [PATCH 04/79] fix(sleep): reconstruct an ordered WHOOP hypnogram
timeline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WHOOP's v2 API returns only per-stage sleep duration totals — no onset
timestamps, no hypnogram endpoint. The mapper stamped all five stage
totals on the one sleep-END instant, so the hypnogram reconstructed
every stage as a span touching the night's right edge: bars stacked
right-aligned with no clock times whenever WHOOP was the sole source.
Lay the asleep stages back-to-back from sleep onset in a fixed
physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one
timed row per segment with measuredAt at that segment's end and a
distinct indexed externalId. IN_BED stays a single envelope row over the
whole window. The existing reader and renderer are untouched — they now
see distinct per-segment instants and place each block at its own time.
The order is synthesised, not measured, so every reconstructed row is
flagged and the night DTO carries a per-session `reconstructed` boolean
(GET /api/sleep/night, regenerated OpenAPI). The client renders the
hypnogram but labels it an approximate layout and never recomputes;
real-series sources (Apple Health, Withings, Fitbit) stay false.
---
docs/api/openapi.yaml | 12 +++
src/app/api/sleep/night/route.ts | 9 ++
.../analytics/__tests__/sleep-night.test.ts | 42 +++++++++
src/lib/analytics/sleep-night.ts | 29 ++++++
src/lib/openapi/routes/measurements.ts | 5 +
src/lib/whoop/__tests__/client.test.ts | 67 +++++++++++--
src/lib/whoop/client.ts | 93 +++++++++++++++----
src/lib/whoop/sync-sleep.ts | 19 +++-
8 files changed, 246 insertions(+), 30 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index bf628854..e1fd8c8c 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -8982,6 +8982,11 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
+ reconstructed:
+ type: boolean
+ description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in
+ a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate
+ layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).
stages:
type: object
propertyNames:
@@ -9047,6 +9052,7 @@ components:
- inBedMinutes
- awakeMinutes
- awakenings
+ - reconstructed
- stages
- segments
additionalProperties: false
@@ -9102,6 +9108,11 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
+ reconstructed:
+ type: boolean
+ description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in
+ a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate
+ layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).
stages:
type: object
propertyNames:
@@ -9167,6 +9178,7 @@ components:
- inBedMinutes
- awakeMinutes
- awakenings
+ - reconstructed
- stages
- segments
additionalProperties: false
diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts
index a930d3a3..29d8874f 100644
--- a/src/app/api/sleep/night/route.ts
+++ b/src/app/api/sleep/night/route.ts
@@ -12,6 +12,11 @@
* priority ladder — so two sources' timelines never overlay. A view over the
* existing per-stage `SLEEP_DURATION` rows; no schema, no new type.
*
+ * `reconstructed` (per session) is true when the winning source has no
+ * per-stage onset timestamps and the server synthesised a contiguous timeline
+ * (WHOOP) — the client renders the hypnogram but labels it an approximate
+ * layout. A real-series source (Apple Health / Withings / Fitbit) is false.
+ *
* `date` omitted → the most recent night in the trailing-year window.
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
@@ -64,6 +69,10 @@ function serializeSession(s: SleepSession) {
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
awakenings: s.awakenings,
+ // iOS #18 — `reconstructed` is true when the source has no per-stage onset
+ // timestamps and the server synthesised a contiguous timeline (WHOOP). iOS
+ // labels the hypnogram as an approximate layout; it never recomputes.
+ reconstructed: s.reconstructed,
// iOS #18 — round the per-stage map too; the underlying totals sum
// second-precision segments and otherwise serialise as e.g. 88.4999.
stages: Object.fromEntries(
diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts
index 17b32973..00221cea 100644
--- a/src/lib/analytics/__tests__/sleep-night.test.ts
+++ b/src/lib/analytics/__tests__/sleep-night.test.ts
@@ -380,6 +380,48 @@ describe("reconstructSleepSessions", () => {
expect(sessions[0].asleepMinutes).toBe(420);
});
+ it("flags a WHOOP-won night as reconstructed (synthetic stage order) with clock-time-distinct segments", () => {
+ // The ingest mapper reconstructs an ordered, contiguous WHOOP timeline.
+ // The reconstructed segments carry distinct measuredAt instants, so the
+ // hypnogram lays them at distinct clock times (no shared right edge).
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), // 21:00→01:00
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "WHOOP"), // 01:00→03:00
+ srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "WHOOP"), // 03:00→05:00
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions).toHaveLength(1);
+ const s = sessions[0];
+ expect(s.source).toBe("WHOOP");
+ expect(s.reconstructed).toBe(true);
+ // No two segments end on the same instant — not stacked on the right edge.
+ const ends = s.segments.map((seg) => seg.end.getTime());
+ expect(new Set(ends).size).toBe(ends.length);
+ });
+
+ it("does NOT flag an Apple-Health night as reconstructed (measured timeline)", () => {
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "APPLE_HEALTH"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].source).toBe("APPLE_HEALTH");
+ expect(sessions[0].reconstructed).toBe(false);
+ });
+
+ it("does NOT flag a Withings night as reconstructed (real per-segment series)", () => {
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WITHINGS"),
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WITHINGS"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].source).toBe("WITHINGS");
+ expect(sessions[0].reconstructed).toBe(false);
+ });
+
it("drops the bare ASLEEP aggregate from segments + stages when granular exists (HIGH H1, v1.11.5)", () => {
// Apple-Health shape: granular CORE/DEEP/REM PLUS the unspecified ASLEEP
// aggregate covering the same period, plus IN_BED. The hypnogram must not
diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts
index 590361df..88024e6c 100644
--- a/src/lib/analytics/sleep-night.ts
+++ b/src/lib/analytics/sleep-night.ts
@@ -281,6 +281,19 @@ export interface SleepNight {
/** Sentinel for rows that carry no source — collapses to one bucket. */
const NO_SOURCE = "__none__";
+/**
+ * Sources whose per-segment stage ORDER is synthesised, not measured. WHOOP's
+ * v2 API exposes only per-stage duration totals (no onset timestamps), so the
+ * ingest mapper reconstructs a contiguous timeline in a fixed physiological
+ * order (`whoop/client.ts` `mapSleep`). A session won by such a source carries
+ * `reconstructed: true` so the UI labels it as an approximate layout. Apple
+ * Health / Withings / Fitbit all store a real per-segment series and stay off
+ * this set.
+ */
+const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet = new Set([
+ "WHOOP",
+]);
+
/**
* Separator between the source and device-type parts of a writer key.
* ` ` cannot appear in a `MeasurementSource` enum literal or a
@@ -615,6 +628,17 @@ export interface SleepSession {
awakenings: number;
/** The canonical source's segments, sorted by start, for the hypnogram. */
segments: SleepSegment[];
+ /**
+ * `true` when the winning writer's stage ORDER is synthesised rather than
+ * measured. WHOOP's v2 API returns only per-stage duration totals — no onset
+ * timestamps — so the server reconstructs a contiguous timeline in a fixed
+ * physiological order (`whoop/client.ts` `mapSleep`). The segment positions
+ * are then approximate, not measured, and the UI / iOS must label the night
+ * as an approximate layout (never present it as measured stage timing).
+ * Sources that store a real per-segment series (Apple Health, Withings,
+ * Fitbit) stay `false`.
+ */
+ reconstructed: boolean;
}
/** Resolve a stage row to its absolute span (start = end − duration). */
@@ -760,6 +784,11 @@ export function reconstructSleepSessions(
stages,
awakenings: countAwakenings(segments),
segments,
+ // The winning writer's stage order is synthetic when its source
+ // reconstructs the timeline (WHOOP). The UI labels such a night as an
+ // approximate layout; a real-series source (Apple/Withings/Fitbit) is
+ // measured.
+ reconstructed: RECONSTRUCTED_TIMELINE_SOURCES.has(canonicalSource),
});
}
return sessions.sort((a, b) => a.start.getTime() - b.start.getTime());
diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts
index 15de359a..9dbb9266 100644
--- a/src/lib/openapi/routes/measurements.ts
+++ b/src/lib/openapi/routes/measurements.ts
@@ -200,6 +200,11 @@ const sleepSessionResource = z.object({
inBedMinutes: z.number().int().nonnegative().nullable(),
awakeMinutes: z.number().int().nonnegative().nullable(),
awakenings: z.number().int().nonnegative(),
+ reconstructed: z
+ .boolean()
+ .describe(
+ "True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).",
+ ),
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
segments: z.array(sleepSegmentResource),
});
diff --git a/src/lib/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts
index 74d3f12b..385ce900 100644
--- a/src/lib/whoop/__tests__/client.test.ts
+++ b/src/lib/whoop/__tests__/client.test.ts
@@ -320,15 +320,64 @@ describe("mapSleep", () => {
it("maps per-stage SLEEP_DURATION rows (ms→min) with sleepStage", () => {
const rows = mapSleep(base);
const dur = rows.filter((r) => r.type === "SLEEP_DURATION");
- const byStage = Object.fromEntries(dur.map((r) => [r.sleepStage, r.value]));
- expect(byStage.CORE).toBe(240); // light → CORE
- expect(byStage.DEEP).toBe(90); // slow-wave → DEEP
- expect(byStage.REM).toBe(120);
- expect(byStage.AWAKE).toBe(30);
- expect(byStage.IN_BED).toBe(480);
+ // One asleep/awake row per reconstructed segment + one IN_BED envelope. The
+ // per-stage TOTALS are unchanged (the reconstruction only reorders timing).
+ const minutesByStage: Record = {};
+ for (const r of dur) {
+ minutesByStage[r.sleepStage!] =
+ (minutesByStage[r.sleepStage!] ?? 0) + r.value;
+ }
+ expect(minutesByStage.CORE).toBe(240); // light → CORE
+ expect(minutesByStage.DEEP).toBe(90); // slow-wave → DEEP
+ expect(minutesByStage.REM).toBe(120);
+ expect(minutesByStage.AWAKE).toBe(30);
+ expect(minutesByStage.IN_BED).toBe(480);
expect(dur.every((r) => r.unit === "minutes")).toBe(true);
});
+ it("reconstructs an ordered, contiguous, non-overlapping per-segment timeline from sleep ONSET", () => {
+ const segs = mapSleep(base)
+ .filter((r) => r.type === "SLEEP_DURATION" && r.sleepStage !== "IN_BED")
+ .map((r) => ({
+ stage: r.sleepStage,
+ end: r.measuredAt.getTime(),
+ start: r.measuredAt.getTime() - r.value * 60_000,
+ reconstructed: r.reconstructed,
+ }))
+ .sort((a, b) => a.start - b.start);
+
+ // AWAKE (lead) → CORE → DEEP → REM, laid back-to-back.
+ expect(segs.map((s) => s.stage)).toEqual(["AWAKE", "CORE", "DEEP", "REM"]);
+ // First segment starts at sleep ONSET (s.start), not the END edge.
+ expect(segs[0].start).toBe(new Date(base.start).getTime());
+ // Contiguous + non-overlapping: each segment's start == previous end.
+ for (let i = 1; i < segs.length; i++) {
+ expect(segs[i].start).toBe(segs[i - 1].end);
+ }
+ // Distinct END instants — no stacking on the night's right edge.
+ expect(new Set(segs.map((s) => s.end)).size).toBe(segs.length);
+ // Every reconstructed row is flagged so the UI labels the night approximate.
+ expect(segs.every((s) => s.reconstructed === true)).toBe(true);
+ });
+
+ it("emits IN_BED as a single un-reconstructed envelope row over [start, end]", () => {
+ const inBed = mapSleep(base).filter((r) => r.sleepStage === "IN_BED");
+ expect(inBed).toHaveLength(1);
+ const r = inBed[0]!;
+ expect(r.value).toBe(480);
+ // measuredAt = sleep END → segmentOf resolves the span back to the window.
+ expect(r.measuredAt.toISOString()).toBe(base.end);
+ expect(r.reconstructed).toBeUndefined();
+ });
+
+ it("gives each reconstructed segment a distinct indexed externalId", () => {
+ const ids = mapSleep(base)
+ .filter((r) => r.reconstructed)
+ .map((r) => r.externalId);
+ expect(ids.every((id) => id?.startsWith("sleep-uuid:seg:"))).toBe(true);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
it("sums SLEEP_NEED components ms→min", () => {
const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED");
expect(need?.value).toBe(490); // 450 + 30 + 10 + 0
@@ -344,9 +393,9 @@ describe("mapSleep", () => {
expect(byType.RESPIRATORY_RATE?.unit).toBe("breaths/min");
});
- it("uses sleep.end as measuredAt", () => {
- const row = mapSleep(base)[0]!;
- expect(row.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z");
+ it("stamps the non-segment scores on sleep.end", () => {
+ const need = mapSleep(base).find((r) => r.type === "SLEEP_NEED")!;
+ expect(need.measuredAt.toISOString()).toBe("2026-06-01T07:00:00.000Z");
});
it("emits nothing for an unscored sleep", () => {
diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts
index a75ad1e0..19c30ddc 100644
--- a/src/lib/whoop/client.ts
+++ b/src/lib/whoop/client.ts
@@ -505,20 +505,24 @@ export interface MappedMeasurement {
fieldTag: string;
/** Per-stage sleep rows carry the SleepStage; everything else omits it. */
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
+ /**
+ * Full externalId for rows the mapper must key itself rather than letting
+ * the sync layer build `:`. The reconstructed sleep
+ * segments (one row per synthetic span) need an index in the key so the
+ * several rows of one stage stay distinct under
+ * `userId_type_source_externalId`. When set the sync layer uses it verbatim.
+ */
+ externalId?: string;
+ /**
+ * `true` on the synthesised per-segment sleep rows whose ORDER is invented:
+ * WHOOP's v2 API returns only per-stage duration totals (no onset
+ * timestamps), so the timeline is reconstructed in a fixed physiological
+ * order. The night is flagged so the UI labels it as an approximate layout
+ * and never presents it as measured stage timing.
+ */
+ reconstructed?: boolean;
}
-/** WHOOP sleep stage → HealthLog SleepStage (light→CORE, slow-wave→DEEP). */
-const SLEEP_STAGE_MAP: Record<
- string,
- { stage: MappedMeasurement["sleepStage"]; fieldTag: string }
-> = {
- total_light_sleep_time_milli: { stage: "CORE", fieldTag: "sleep_core" },
- total_slow_wave_sleep_time_milli: { stage: "DEEP", fieldTag: "sleep_deep" },
- total_rem_sleep_time_milli: { stage: "REM", fieldTag: "sleep_rem" },
- total_awake_time_milli: { stage: "AWAKE", fieldTag: "sleep_awake" },
- total_in_bed_time_milli: { stage: "IN_BED", fieldTag: "sleep_in_bed" },
-};
-
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
@@ -589,16 +593,71 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
const out: MappedMeasurement[] = [];
const stages = s.score.stage_summary;
- for (const [key, mapping] of Object.entries(SLEEP_STAGE_MAP)) {
- const ms = stages[key as keyof typeof stages];
- if (typeof ms !== "number") continue;
+
+ // WHOOP v2 returns only per-stage DURATION totals (no per-stage onset
+ // timestamps, no hypnogram endpoint). Stamping every stage total on the one
+ // sleep-END instant makes the hypnogram reconstruct five spans that all touch
+ // the night's right edge (the stacked-bar artefact). Since the API never
+ // carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the
+ // asleep stages back-to-back from sleep ONSET in a fixed physiological order,
+ // emitting one timed row per segment with `measuredAt = that segment's END`.
+ // The ORDER is synthetic, so every reconstructed row is flagged
+ // `reconstructed: true` and the night DTO advertises it — the UI labels it as
+ // an approximate layout and never presents it as measured stage timing.
+ //
+ // IN_BED stays a single envelope row spanning the whole [start, end] window:
+ // the in-bed reader wants the union envelope, not a placed segment. AWAKE is
+ // laid as a leading segment from onset so the asleep stages still partition
+ // the remaining window.
+ const onset = new Date(s.start).getTime();
+ // Order the laid-out stages: a brief AWAKE settling-in block, then the asleep
+ // stages CORE → DEEP → REM. Each carries its own duration; the cursor walks
+ // forward so the spans are contiguous and non-overlapping.
+ const TIMELINE_ORDER: ReadonlyArray<{
+ key: keyof typeof stages;
+ stage: NonNullable;
+ tag: string;
+ }> = [
+ { key: "total_awake_time_milli", stage: "AWAKE", tag: "sleep_awake" },
+ { key: "total_light_sleep_time_milli", stage: "CORE", tag: "sleep_core" },
+ { key: "total_slow_wave_sleep_time_milli", stage: "DEEP", tag: "sleep_deep" },
+ { key: "total_rem_sleep_time_milli", stage: "REM", tag: "sleep_rem" },
+ ];
+ let cursor = onset;
+ let segIndex = 0;
+ for (const { key, stage, tag } of TIMELINE_ORDER) {
+ const ms = stages[key];
+ if (typeof ms !== "number" || ms <= 0) continue;
+ const segEnd = cursor + ms;
out.push({
type: "SLEEP_DURATION",
value: round2(ms * MS_TO_MIN),
unit: "minutes",
+ measuredAt: new Date(segEnd),
+ // The sync layer keys reconstructed rows by this externalId verbatim; the
+ // index keeps the several rows of one night distinct under
+ // userId_type_source_externalId.
+ fieldTag: tag,
+ externalId: `${s.id}:seg:${tag}:${segIndex}`,
+ sleepStage: stage,
+ reconstructed: true,
+ });
+ cursor = segEnd;
+ segIndex += 1;
+ }
+
+ // IN_BED — single envelope row over the whole sleep window. `measuredAt` is
+ // the sleep END so `segmentOf` resolves the span back to [start, end]; the
+ // in-bed union-envelope reader consumes the full window.
+ const inBedMs = stages.total_in_bed_time_milli;
+ if (typeof inBedMs === "number" && inBedMs > 0) {
+ out.push({
+ type: "SLEEP_DURATION",
+ value: round2(inBedMs * MS_TO_MIN),
+ unit: "minutes",
measuredAt,
- fieldTag: mapping.fieldTag,
- sleepStage: mapping.stage,
+ fieldTag: "sleep_in_bed",
+ sleepStage: "IN_BED",
});
}
diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts
index 234052ad..a9717317 100644
--- a/src/lib/whoop/sync-sleep.ts
+++ b/src/lib/whoop/sync-sleep.ts
@@ -4,8 +4,14 @@
* `mapSleep` (per-stage SLEEP_DURATION rows, SLEEP_NEED, the SLEEP_*
* percentages, RESPIRATORY_RATE), and upserts as `source = WHOOP`.
*
- * Per-stage rows carry the `sleepStage` axis so the five stage rows for one
- * night stay distinct under the dedup key. externalId = `:`.
+ * Sleep rows carry the `sleepStage` axis so the night's rows stay distinct
+ * under the dedup key. WHOOP exposes only per-stage DURATION totals (no onset
+ * timestamps), so `mapSleep` RECONSTRUCTS an ordered, contiguous per-segment
+ * timeline (CORE/DEEP/REM/AWAKE laid back-to-back from sleep onset) and flags
+ * those rows `reconstructed`; each reconstructed segment carries its own
+ * indexed externalId `:seg::`. IN_BED stays a single
+ * envelope row keyed `:sleep_in_bed`. The non-segment scores keep
+ * the `:` shape.
*/
import { fetchSleeps, fetchSleepById, mapSleep } from "./client";
import {
@@ -53,7 +59,10 @@ export async function syncUserSleep(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
- externalId: `${s.id}:${m.fieldTag}`,
+ // Reconstructed sleep segments carry their own indexed externalId so
+ // the several rows of one stage stay distinct; everything else keeps
+ // the `:` shape.
+ externalId: m.externalId ?? `${s.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
@@ -92,7 +101,9 @@ export async function syncWhoopSleepById(
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
- externalId: `${record.id}:${m.fieldTag}`,
+ // Reconstructed sleep segments carry their own indexed externalId
+ // (see syncUserSleep).
+ externalId: m.externalId ?? `${record.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
From bd467d0c4daf4daf724207d308d08986ce25548b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:16:16 +0200
Subject: [PATCH 05/79] fix(sleep): emit Fitbit sleep as per-segment rows, not
a stage collapse
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Google sleep payload carries a real per-stage segment series, but the
mapper summed each stage's segments into one row stamped on the stage's
last end. The hypnogram then reconstructed all of a stage's time as a
single block ending at the night's right edge — the same stacked-bar
artefact as WHOOP, despite the source carrying true timing.
Emit one row per segment with measuredAt at that segment's end and an
indexed externalId so the several segments of one stage stay distinct
under the dedup key. The timeline is measured (real onsets), so these
rows are not flagged reconstructed.
---
src/lib/fitbit/__tests__/client.test.ts | 42 +++++++++++++---------
src/lib/fitbit/client.ts | 46 +++++++++++--------------
src/lib/fitbit/sync-sleep.ts | 19 +++++-----
3 files changed, 56 insertions(+), 51 deletions(-)
diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts
index 38b54aee..9edb6b4e 100644
--- a/src/lib/fitbit/__tests__/client.test.ts
+++ b/src/lib/fitbit/__tests__/client.test.ts
@@ -526,7 +526,7 @@ describe("sleep-stage mapping", () => {
expect(mapFitbitSleepStage(42)).toBeNull();
});
- it("maps a session into per-stage SLEEP_DURATION rows with measuredAt = stage END", () => {
+ it("maps a session into per-SEGMENT SLEEP_DURATION rows with measuredAt = segment END (real timeline)", () => {
const session = {
sleep: {
startTime: "2026-05-10T22:00:00.000Z",
@@ -556,23 +556,31 @@ describe("sleep-stage mapping", () => {
},
};
const out = mapSleepSession(session);
- const byStage = Object.fromEntries(out.map((m) => [m.sleepStage, m]));
-
- // CORE = the two light segments summed (30 + 45 = 75 min).
- expect(byStage.CORE!.value).toBe(75);
- expect(byStage.CORE!.type).toBe("SLEEP_DURATION");
- expect(byStage.CORE!.unit).toBe("minutes");
- // measuredAt is the LATEST end for that stage (the second light segment).
- expect(byStage.CORE!.measuredAt.toISOString()).toBe(
+
+ // One row PER segment (no collapse) — 4 segments → 4 rows.
+ expect(out).toHaveLength(4);
+ expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true);
+ expect(out.every((m) => m.unit === "minutes")).toBe(true);
+
+ // Each row carries its OWN end instant + duration — a real clock-time
+ // timeline, not a stage collapse.
+ const core = out.filter((m) => m.sleepStage === "CORE");
+ expect(core.map((m) => m.value)).toEqual([30, 45]);
+ expect(core.map((m) => m.measuredAt.toISOString())).toEqual([
+ "2026-05-10T22:30:00.000Z",
"2026-05-11T00:15:00.000Z",
- );
- expect(byStage.DEEP!.value).toBe(60);
- expect(byStage.REM!.value).toBe(45);
+ ]);
+ const deep = out.find((m) => m.sleepStage === "DEEP")!;
+ expect(deep.value).toBe(60);
+ expect(deep.measuredAt.toISOString()).toBe("2026-05-10T23:30:00.000Z");
+ const rem = out.find((m) => m.sleepStage === "REM")!;
+ expect(rem.value).toBe(45);
- // externalId field-tag is session-anchored so a re-score overwrites in place.
- expect(byStage.DEEP!.fieldTag).toBe(
- "2026-05-11T06:00:00.000Z:sleep_deep",
- );
+ // Every fieldTag is distinct (session anchor + stage + segment index) so
+ // the several segments of one stage never collide under the dedup key.
+ const tags = out.map((m) => m.fieldTag);
+ expect(new Set(tags).size).toBe(tags.length);
+ expect(tags).toContain("2026-05-11T06:00:00.000Z:sleep_deep:1");
});
it("anchors the session externalId on sleep.interval.end_time for an INTERVAL-shaped point", () => {
@@ -592,7 +600,7 @@ describe("sleep-stage mapping", () => {
},
});
const deep = out.find((m) => m.sleepStage === "DEEP");
- expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep");
+ expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep:0");
});
it("yields nothing for a session with no parseable segments", () => {
diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts
index 25c21714..e31ee245 100644
--- a/src/lib/fitbit/client.ts
+++ b/src/lib/fitbit/client.ts
@@ -1244,10 +1244,18 @@ function sleepSessionAnchor(point: FitbitDataPoint): string {
}
/**
- * Map one Google sleep session into per-stage `SLEEP_DURATION` rows. Each
- * stage's segments are summed into one row carrying the stage's `SleepStage`,
- * `measuredAt = the latest segment END for that stage`, and a stage-scoped
- * fieldTag so the (up to six) stage rows for one night stay distinct under the
+ * Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows. The
+ * Google sleep payload carries a real per-stage segment series (each with its
+ * own start/end), so one row is emitted PER SEGMENT — `measuredAt = that
+ * segment's END` — rather than collapsing a stage's segments onto a single
+ * lastEnd instant. The collapsed shape stamped every stage total on its last
+ * end, so the hypnogram reconstructed all of a stage's time as one block ending
+ * at the night's right edge (the stacked-bar artefact); per-segment rows lay
+ * each block at its true clock time. The timeline is MEASURED (real onsets), so
+ * these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets.
+ *
+ * Each segment carries a stage-scoped, INDEXED fieldTag so the several segments
+ * of one stage stay distinct under the `(userId, type, source, externalId)`
* dedup key. Unknown stage labels are skipped; a session with no parseable
* segment yields nothing.
*/
@@ -1257,38 +1265,24 @@ export function mapSleepSession(
const segments = readSleepSegments(point);
if (segments.length === 0) return [];
- // Accumulate per-stage minutes + track the latest end instant for each stage.
- const perStage = new Map<
- FitbitSleepStage,
- { minutes: number; lastEnd: Date }
- >();
+ const anchor = sleepSessionAnchor(point);
+ const out: FitbitMappedMeasurement[] = [];
+ let segIndex = 0;
for (const seg of segments) {
const stage = mapFitbitSleepStage(seg.stage);
if (!stage) continue;
const mins = minutesBetween(seg.startTime, seg.endTime);
- if (mins === null) continue;
+ if (mins === null || !(mins > 0)) continue;
const end = new Date(seg.endTime as string);
- const prev = perStage.get(stage);
- if (prev) {
- prev.minutes += mins;
- if (end > prev.lastEnd) prev.lastEnd = end;
- } else {
- perStage.set(stage, { minutes: mins, lastEnd: end });
- }
- }
-
- const anchor = sleepSessionAnchor(point);
- const out: FitbitMappedMeasurement[] = [];
- for (const [stage, agg] of perStage) {
- if (!(agg.minutes > 0)) continue;
out.push({
type: "SLEEP_DURATION",
- value: round2(agg.minutes),
+ value: round2(mins),
unit: "minutes",
- measuredAt: agg.lastEnd,
- fieldTag: `${anchor}:sleep_${stage.toLowerCase()}`,
+ measuredAt: end,
+ fieldTag: `${anchor}:sleep_${stage.toLowerCase()}:${segIndex}`,
sleepStage: stage,
});
+ segIndex += 1;
}
return out;
}
diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts
index 953cd40f..d7ac50f1 100644
--- a/src/lib/fitbit/sync-sleep.ts
+++ b/src/lib/fitbit/sync-sleep.ts
@@ -2,16 +2,19 @@
* Fitbit / Google Health sleep-bundle sync (v1.12.0, W5).
*
* Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps
- * each into per-stage `SLEEP_DURATION` rows via `mapSleepSession` (minutes per
- * stage; `measuredAt = the stage's END instant; harmonised onto the shared
- * `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the night-total +
- * hypnogram readers already consume for WHOOP / Apple). Upserts as
+ * each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row
+ * per stage segment; `measuredAt = that segment's END instant; harmonised onto
+ * the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the
+ * night-total + hypnogram readers already consume for WHOOP / Apple). Google
+ * carries a real per-segment series, so the rows lay each block at its true
+ * clock time (a MEASURED timeline, not reconstructed). Upserts as
* `source = FITBIT`.
*
- * Per-stage rows carry the `sleepStage` axis so the (up to six) stage rows for
- * one night stay distinct under the `(userId, type, source, externalId)` dedup
- * key. externalId = `:sleep_` — a re-scored night
- * overwrites in place. A 24 h overlap covers Google's after-the-fact re-score.
+ * Each segment carries the `sleepStage` axis and an indexed fieldTag so the
+ * several segments of one stage stay distinct under the
+ * `(userId, type, source, externalId)` dedup key. externalId =
+ * `:sleep_:` — a re-scored night overwrites in place.
+ * A 24 h overlap covers Google's after-the-fact re-score.
*
* A per-data-class 403 soft-skips the resource — the sleep bundle is granted
* independently of activity / metrics in the Google consent flow.
From 7ad0b9c4f434d296a79f0dd3f50bd73062175e88 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:16:23 +0200
Subject: [PATCH 06/79] fix(sleep): stamp Withings sleep segments at the END,
not the START
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Withings sleep sync stamped each segment's measuredAt with its startdate,
but every reader treats measuredAt as the segment END and resolves the
span as start = end − duration. Each night was therefore rendered one
segment-length earlier than reality. Stamp the enddate so the span lands
at its true clock time.
---
src/lib/withings/__tests__/sync-sleep.test.ts | 7 +++++--
src/lib/withings/sync-sleep.ts | 17 ++++++++++++-----
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts
index 28757ebd..6ae04f3a 100644
--- a/src/lib/withings/__tests__/sync-sleep.test.ts
+++ b/src/lib/withings/__tests__/sync-sleep.test.ts
@@ -194,7 +194,7 @@ describe("syncUserSleep — segment writes + idempotency", () => {
expect(firstDuration.data.unit).toBe("minutes");
});
- it("converts startdate (unix seconds) to a Date in measuredAt", async () => {
+ it("stamps measuredAt at the segment END (enddate, unix seconds → Date)", async () => {
installFetchMock([
{ startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 },
]);
@@ -205,7 +205,10 @@ describe("syncUserSleep — segment writes + idempotency", () => {
const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { measuredAt: Date };
};
- expect(arg.data.measuredAt.getTime()).toBe(1715000000 * 1000);
+ // measuredAt is the segment END — every reader treats it as the END and
+ // resolves the span as start = end − duration. Stamping the START shifted
+ // the night one segment-length earlier.
+ expect(arg.data.measuredAt.getTime()).toBe(1715003600 * 1000);
});
it("skips state 4 (synthetic marker) without throwing", async () => {
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index 231d94f6..4b92080d 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -26,10 +26,13 @@
* when Withings re-emits the night with adjusted segment boundaries.
*
* Date semantics: each segment's `measuredAt` is the segment's
- * `startdate` (unix seconds → `Date`). The duration in minutes lands
- * in `value`. The HealthLog analytics aggregator already groups
- * stage rows under their parent night via the per-night `dayKey`
- * helper, so the segment-level timestamp is the canonical sort key.
+ * `enddate` (unix seconds → `Date`). Every reader treats `measuredAt`
+ * as the segment END and resolves the span as `start = end − duration`
+ * (`sleep-night.ts` `segmentOf`); stamping the START shifted the whole
+ * night one segment-length earlier. The duration in minutes lands in
+ * `value`. The HealthLog analytics aggregator groups stage rows under
+ * their parent night via the per-night `dayKey` helper, so the
+ * segment-level END timestamp is the canonical sort key.
*/
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
@@ -258,7 +261,11 @@ export async function syncUserSleep(
segmentIndex++;
continue;
}
- const measuredAt = new Date(segment.startdate * 1000);
+ // `measuredAt` is the segment END (`enddate`). Every reader treats
+ // `measuredAt` as the END instant — `segmentOf` resolves the span as
+ // `start = measuredAt − value·60_000` (sleep-night.ts). Stamping the START
+ // here shifted the whole night one segment-length earlier; END is correct.
+ const measuredAt = new Date(segment.enddate * 1000);
const durationSec = Math.max(0, segment.enddate - segment.startdate);
// Canonical SLEEP_DURATION unit is minutes (Apple Health alignment,
// v1.4.23 schema note). Round so re-sync doesn't flip values on
From ddfed17fa31c95010a468eacbe6dc85bc53439c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:16:33 +0200
Subject: [PATCH 07/79] feat(sleep): backfill existing WHOOP + Withings sleep
rows after the stamp fix
The WHOOP timeline reconstruction and the Withings END-stamp change the
stored measuredAt (and, for WHOOP, the externalId), so existing rows
keep the old broken shape until re-synced. An in-place update collides
with the (userId, type, measuredAt, source, sleepStage) unique index, so
the backfill deletes the affected SLEEP_DURATION rows for the source and
re-syncs from scratch; the re-sync re-folds the rollup tier in its tail.
Add a boot-time pg-boss one-shot per connection, modelled on the WHOOP /
Fitbit backfills: discovery enqueues one job per (user, provider) where
sleep_timeline_backfill_at is null, the per-user pass deletes + re-syncs
and stamps the marker, and the pass is idempotent across reboots. The
queue is registered in allQueues, wired to a handler, and enqueued at
boot.
---
.../migration.sql | 20 +++
prisma/schema.prisma | 14 ++
.../__tests__/sleep-timeline-backfill.test.ts | 149 +++++++++++++++
.../__tests__/sleep-timeline-queues.test.ts | 38 ++++
src/lib/jobs/reminder-worker.ts | 63 +++++++
src/lib/jobs/sleep-timeline-backfill.ts | 169 ++++++++++++++++++
6 files changed, 453 insertions(+)
create mode 100644 prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql
create mode 100644 src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts
create mode 100644 src/lib/jobs/__tests__/sleep-timeline-queues.test.ts
create mode 100644 src/lib/jobs/sleep-timeline-backfill.ts
diff --git a/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql b/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql
new file mode 100644
index 00000000..b5ea8e75
--- /dev/null
+++ b/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql
@@ -0,0 +1,20 @@
+-- v1.17.1 — one-shot sleep-timeline backfill markers.
+--
+-- WHOOP's v2 API returns only per-stage sleep DURATION totals (no onset
+-- timestamps), and the pre-fix mapper stamped all five stage totals on the one
+-- sleep-END instant. The hypnogram then reconstructed every stage as a span
+-- touching the night's right edge — the stacked-bar artefact with no clock
+-- times. The fix reconstructs an ordered, contiguous per-segment timeline.
+--
+-- Withings stamped each sleep segment with its START while every reader treats
+-- `measured_at` as the segment END, shifting each night one segment-length
+-- earlier. The fix stamps the END.
+--
+-- Both fixes change the stored row shape / instant, so existing rows must be
+-- re-synced. The boot-time backfill (per connection) deletes the affected
+-- SLEEP_DURATION rows, re-syncs with the corrected mapper, re-folds the rollup
+-- tier, and stamps `sleep_timeline_backfill_at` so the discovery query drops
+-- the connection. Null = backfill not yet run; the marker makes the one-shot
+-- idempotent across reboots.
+ALTER TABLE "whoop_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
+ALTER TABLE "withings_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ba56aa38..5558e34b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -2150,6 +2150,13 @@ model WithingsConnection {
/// has never re-authed since v1.4.25 — the UI surfaces a banner
/// inviting them to reconnect.
scope String? @map("scope")
+ /// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
+ /// sleep rows were stamped with the segment START; the reader treats
+ /// `measuredAt` as the END, shifting every night one segment-length earlier.
+ /// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
+ /// and re-syncs with the corrected END stamp, then sets this so the discovery
+ /// query drops the connection. Null = backfill not yet run.
+ sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2226,6 +2233,13 @@ model WhoopConnection {
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
+ /// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP
+ /// sleep rows stamped all five stage totals on the one sleep-END instant, so
+ /// the hypnogram stacked them on the night's right edge. The boot-time
+ /// backfill deletes the legacy summary rows and re-syncs the reconstructed
+ /// per-segment timeline, then sets this so the discovery query drops the
+ /// connection. Null = backfill not yet run.
+ sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// Max heart rate from the WHOOP body-measurement profile — a profile
/// constant, not a time series, so it lives here rather than as a
/// Measurement row.
diff --git a/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts b/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts
new file mode 100644
index 00000000..710f923b
--- /dev/null
+++ b/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts
@@ -0,0 +1,149 @@
+/**
+ * v1.17.1 — sleep-timeline backfill self-convergence tests (mocked).
+ * - discovery only matches connections whose sleep rows predate the fix
+ * (`sleepTimelineBackfillAt IS NULL`), for both WHOOP and Withings;
+ * - a completed pass DELETES the source's SLEEP_DURATION rows, re-syncs, and
+ * stamps `sleepTimelineBackfillAt` so the next discovery drops the account
+ * (idempotent across reboots);
+ * - the discovery enqueue is singleton-keyed per (provider, user).
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { prismaMock, bossSend, syncUserWhoop, syncWithingsSleep } = vi.hoisted(
+ () => ({
+ prismaMock: {
+ whoopConnection: { findMany: vi.fn(), update: vi.fn() },
+ withingsConnection: { findMany: vi.fn(), update: vi.fn() },
+ measurement: { deleteMany: vi.fn() },
+ },
+ bossSend: vi.fn(),
+ syncUserWhoop: vi.fn(),
+ syncWithingsSleep: vi.fn(),
+ }),
+);
+
+vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
+
+vi.mock("@/lib/jobs/boss-instance", () => ({
+ getGlobalBoss: () => ({ send: bossSend }),
+}));
+
+vi.mock("@/lib/whoop/sync", () => ({
+ syncUserWhoop: (...a: unknown[]) => syncUserWhoop(...a),
+}));
+
+vi.mock("@/lib/withings/sync-sleep", () => ({
+ syncUserSleep: (...a: unknown[]) => syncWithingsSleep(...a),
+}));
+
+vi.mock("@/lib/logging/context", () => ({
+ annotate: () => {},
+ getEvent: () => null,
+}));
+
+import {
+ enqueueBootTimeSleepTimelineBackfill,
+ runSleepTimelineBackfillForUser,
+} from "../sleep-timeline-backfill";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("enqueueBootTimeSleepTimelineBackfill — discovery", () => {
+ it("queries un-backfilled WHOOP + Withings connections and enqueues one job per (provider, user)", async () => {
+ prismaMock.whoopConnection.findMany.mockResolvedValue([{ userId: "w1" }]);
+ prismaMock.withingsConnection.findMany.mockResolvedValue([
+ { userId: "v1" },
+ ]);
+ bossSend.mockResolvedValue("job-id");
+
+ const result = await enqueueBootTimeSleepTimelineBackfill();
+
+ expect(prismaMock.whoopConnection.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }),
+ );
+ expect(prismaMock.withingsConnection.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }),
+ );
+ expect(result.enqueued).toBe(2);
+ expect(bossSend).toHaveBeenCalledWith(
+ "sleep-timeline-backfill",
+ expect.objectContaining({ userId: "w1", provider: "WHOOP" }),
+ expect.objectContaining({
+ singletonKey: "sleep-timeline-backfill|WHOOP|w1",
+ }),
+ );
+ expect(bossSend).toHaveBeenCalledWith(
+ "sleep-timeline-backfill",
+ expect.objectContaining({ userId: "v1", provider: "WITHINGS" }),
+ expect.objectContaining({
+ singletonKey: "sleep-timeline-backfill|WITHINGS|v1",
+ }),
+ );
+ });
+
+ it("self-converges: no un-backfilled connections → nothing enqueued", async () => {
+ prismaMock.whoopConnection.findMany.mockResolvedValue([]);
+ prismaMock.withingsConnection.findMany.mockResolvedValue([]);
+
+ const result = await enqueueBootTimeSleepTimelineBackfill();
+
+ expect(result.enqueued).toBe(0);
+ expect(bossSend).not.toHaveBeenCalled();
+ });
+
+ it("never throws — surfaces a discovery error through the result value", async () => {
+ prismaMock.whoopConnection.findMany.mockRejectedValue(new Error("db down"));
+ prismaMock.withingsConnection.findMany.mockResolvedValue([]);
+
+ const result = await enqueueBootTimeSleepTimelineBackfill();
+
+ expect(result.error).toBe("db down");
+ expect(result.enqueued).toBe(0);
+ });
+});
+
+describe("runSleepTimelineBackfillForUser", () => {
+ it("WHOOP: deletes the source's sleep rows, full-syncs, and stamps the marker", async () => {
+ prismaMock.measurement.deleteMany.mockResolvedValue({ count: 5 });
+ syncUserWhoop.mockResolvedValue(42);
+ prismaMock.whoopConnection.update.mockResolvedValue({});
+
+ const { deleted, imported } = await runSleepTimelineBackfillForUser(
+ "w1",
+ "WHOOP",
+ );
+
+ expect(deleted).toBe(5);
+ expect(imported).toBe(42);
+ expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({
+ where: { userId: "w1", type: "SLEEP_DURATION", source: "WHOOP" },
+ });
+ expect(syncUserWhoop).toHaveBeenCalledWith("w1", { fullSync: true });
+ const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0];
+ expect(updateArg.where).toEqual({ userId: "w1" });
+ expect(updateArg.data.sleepTimelineBackfillAt).toBeInstanceOf(Date);
+ });
+
+ it("WITHINGS: deletes the source's sleep rows, re-syncs, and stamps the marker", async () => {
+ prismaMock.measurement.deleteMany.mockResolvedValue({ count: 3 });
+ syncWithingsSleep.mockResolvedValue(7);
+ prismaMock.withingsConnection.update.mockResolvedValue({});
+
+ const { deleted, imported } = await runSleepTimelineBackfillForUser(
+ "v1",
+ "WITHINGS",
+ );
+
+ expect(deleted).toBe(3);
+ expect(imported).toBe(7);
+ expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({
+ where: { userId: "v1", type: "SLEEP_DURATION", source: "WITHINGS" },
+ });
+ expect(syncWithingsSleep).toHaveBeenCalledWith("v1", { fullSync: true });
+ const updateArg = prismaMock.withingsConnection.update.mock.calls[0]![0];
+ expect(updateArg.where).toEqual({ userId: "v1" });
+ expect(updateArg.data.sleepTimelineBackfillAt).toBeInstanceOf(Date);
+ });
+});
diff --git a/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts b/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts
new file mode 100644
index 00000000..2cbccc29
--- /dev/null
+++ b/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts
@@ -0,0 +1,38 @@
+/**
+ * v1.17.1 — pg-boss queue registration for the one-shot sleep-timeline backfill.
+ *
+ * The reminder-worker imports heavy infrastructure, so we read the source as
+ * text and assert the queue constant is declared, registered in `allQueues`,
+ * wired to a `boss.work` handler, and enqueued at boot — the fast guard that
+ * catches the v1.4.37 dead-queue class (a queue declared but never registered,
+ * which silently never drains).
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
+const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
+
+describe("reminder-worker — sleep-timeline backfill queue", () => {
+ it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => {
+ const block = /const allQueues = \[([\s\S]*?)\];/.exec(source);
+ expect(block).not.toBeNull();
+ expect(block![1]!).toContain("SLEEP_TIMELINE_BACKFILL_QUEUE");
+ });
+
+ it("wires a boss.work handler to the per-user runner", () => {
+ expect(source).toMatch(
+ /boss\.work[\s\S]{0,260}SLEEP_TIMELINE_BACKFILL_QUEUE[\s\S]{0,260}runSleepTimelineBackfillForUser/,
+ );
+ });
+
+ it("wires the boot discovery", () => {
+ expect(source).toMatch(/enqueueBootTimeSleepTimelineBackfill\(\)/);
+ });
+
+ it("imports the backfill exports", () => {
+ expect(source).toMatch(/from\s*["']@\/lib\/jobs\/sleep-timeline-backfill["']/);
+ });
+});
diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts
index 57aa681f..056fbf57 100644
--- a/src/lib/jobs/reminder-worker.ts
+++ b/src/lib/jobs/reminder-worker.ts
@@ -21,6 +21,13 @@ import {
enqueueBootTimeFitbitBackfill,
type FitbitBackfillPayload,
} from "@/lib/jobs/fitbit-backfill";
+import {
+ SLEEP_TIMELINE_BACKFILL_QUEUE,
+ SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
+ runSleepTimelineBackfillForUser,
+ enqueueBootTimeSleepTimelineBackfill,
+ type SleepTimelineBackfillPayload,
+} from "@/lib/jobs/sleep-timeline-backfill";
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -629,6 +636,13 @@ export async function startReminderWorker() {
FITBIT_SYNC_QUEUE,
FITBIT_BACKFILL_QUEUE,
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
+ // v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings.
+ // Discovery enqueues one job per connection whose sleep rows predate the
+ // stamp/shape fix; the pass deletes the affected SLEEP_DURATION rows and
+ // re-syncs. Idempotent across reboots. The queue MUST be registered here or
+ // pg-boss never provisions it and the boot enqueue silently never drains
+ // (the v1.4.37 dead-queue class).
+ SLEEP_TIMELINE_BACKFILL_QUEUE,
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no
// backfill queue — the hourly window walks the recent SGV set). The queue
// MUST be registered here or pg-boss never provisions it and the schedule
@@ -1056,6 +1070,27 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleFitbitOAuthStateCleanup,
);
+ // v1.17.1 — one-shot sleep-timeline backfill. The boot enqueue below sends
+ // one job per (user, provider) whose sleep rows predate the stamp/shape fix;
+ // this handler deletes the affected rows, re-syncs, and stamps the marker so
+ // the discovery query drops the connection.
+ await boss.work(
+ SLEEP_TIMELINE_BACKFILL_QUEUE,
+ { localConcurrency: SLEEP_TIMELINE_BACKFILL_CONCURRENCY },
+ async (jobs) => {
+ for (const job of jobs) {
+ const { userId, provider } = job.data;
+ const { deleted, imported } = await runSleepTimelineBackfillForUser(
+ userId,
+ provider,
+ );
+ workerLog(
+ "info",
+ `[sleep-timeline-backfill] user=${userId} provider=${provider} deleted=${deleted} imported=${imported}`,
+ );
+ }
+ },
+ );
// v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no
// `userId`) walks every configured instance; one user's unreachable host is
// warned, not fatal.
@@ -1882,6 +1917,34 @@ export async function startReminderWorker() {
);
}
+ // v1.17.1 — fire-and-forget boot discovery for the one-shot sleep-timeline
+ // backfill. Finds every WHOOP + Withings connection whose sleep rows predate
+ // the stamp/shape fix and enqueues one job per (user, provider). Idempotent
+ // across reboots: a completed pass stamps `sleepTimelineBackfillAt`, dropping
+ // the connection from the discovery set. Errors come back through the
+ // helper's result value — the worker boot never fails because of a miss.
+ try {
+ const { enqueued, skipped, error } =
+ await enqueueBootTimeSleepTimelineBackfill();
+ if (error) {
+ workerLog(
+ "error",
+ `[sleep-timeline-backfill] boot discovery failed: ${error}`,
+ );
+ } else {
+ workerLog(
+ "info",
+ `[sleep-timeline-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
+ );
+ }
+ } catch (err) {
+ workerLog(
+ "error",
+ "[sleep-timeline-backfill] boot discovery threw an unexpected error",
+ err,
+ );
+ }
+
// v1.7.0 — fire-and-forget boot discovery for the daily-mean
// consolidation pass. Finds every user holding live per-sample
// high-frequency mean-type rows and enqueues one job per account.
diff --git a/src/lib/jobs/sleep-timeline-backfill.ts b/src/lib/jobs/sleep-timeline-backfill.ts
new file mode 100644
index 00000000..d3f49f15
--- /dev/null
+++ b/src/lib/jobs/sleep-timeline-backfill.ts
@@ -0,0 +1,169 @@
+/**
+ * v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings.
+ *
+ * Two ingest-side stamp/shape fixes changed the stored sleep rows:
+ *
+ * - WHOOP: the old mapper stamped all five per-stage DURATION totals on the
+ * one sleep-END instant (WHOOP v2 exposes no onset timestamps), so the
+ * hypnogram reconstructed every stage as a span touching the night's right
+ * edge — stacked, no clock times. The fix reconstructs an ordered,
+ * contiguous per-segment timeline with distinct `measuredAt` per segment and
+ * NEW indexed externalIds (`:seg::`).
+ * - Withings: each segment was stamped with its START while every reader
+ * treats `measuredAt` as the END, shifting each night one segment-length
+ * earlier. The fix stamps the END.
+ *
+ * Both change the row's `measuredAt` (and, for WHOOP, the externalId), and the
+ * unique index `(userId, type, measuredAt, source, sleepStage)` makes an
+ * in-place UPDATE collision-prone. So the per-connection pass DELETES the
+ * affected `SLEEP_DURATION` rows for that source, RE-SYNCS with the corrected
+ * mapper (which re-folds the rollup tier in its tail), and stamps
+ * `sleepTimelineBackfillAt` so the discovery query drops the connection.
+ *
+ * Idempotent across reboots: the discovery predicate is
+ * `sleep_timeline_backfill_at IS NULL`, and a completed pass stamps the marker.
+ * A reboot mid-pass re-runs delete + re-sync from scratch — the re-sync upserts
+ * are key-stable, so the result converges.
+ *
+ * Modelled on `whoop-backfill.ts` / `fitbit-backfill.ts`. The queue name MUST
+ * be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts` or pg-boss
+ * never provisions it and the boot enqueue silently never drains (the v1.4.37
+ * dead-queue class).
+ */
+import { prisma } from "@/lib/db";
+import { annotate } from "@/lib/logging/context";
+import { getGlobalBoss } from "@/lib/jobs/boss-instance";
+import { syncUserWhoop } from "@/lib/whoop/sync";
+import { syncUserSleep as syncWithingsSleep } from "@/lib/withings/sync-sleep";
+
+export const SLEEP_TIMELINE_BACKFILL_QUEUE = "sleep-timeline-backfill";
+
+/**
+ * Serial concurrency — a pass deletes a source's sleep rows then walks a
+ * re-sync that is rate-bounded by the upstream API cap. Concurrency-1 keeps it
+ * from crowding the request pool, matching the other backfill queues.
+ */
+export const SLEEP_TIMELINE_BACKFILL_CONCURRENCY = 1;
+
+export type SleepTimelineProvider = "WHOOP" | "WITHINGS";
+
+export interface SleepTimelineBackfillPayload {
+ userId: string;
+ provider: SleepTimelineProvider;
+ enqueuedAt: string;
+}
+
+/**
+ * Delete the legacy `SLEEP_DURATION` rows for one source, then re-sync with the
+ * corrected mapper. WHOOP runs a full-history sync (the segment externalIds are
+ * new, so the deleted summary rows are not re-created); Withings re-syncs its
+ * default trailing window with the corrected END stamp. The re-sync re-folds
+ * the rollup tier in its own tail.
+ */
+export async function runSleepTimelineBackfillForUser(
+ userId: string,
+ provider: SleepTimelineProvider,
+): Promise<{ deleted: number; imported: number }> {
+ const { count: deleted } = await prisma.measurement.deleteMany({
+ where: { userId, type: "SLEEP_DURATION", source: provider },
+ });
+
+ let imported = 0;
+ if (provider === "WHOOP") {
+ imported = await syncUserWhoop(userId, { fullSync: true });
+ await prisma.whoopConnection.update({
+ where: { userId },
+ data: { sleepTimelineBackfillAt: new Date() },
+ });
+ } else {
+ imported = await syncWithingsSleep(userId, { fullSync: true });
+ await prisma.withingsConnection.update({
+ where: { userId },
+ data: { sleepTimelineBackfillAt: new Date() },
+ });
+ }
+
+ annotate({
+ action: {
+ name: "sleep.timeline.backfill.complete",
+ details: { provider, deleted, imported },
+ },
+ });
+ return { deleted, imported };
+}
+
+/**
+ * Boot-time discovery. Finds every WHOOP + Withings connection not yet
+ * backfilled (`sleep_timeline_backfill_at IS NULL`) and enqueues one job per
+ * (user, provider). Idempotent across reboots: a completed pass stamps the
+ * marker, dropping the connection from the discovery set. pg-boss
+ * `singletonKey` coalesces duplicate sends so a fast restart while a job is
+ * queued doesn't double up.
+ *
+ * Best-effort: errors are returned through the result value so worker boot
+ * never fails because of a backfill miss.
+ */
+export async function enqueueBootTimeSleepTimelineBackfill(): Promise<{
+ enqueued: number;
+ skipped: number;
+ error: string | null;
+}> {
+ const boss = getGlobalBoss();
+ if (!boss) {
+ return { enqueued: 0, skipped: 0, error: null };
+ }
+
+ try {
+ const [whoop, withings] = await Promise.all([
+ prisma.whoopConnection.findMany({
+ where: { sleepTimelineBackfillAt: null },
+ select: { userId: true },
+ }),
+ prisma.withingsConnection.findMany({
+ where: { sleepTimelineBackfillAt: null },
+ select: { userId: true },
+ }),
+ ]);
+
+ const targets: Array<{ userId: string; provider: SleepTimelineProvider }> =
+ [
+ ...whoop.map((c) => ({ userId: c.userId, provider: "WHOOP" as const })),
+ ...withings.map((c) => ({
+ userId: c.userId,
+ provider: "WITHINGS" as const,
+ })),
+ ];
+
+ if (targets.length === 0) {
+ return { enqueued: 0, skipped: 0, error: null };
+ }
+
+ let enqueued = 0;
+ let skipped = 0;
+ for (const { userId, provider } of targets) {
+ const payload: SleepTimelineBackfillPayload = {
+ userId,
+ provider,
+ enqueuedAt: new Date().toISOString(),
+ };
+ const jobId = await boss.send(SLEEP_TIMELINE_BACKFILL_QUEUE, payload, {
+ retryLimit: 3,
+ retryDelay: 60,
+ retryBackoff: true,
+ singletonKey: `sleep-timeline-backfill|${provider}|${userId}`,
+ });
+ if (jobId) {
+ enqueued += 1;
+ } else {
+ skipped += 1;
+ }
+ }
+ return { enqueued, skipped, error: null };
+ } catch (err) {
+ return {
+ enqueued: 0,
+ skipped: 0,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+}
From 38c7604ce41d2b0c1ae8eb3efa7c5e25ba28b969 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:19:47 +0200
Subject: [PATCH 08/79] feat(integrations): per-user Polar and Oura OAuth
credentials
Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.
- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
Oura cards; the status read exposes hasOwnCredentials for the saved
placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
with unit tests; add the new strings across all six locales.
---
messages/de.json | 18 ++-
messages/en.json | 18 ++-
messages/es.json | 18 ++-
messages/fr.json | 18 ++-
messages/it.json | 18 ++-
messages/pl.json | 18 ++-
src/app/api/oura/callback/route.ts | 5 +-
src/app/api/oura/connect/route.ts | 5 +-
src/app/api/oura/credentials/route.ts | 81 ++++++++++
.../api/oura/status/__tests__/route.test.ts | 13 +-
src/app/api/oura/status/route.ts | 27 +++-
src/app/api/polar/callback/route.ts | 9 +-
src/app/api/polar/connect/route.ts | 5 +-
src/app/api/polar/credentials/route.ts | 81 ++++++++++
.../api/polar/status/__tests__/route.test.ts | 15 +-
src/app/api/polar/status/route.ts | 32 ++--
.../__tests__/oauth-provider-card.test.tsx | 38 ++++-
.../integrations/oauth-provider-card.tsx | 150 +++++++++++++++++-
.../settings/integrations/oura-card.tsx | 4 +-
.../settings/integrations/polar-card.tsx | 4 +-
src/lib/oura/__tests__/credentials.test.ts | 80 ++++++++++
src/lib/oura/__tests__/sync.test.ts | 6 +-
src/lib/oura/credentials.ts | 72 ++++++++-
src/lib/oura/sync.ts | 9 +-
src/lib/polar/__tests__/credentials.test.ts | 83 ++++++++++
src/lib/polar/credentials.ts | 73 ++++++++-
src/lib/validations/oura.ts | 16 ++
src/lib/validations/polar.ts | 16 ++
28 files changed, 856 insertions(+), 76 deletions(-)
create mode 100644 src/app/api/oura/credentials/route.ts
create mode 100644 src/app/api/polar/credentials/route.ts
create mode 100644 src/lib/oura/__tests__/credentials.test.ts
create mode 100644 src/lib/polar/__tests__/credentials.test.ts
create mode 100644 src/lib/validations/oura.ts
create mode 100644 src/lib/validations/polar.ts
diff --git a/messages/de.json b/messages/de.json
index 01784ec8..c910cf99 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4603,7 +4603,14 @@
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
"polarViewData": "Schlaf & Erholung ansehen",
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
- "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
+ "polarCredentials": "API-Zugangsdaten",
+ "polarCredentialsHelp": "Optional: Registriere deine eigene Polar-AccessLink-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Polar-App des Servers zu nutzen.",
+ "polarClientId": "Client ID",
+ "polarClientSecret": "Client Secret",
+ "polarCredentialsSaved": "Zugangsdaten gespeichert",
+ "polarCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
+ "polarSaveCredentials": "Speichern",
+ "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"polarConnect": "Polar verbinden",
"polarDisconnect": "Trennen",
"polarDisconnectTitle": "Polar trennen?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
"ouraViewData": "Schlaf & Erholung ansehen",
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
- "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
+ "ouraCredentials": "API-Zugangsdaten",
+ "ouraCredentialsHelp": "Optional: Registriere deine eigene Oura-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Oura-App des Servers zu nutzen.",
+ "ouraClientId": "Client ID",
+ "ouraClientSecret": "Client Secret",
+ "ouraCredentialsSaved": "Zugangsdaten gespeichert",
+ "ouraCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
+ "ouraSaveCredentials": "Speichern",
+ "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"ouraConnect": "Oura verbinden",
"ouraDisconnect": "Trennen",
"ouraDisconnectTitle": "Oura trennen?",
diff --git a/messages/en.json b/messages/en.json
index a78cc1a2..f61d681e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4603,7 +4603,14 @@
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
"polarViewData": "View your sleep & recovery",
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
- "polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
+ "polarCredentials": "API Credentials",
+ "polarCredentialsHelp": "Optional: register your own Polar AccessLink app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Polar app.",
+ "polarClientId": "Client ID",
+ "polarClientSecret": "Client Secret",
+ "polarCredentialsSaved": "Credentials saved",
+ "polarCredentialsSavedPlaceholder": "Saved — enter new to replace",
+ "polarSaveCredentials": "Save",
+ "polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.",
"polarConnect": "Connect Polar",
"polarDisconnect": "Disconnect",
"polarDisconnectTitle": "Disconnect Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
"ouraViewData": "View your sleep & recovery",
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
- "ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
+ "ouraCredentials": "API Credentials",
+ "ouraCredentialsHelp": "Optional: register your own Oura app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Oura app.",
+ "ouraClientId": "Client ID",
+ "ouraClientSecret": "Client Secret",
+ "ouraCredentialsSaved": "Credentials saved",
+ "ouraCredentialsSavedPlaceholder": "Saved — enter new to replace",
+ "ouraSaveCredentials": "Save",
+ "ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.",
"ouraConnect": "Connect Oura",
"ouraDisconnect": "Disconnect",
"ouraDisconnectTitle": "Disconnect Oura?",
diff --git a/messages/es.json b/messages/es.json
index 07ef722c..7f4564f1 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4603,7 +4603,14 @@
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
"polarViewData": "Ver tu sueño y recuperación",
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
- "polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
+ "polarCredentials": "Credenciales de API",
+ "polarCredentialsHelp": "Opcional: registra tu propia app de Polar AccessLink y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
+ "polarClientId": "Client ID",
+ "polarClientSecret": "Client Secret",
+ "polarCredentialsSaved": "Credenciales guardadas",
+ "polarCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
+ "polarSaveCredentials": "Guardar",
+ "polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.",
"polarConnect": "Conectar Polar",
"polarDisconnect": "Desconectar",
"polarDisconnectTitle": "¿Desconectar Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
"ouraViewData": "Ver tu sueño y recuperación",
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
- "ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
+ "ouraCredentials": "Credenciales de API",
+ "ouraCredentialsHelp": "Opcional: registra tu propia app de Oura y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
+ "ouraClientId": "Client ID",
+ "ouraClientSecret": "Client Secret",
+ "ouraCredentialsSaved": "Credenciales guardadas",
+ "ouraCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
+ "ouraSaveCredentials": "Guardar",
+ "ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.",
"ouraConnect": "Conectar Oura",
"ouraDisconnect": "Desconectar",
"ouraDisconnectTitle": "¿Desconectar Oura?",
diff --git a/messages/fr.json b/messages/fr.json
index acec48c7..dab67d9f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4603,7 +4603,14 @@
"polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
"polarViewData": "Voir votre sommeil et récupération",
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
- "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
+ "polarCredentials": "Identifiants API",
+ "polarCredentialsHelp": "Facultatif : enregistrez votre propre application Polar AccessLink et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Polar partagée du serveur.",
+ "polarClientId": "Client ID",
+ "polarClientSecret": "Client Secret",
+ "polarCredentialsSaved": "Identifiants enregistrés",
+ "polarCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
+ "polarSaveCredentials": "Enregistrer",
+ "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"polarConnect": "Connecter Polar",
"polarDisconnect": "Déconnecter",
"polarDisconnectTitle": "Déconnecter Polar ?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
"ouraViewData": "Voir votre sommeil et récupération",
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
- "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
+ "ouraCredentials": "Identifiants API",
+ "ouraCredentialsHelp": "Facultatif : enregistrez votre propre application Oura et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Oura partagée du serveur.",
+ "ouraClientId": "Client ID",
+ "ouraClientSecret": "Client Secret",
+ "ouraCredentialsSaved": "Identifiants enregistrés",
+ "ouraCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
+ "ouraSaveCredentials": "Enregistrer",
+ "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"ouraConnect": "Connecter Oura",
"ouraDisconnect": "Déconnecter",
"ouraDisconnectTitle": "Déconnecter Oura ?",
diff --git a/messages/it.json b/messages/it.json
index e825a177..e4dcc3b2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4603,7 +4603,14 @@
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
"polarViewData": "Vedi sonno e recupero",
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
- "polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
+ "polarCredentials": "Credenziali API",
+ "polarCredentialsHelp": "Facoltativo: registra la tua app Polar AccessLink e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Polar condivisa del server.",
+ "polarClientId": "Client ID",
+ "polarClientSecret": "Client Secret",
+ "polarCredentialsSaved": "Credenziali salvate",
+ "polarCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
+ "polarSaveCredentials": "Salva",
+ "polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"polarConnect": "Collega Polar",
"polarDisconnect": "Disconnetti",
"polarDisconnectTitle": "Disconnettere Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
"ouraViewData": "Vedi sonno e recupero",
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
- "ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
+ "ouraCredentials": "Credenziali API",
+ "ouraCredentialsHelp": "Facoltativo: registra la tua app Oura e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Oura condivisa del server.",
+ "ouraClientId": "Client ID",
+ "ouraClientSecret": "Client Secret",
+ "ouraCredentialsSaved": "Credenziali salvate",
+ "ouraCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
+ "ouraSaveCredentials": "Salva",
+ "ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"ouraConnect": "Collega Oura",
"ouraDisconnect": "Disconnetti",
"ouraDisconnectTitle": "Disconnettere Oura?",
diff --git a/messages/pl.json b/messages/pl.json
index b551d7b0..d58a5999 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4603,7 +4603,14 @@
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
"polarViewData": "Zobacz sen i regenerację",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
- "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
+ "polarCredentials": "Dane API",
+ "polarCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Polar AccessLink i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Polar serwera.",
+ "polarClientId": "Client ID",
+ "polarClientSecret": "Client Secret",
+ "polarCredentialsSaved": "Dane zapisane",
+ "polarCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
+ "polarSaveCredentials": "Zapisz",
+ "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"polarConnect": "Połącz Polar",
"polarDisconnect": "Rozłącz",
"polarDisconnectTitle": "Rozłączyć Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
"ouraViewData": "Zobacz sen i regenerację",
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
- "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
+ "ouraCredentials": "Dane API",
+ "ouraCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Oura i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Oura serwera.",
+ "ouraClientId": "Client ID",
+ "ouraClientSecret": "Client Secret",
+ "ouraCredentialsSaved": "Dane zapisane",
+ "ouraCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
+ "ouraSaveCredentials": "Zapisz",
+ "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"ouraConnect": "Połącz Oura",
"ouraDisconnect": "Rozłącz",
"ouraDisconnectTitle": "Rozłączyć Oura?",
diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts
index e3089916..9dd9a0fa 100644
--- a/src/app/api/oura/callback/route.ts
+++ b/src/app/api/oura/callback/route.ts
@@ -4,7 +4,8 @@ import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
-import { exchangeCode, getOuraCredentials } from "@/lib/oura/client";
+import { exchangeCode } from "@/lib/oura/client";
+import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
@@ -65,7 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
try {
- const creds = getOuraCredentials();
+ const creds = await getOuraClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts
index fd67aa35..e7cd4f23 100644
--- a/src/app/api/oura/connect/route.ts
+++ b/src/app/api/oura/connect/route.ts
@@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
-import { getAuthorizationUrl, getOuraCredentials } from "@/lib/oura/client";
+import { getAuthorizationUrl } from "@/lib/oura/client";
+import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
@@ -40,7 +41,7 @@ export const GET = apiHandler(async () => {
);
}
- const creds = getOuraCredentials();
+ const creds = await getOuraClientCredentials(user.id);
if (!creds) {
return apiError("Oura integration is not configured on this server.", 400);
}
diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts
new file mode 100644
index 00000000..6a3bad80
--- /dev/null
+++ b/src/app/api/oura/credentials/route.ts
@@ -0,0 +1,81 @@
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import {
+ storeOuraClientCredentials,
+ clearOuraClientCredentials,
+} from "@/lib/oura/credentials";
+import { ouraCredentialsSchema } from "@/lib/validations/oura";
+import { NextRequest } from "next/server";
+import { z } from "zod/v4";
+
+/**
+ * v1.17.1 — per-user Oura BYO-key credentials.
+ *
+ * Whether the user has their own Oura client id/secret stored. The
+ * connect/callback/status routes resolve credentials DB-first then env, so an
+ * unset pair simply falls back to the shared env app.
+ */
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "oura.credentials.get" } });
+
+ const dbUser = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: {
+ ouraClientIdEncrypted: true,
+ ouraClientSecretEncrypted: true,
+ },
+ });
+
+ return apiSuccess({
+ hasCredentials:
+ !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted,
+ });
+});
+
+/**
+ * Save Oura OAuth client credentials (encrypted at rest).
+ */
+export const PUT = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "oura.credentials.update" } });
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const result = z.safeParse(ouraCredentialsSchema, body);
+ if (!result.success) {
+ return apiError("Client ID and Client Secret are required", 422);
+ }
+
+ await storeOuraClientCredentials(
+ user.id,
+ result.data.clientId,
+ result.data.clientSecret,
+ );
+
+ return apiSuccess({ updated: true });
+});
+
+/**
+ * Delete Oura credentials and the active connection.
+ */
+export const DELETE = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "oura.credentials.delete" } });
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: {
+ ouraAccessTokenEncrypted: null,
+ ouraRefreshTokenEncrypted: null,
+ },
+ });
+ await clearOuraClientCredentials(user.id);
+
+ return apiSuccess({ deleted: true });
+});
diff --git a/src/app/api/oura/status/__tests__/route.test.ts b/src/app/api/oura/status/__tests__/route.test.ts
index b62b1d88..dc73142d 100644
--- a/src/app/api/oura/status/__tests__/route.test.ts
+++ b/src/app/api/oura/status/__tests__/route.test.ts
@@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
-vi.mock("@/lib/oura/client", () => ({
- getOuraCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
+vi.mock("@/lib/oura/credentials", () => ({
+ getOuraClientCredentials: vi.fn(async () => ({
+ clientId: "c",
+ clientSecret: "s",
+ })),
}));
vi.mock("@/lib/integrations/status", () => ({
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
-import { getOuraCredentials } from "@/lib/oura/client";
+import { getOuraClientCredentials } from "@/lib/oura/credentials";
const userFind = prisma.user.findUnique as ReturnType;
-const credsMock = getOuraCredentials as ReturnType;
+const credsMock = getOuraClientCredentials as ReturnType;
type Body = {
connected: boolean;
@@ -49,7 +52,7 @@ const call = () =>
describe("GET /api/oura/status", () => {
beforeEach(() => {
vi.clearAllMocks();
- credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
+ credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available", async () => {
diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts
index d5dcb648..f3d4d17f 100644
--- a/src/app/api/oura/status/route.ts
+++ b/src/app/api/oura/status/route.ts
@@ -2,15 +2,18 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
-import { getOuraCredentials } from "@/lib/oura/client";
+import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — Oura connection status for the current user.
*
- * `available` reports whether the server has the shared OAuth app configured.
+ * `available` reports whether usable OAuth credentials resolve for this user —
+ * per-user BYO keys (v1.17.1) first, then the shared env app.
+ * `hasOwnCredentials` reports whether the user has stored their own BYO pair.
* `connected` is whether a token is stored. The ledger snapshot comes off the
- * shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned.
+ * shared `oura` IntegrationKey. The access / refresh tokens + client secret are
+ * NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -18,14 +21,25 @@ export const GET = apiHandler(async () => {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
- select: { ouraAccessTokenEncrypted: true },
+ select: {
+ ouraAccessTokenEncrypted: true,
+ ouraClientIdEncrypted: true,
+ ouraClientSecretEncrypted: true,
+ },
});
- const available = !!getOuraCredentials();
+ const available = !!(await getOuraClientCredentials(user.id));
const connected = !!dbUser?.ouraAccessTokenEncrypted;
+ const hasOwnCredentials =
+ !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted;
if (!connected) {
- return apiSuccess({ connected: false, configured: false, available });
+ return apiSuccess({
+ connected: false,
+ configured: false,
+ available,
+ hasOwnCredentials,
+ });
}
const status = await getIntegrationStatus(user.id, "oura");
@@ -34,6 +48,7 @@ export const GET = apiHandler(async () => {
connected: true,
configured: true,
available,
+ hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts
index 34b084e4..53ed9d19 100644
--- a/src/app/api/polar/callback/route.ts
+++ b/src/app/api/polar/callback/route.ts
@@ -4,11 +4,8 @@ import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
-import {
- exchangeCode,
- getPolarCredentials,
- registerUser,
-} from "@/lib/polar/client";
+import { exchangeCode, registerUser } from "@/lib/polar/client";
+import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
@@ -75,7 +72,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
try {
- const creds = getPolarCredentials();
+ const creds = await getPolarClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts
index 5da06696..65d94e70 100644
--- a/src/app/api/polar/connect/route.ts
+++ b/src/app/api/polar/connect/route.ts
@@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
-import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client";
+import { getAuthorizationUrl } from "@/lib/polar/client";
+import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
@@ -44,7 +45,7 @@ export const GET = apiHandler(async () => {
);
}
- const creds = getPolarCredentials();
+ const creds = await getPolarClientCredentials(user.id);
if (!creds) {
return apiError(
"Polar integration is not configured on this server.",
diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts
new file mode 100644
index 00000000..9a92d467
--- /dev/null
+++ b/src/app/api/polar/credentials/route.ts
@@ -0,0 +1,81 @@
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import {
+ storePolarClientCredentials,
+ clearPolarClientCredentials,
+} from "@/lib/polar/credentials";
+import { polarCredentialsSchema } from "@/lib/validations/polar";
+import { NextRequest } from "next/server";
+import { z } from "zod/v4";
+
+/**
+ * v1.17.1 — per-user Polar BYO-key credentials.
+ *
+ * Whether the user has their own Polar AccessLink client id/secret stored. The
+ * connect/callback/status routes resolve credentials DB-first then env, so an
+ * unset pair simply falls back to the shared env app.
+ */
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "polar.credentials.get" } });
+
+ const dbUser = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: {
+ polarClientIdEncrypted: true,
+ polarClientSecretEncrypted: true,
+ },
+ });
+
+ return apiSuccess({
+ hasCredentials:
+ !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted,
+ });
+});
+
+/**
+ * Save Polar OAuth client credentials (encrypted at rest).
+ */
+export const PUT = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "polar.credentials.update" } });
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const result = z.safeParse(polarCredentialsSchema, body);
+ if (!result.success) {
+ return apiError("Client ID and Client Secret are required", 422);
+ }
+
+ await storePolarClientCredentials(
+ user.id,
+ result.data.clientId,
+ result.data.clientSecret,
+ );
+
+ return apiSuccess({ updated: true });
+});
+
+/**
+ * Delete Polar credentials and the active connection.
+ */
+export const DELETE = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "polar.credentials.delete" } });
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: {
+ polarAccessTokenEncrypted: null,
+ polarUserIdEncrypted: null,
+ },
+ });
+ await clearPolarClientCredentials(user.id);
+
+ return apiSuccess({ deleted: true });
+});
diff --git a/src/app/api/polar/status/__tests__/route.test.ts b/src/app/api/polar/status/__tests__/route.test.ts
index e7e94d27..7a7d3016 100644
--- a/src/app/api/polar/status/__tests__/route.test.ts
+++ b/src/app/api/polar/status/__tests__/route.test.ts
@@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
-vi.mock("@/lib/polar/client", () => ({
- getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
+vi.mock("@/lib/polar/credentials", () => ({
+ getPolarClientCredentials: vi.fn(async () => ({
+ clientId: "c",
+ clientSecret: "s",
+ })),
}));
vi.mock("@/lib/integrations/status", () => ({
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
-import { getPolarCredentials } from "@/lib/polar/client";
+import { getPolarClientCredentials } from "@/lib/polar/credentials";
const userFind = prisma.user.findUnique as ReturnType;
-const credsMock = getPolarCredentials as ReturnType;
+const credsMock = getPolarClientCredentials as ReturnType;
type Body = {
connected: boolean;
@@ -49,7 +52,7 @@ const call = () =>
describe("GET /api/polar/status", () => {
beforeEach(() => {
vi.clearAllMocks();
- credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
+ credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available when env is set and no token stored", async () => {
@@ -60,7 +63,7 @@ describe("GET /api/polar/status", () => {
});
it("reports available=false when the server has no Polar app configured", async () => {
- credsMock.mockReturnValue(null);
+ credsMock.mockResolvedValue(null);
userFind.mockResolvedValue(null);
const res = await call();
expect(res.available).toBe(false);
diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts
index cfa8ad71..87d43742 100644
--- a/src/app/api/polar/status/route.ts
+++ b/src/app/api/polar/status/route.ts
@@ -2,17 +2,19 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
-import { getPolarCredentials } from "@/lib/polar/client";
+import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — Polar connection status for the current user.
*
- * `available` reports whether the server has the shared OAuth app configured
- * (env client id/secret) so the card can grey out the connect button when the
- * operator hasn't set it up. `connected` is whether a token is stored. The
- * ledger snapshot (state / lastSuccessAt / lastError) comes off the shared
- * `polar` IntegrationKey. The access token + member id are NEVER returned.
+ * `available` reports whether usable OAuth credentials resolve for this user —
+ * per-user BYO keys (v1.17.1) first, then the shared env app — so the card can
+ * grey out the connect button when neither is set. `hasOwnCredentials` reports
+ * whether the user has stored their own BYO pair (drives the saved-placeholder
+ * UI). `connected` is whether a token is stored. The ledger snapshot (state /
+ * lastSuccessAt / lastError) comes off the shared `polar` IntegrationKey. The
+ * access token, member id, and client secret are NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -20,14 +22,25 @@ export const GET = apiHandler(async () => {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
- select: { polarAccessTokenEncrypted: true },
+ select: {
+ polarAccessTokenEncrypted: true,
+ polarClientIdEncrypted: true,
+ polarClientSecretEncrypted: true,
+ },
});
- const available = !!getPolarCredentials();
+ const available = !!(await getPolarClientCredentials(user.id));
const connected = !!dbUser?.polarAccessTokenEncrypted;
+ const hasOwnCredentials =
+ !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted;
if (!connected) {
- return apiSuccess({ connected: false, configured: false, available });
+ return apiSuccess({
+ connected: false,
+ configured: false,
+ available,
+ hasOwnCredentials,
+ });
}
const status = await getIntegrationStatus(user.id, "polar");
@@ -36,6 +49,7 @@ export const GET = apiHandler(async () => {
connected: true,
configured: true,
available,
+ hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
index 1e6b801d..83090442 100644
--- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
+++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
@@ -23,7 +23,7 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { OAuthProviderCard } from "../oauth-provider-card";
-function render() {
+function render({ credentials = false }: { credentials?: boolean } = {}) {
return renderToStaticMarkup(
,
);
@@ -86,3 +87,38 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
expect(html).toContain('data-testid="polar-connect"');
});
});
+
+describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
+ it("renders the credentials form only when the `credentials` prop is set", () => {
+ statusPayload = {
+ connected: false,
+ configured: false,
+ available: true,
+ hasOwnCredentials: false,
+ };
+ // Opt-in: the BYO client-id/secret form + save button appear.
+ const withForm = render({ credentials: true });
+ expect(withForm).toContain('data-testid="polar-credentials"');
+ expect(withForm).toContain('id="polar-clientid"');
+ expect(withForm).toContain('id="polar-secret"');
+
+ // Default: no credential inputs (env-only behaviour preserved).
+ const withoutForm = render();
+ expect(withoutForm).not.toContain('data-testid="polar-credentials"');
+ expect(withoutForm).not.toContain('id="polar-clientid"');
+ });
+
+ it("shows the saved-placeholder once the user has stored their own pair", () => {
+ statusPayload = {
+ connected: true,
+ configured: true,
+ available: true,
+ hasOwnCredentials: true,
+ state: "connected",
+ lastSuccessAt: null,
+ lastError: null,
+ };
+ const html = render({ credentials: true });
+ expect(html).toContain("Saved — enter new to replace");
+ });
+});
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index ea783d17..77b2e97f 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -1,16 +1,17 @@
"use client";
-// v1.17.0 (F4) — shared OAuth integration card for env-based providers (Polar,
-// Oura). Unlike WHOOP / Fitbit (per-user BYO client id/secret), these use a
-// single shared OAuth app the operator configures via env, so the card has NO
-// credential inputs — just a connect button that redirects to the provider, a
-// status pill off the self-contained status read, and a disconnect action.
+// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura.
+// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit):
+// the card renders an optional "your OAuth app credentials" form (driven by the
+// `credentials` prop) above the connect button. Credentials resolve DB-first
+// then env on the server, so a user who pastes their own client id/secret uses
+// their own app while existing env-configured deploys keep working unchanged.
// Mirrors the Nightscout card's self-contained status pattern.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ArrowRight, Link2, Unlink, type LucideIcon } from "lucide-react";
+import { ArrowRight, Link2, Loader2, Save, Unlink, type LucideIcon } from "lucide-react";
import {
AlertDialog,
@@ -24,13 +25,16 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
-import { apiGet, apiPost } from "@/lib/api/api-fetch";
+import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
@@ -38,6 +42,8 @@ export interface OAuthProviderStatus {
connected: boolean;
configured: boolean;
available: boolean;
+ /** Whether the user has stored their own BYO client id/secret pair. */
+ hasOwnCredentials?: boolean;
state?:
| "connected"
| "error_transient"
@@ -74,6 +80,9 @@ export interface OAuthProviderCardProps {
icon: LucideIcon;
/** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */
dataHref: string;
+ /** When set, render a per-user BYO OAuth-credentials form above the connect
+ * button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
+ credentials?: boolean;
enabled?: boolean;
}
@@ -83,11 +92,19 @@ export function OAuthProviderCard({
i18nPrefix,
icon,
dataHref,
+ credentials = false,
enabled = true,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState(null);
+ const [clientId, setClientId] = useState("");
+ const [clientSecret, setClientSecret] = useState("");
+ const [credsSaving, setCredsSaving] = useState(false);
+ const [credsMsg, setCredsMsg] = useState(null);
+ const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
+ null,
+ );
const { data: status } = useQuery({
queryKey: statusQueryKey,
@@ -96,6 +113,42 @@ export function OAuthProviderCard({
refetchOnWindowFocus: true,
});
+ async function handleSaveCredentials(e: React.FormEvent) {
+ e.preventDefault();
+ setCredsSaving(true);
+ setCredsMsg(null);
+ setCredsMsgType(null);
+ try {
+ const res = await apiFetchRaw(`/api/${provider}/credentials`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ clientId: clientId.trim(),
+ clientSecret: clientSecret.trim(),
+ }),
+ });
+ if (res.ok) {
+ setCredsMsg(t(`${i18nPrefix}CredentialsSaved`));
+ setCredsMsgType("success");
+ setClientId("");
+ setClientSecret("");
+ queryClient.invalidateQueries({ queryKey: statusQueryKey });
+ } else {
+ try {
+ const json = await res.json();
+ setCredsMsg(json.error || t("settings.savingError"));
+ } catch {
+ setCredsMsg(t("settings.savingError"));
+ }
+ setCredsMsgType("error");
+ }
+ } catch {
+ setCredsMsg(t("common.networkError"));
+ setCredsMsgType("error");
+ }
+ setCredsSaving(false);
+ }
+
const disconnect = useMutation({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
@@ -182,6 +235,89 @@ export function OAuthProviderCard({
{t(`${i18nPrefix}Help`)}
+ {credentials && (
+
+
+ {t(`${i18nPrefix}Credentials`)}
+
+
+ {t(`${i18nPrefix}CredentialsHelp`)}
+
+
+
+ )}
+
{serverUnavailable && (
);
diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx
index 621cd932..56a31021 100644
--- a/src/components/settings/integrations/polar-card.tsx
+++ b/src/components/settings/integrations/polar-card.tsx
@@ -1,7 +1,8 @@
"use client";
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
-// shared env-based OAuth card.
+// shared OAuth card.
+// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
import { Watch } from "lucide-react";
@@ -16,6 +17,7 @@ export function PolarCard({ enabled = true }: { enabled?: boolean }) {
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
+ credentials
enabled={enabled}
/>
);
diff --git a/src/lib/oura/__tests__/credentials.test.ts b/src/lib/oura/__tests__/credentials.test.ts
new file mode 100644
index 00000000..5e63fc22
--- /dev/null
+++ b/src/lib/oura/__tests__/credentials.test.ts
@@ -0,0 +1,80 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const findUnique = vi.fn();
+const getOuraCredentials = vi.fn();
+
+vi.mock("@/lib/db", () => ({
+ prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } },
+}));
+
+vi.mock("@/lib/crypto", () => ({
+ decrypt: (cipher: string) => `dec(${cipher})`,
+ encrypt: (plain: string) => `enc(${plain})`,
+}));
+
+// The env fallback lives in ./client; stub it so the precedence is observable.
+vi.mock("../client", () => ({
+ getOuraCredentials: () => getOuraCredentials(),
+}));
+
+import { getOuraClientCredentials } from "../credentials";
+
+describe("getOuraClientCredentials — DB-first then env", () => {
+ beforeEach(() => {
+ findUnique.mockReset();
+ getOuraCredentials.mockReset();
+ });
+
+ it("returns the per-user BYO client id/secret when both columns are set", async () => {
+ findUnique.mockResolvedValue({
+ ouraClientIdEncrypted: "enc-id",
+ ouraClientSecretEncrypted: "enc-secret",
+ });
+ const creds = await getOuraClientCredentials("user-1");
+ expect(creds).toEqual({
+ clientId: "dec(enc-id)",
+ clientSecret: "dec(enc-secret)",
+ });
+ expect(getOuraCredentials).not.toHaveBeenCalled();
+ expect(findUnique).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ select: {
+ ouraClientIdEncrypted: true,
+ ouraClientSecretEncrypted: true,
+ },
+ });
+ });
+
+ it("falls back to the shared env app when the user has no BYO pair", async () => {
+ findUnique.mockResolvedValue({
+ ouraClientIdEncrypted: null,
+ ouraClientSecretEncrypted: null,
+ });
+ getOuraCredentials.mockReturnValue({
+ clientId: "env-id",
+ clientSecret: "env-secret",
+ });
+ const creds = await getOuraClientCredentials("user-1");
+ expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
+ expect(getOuraCredentials).toHaveBeenCalledOnce();
+ });
+
+ it("falls back to env when only one half of the BYO pair is present", async () => {
+ findUnique.mockResolvedValue({
+ ouraClientIdEncrypted: null,
+ ouraClientSecretEncrypted: "enc-secret",
+ });
+ getOuraCredentials.mockReturnValue({
+ clientId: "env-id",
+ clientSecret: "env-secret",
+ });
+ const creds = await getOuraClientCredentials("user-1");
+ expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
+ });
+
+ it("returns null when neither the user nor the env is configured", async () => {
+ findUnique.mockResolvedValue(null);
+ getOuraCredentials.mockReturnValue(null);
+ expect(await getOuraClientCredentials("ghost")).toBeNull();
+ });
+});
diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts
index 2d0fc91e..ebaf221d 100644
--- a/src/lib/oura/__tests__/sync.test.ts
+++ b/src/lib/oura/__tests__/sync.test.ts
@@ -31,6 +31,7 @@ const {
vi.mock("../credentials", () => ({
getOuraConnection: getConnMock,
storeOuraTokens: storeTokensMock,
+ getOuraClientCredentials: getCredsMock,
}));
vi.mock("@/lib/db", () => ({
@@ -57,7 +58,6 @@ vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- getOuraCredentials: getCredsMock,
fetchReadiness: fetchReadinessMock,
fetchSleep: fetchSleepMock,
fetchDailyActivity: fetchActivityMock,
@@ -73,7 +73,9 @@ const CONN = { accessToken: "acc", refreshToken: "ref" };
beforeEach(() => {
getConnMock.mockReset();
storeTokensMock.mockReset().mockResolvedValue(undefined);
- getCredsMock.mockReset().mockReturnValue({ clientId: "c", clientSecret: "s" });
+ getCredsMock
+ .mockReset()
+ .mockResolvedValue({ clientId: "c", clientSecret: "s" });
fetchReadinessMock.mockReset().mockResolvedValue([]);
fetchSleepMock.mockReset().mockResolvedValue([]);
fetchActivityMock.mockReset().mockResolvedValue([]);
diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts
index ecf9b420..4f95f9e1 100644
--- a/src/lib/oura/credentials.ts
+++ b/src/lib/oura/credentials.ts
@@ -1,14 +1,76 @@
/**
* v1.17.0 (F4) — per-user Oura token accessors.
+ * v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
*
- * Oura uses a single shared OAuth app (env client id/secret via
- * `getOuraCredentials`). Per-user state is the granted access + refresh token,
- * stored encrypted on `User`. The merged schema carries NO token-expiry column,
- * so the sync layer refreshes REACTIVELY on a 401 rather than proactively on an
- * expiry timestamp (and persists both rotated tokens).
+ * Like WHOOP / Fitbit, Oura is now a per-user BYO-key integration: each user may
+ * register their own Oura app and store the client id/secret encrypted on
+ * `User`, with a fall-back to the shared env app for existing single-app
+ * deploys. Also per-user is the granted access + refresh token. The schema
+ * carries NO token-expiry column, so the sync layer refreshes REACTIVELY on a
+ * 401 rather than proactively on an expiry timestamp (persisting both rotated
+ * tokens).
*/
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
+import { getOuraCredentials, type OuraCredentials } from "./client";
+
+/**
+ * Resolve the user's Oura OAuth client id/secret, DB-first then env.
+ *
+ * v1.17.1 makes Oura a per-user BYO-key integration (mirroring WHOOP / Fitbit):
+ * a self-hoster registers their own Oura app and pastes the client id/secret
+ * into Settings, stored encrypted on `User`. When the user has not configured
+ * per-user keys we fall back to the shared env app (`OURA_CLIENT_ID` /
+ * `OURA_CLIENT_SECRET`) so existing single-app deploys keep working. Returns
+ * null when neither source is configured.
+ */
+export async function getOuraClientCredentials(
+ userId: string,
+): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ ouraClientIdEncrypted: true,
+ ouraClientSecretEncrypted: true,
+ },
+ });
+
+ if (user?.ouraClientIdEncrypted && user?.ouraClientSecretEncrypted) {
+ return {
+ clientId: decrypt(user.ouraClientIdEncrypted),
+ clientSecret: decrypt(user.ouraClientSecretEncrypted),
+ };
+ }
+
+ // Fall back to the shared env-configured OAuth app.
+ return getOuraCredentials();
+}
+
+/** Persist the user's Oura OAuth client id/secret, encrypted at rest. */
+export async function storeOuraClientCredentials(
+ userId: string,
+ clientId: string,
+ clientSecret: string,
+): Promise {
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ ouraClientIdEncrypted: encrypt(clientId),
+ ouraClientSecretEncrypted: encrypt(clientSecret),
+ },
+ });
+}
+
+/** Clear the user's stored Oura OAuth client id/secret. */
+export async function clearOuraClientCredentials(userId: string): Promise {
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ ouraClientIdEncrypted: null,
+ ouraClientSecretEncrypted: null,
+ },
+ });
+}
export interface OuraConnection {
accessToken: string;
diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts
index 58d110e5..efadb080 100644
--- a/src/lib/oura/sync.ts
+++ b/src/lib/oura/sync.ts
@@ -35,14 +35,17 @@ import {
fetchDailyActivity,
fetchReadiness,
fetchSleep,
- getOuraCredentials,
mapDailyActivity,
mapReadiness,
mapSleep,
refreshAccessToken,
type MappedMeasurement,
} from "./client";
-import { getOuraConnection, storeOuraTokens } from "./credentials";
+import {
+ getOuraClientCredentials,
+ getOuraConnection,
+ storeOuraTokens,
+} from "./credentials";
import { OuraApiError, classifyOuraError } from "./response-classifier";
/** Default lookback window (days) for an incremental sync. Oura finalises a
@@ -118,7 +121,7 @@ async function fetchAll(
// case — rethrow so the caller classifies it.
if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err;
- const creds = getOuraCredentials();
+ const creds = await getOuraClientCredentials(userId);
if (!creds) throw err;
const rotated = await refreshAccessToken(refreshToken, creds);
diff --git a/src/lib/polar/__tests__/credentials.test.ts b/src/lib/polar/__tests__/credentials.test.ts
new file mode 100644
index 00000000..11f8d5b2
--- /dev/null
+++ b/src/lib/polar/__tests__/credentials.test.ts
@@ -0,0 +1,83 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const findUnique = vi.fn();
+const getPolarCredentials = vi.fn();
+
+vi.mock("@/lib/db", () => ({
+ prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } },
+}));
+
+// decrypt() is the fail-closed AES-256-GCM reader; the resolver only needs it
+// to round-trip the stored ciphertext, so a `dec()` shim is sufficient here.
+vi.mock("@/lib/crypto", () => ({
+ decrypt: (cipher: string) => `dec(${cipher})`,
+ encrypt: (plain: string) => `enc(${plain})`,
+}));
+
+// The env fallback lives in ./client; stub it so the precedence is observable.
+vi.mock("../client", () => ({
+ getPolarCredentials: () => getPolarCredentials(),
+}));
+
+import { getPolarClientCredentials } from "../credentials";
+
+describe("getPolarClientCredentials — DB-first then env", () => {
+ beforeEach(() => {
+ findUnique.mockReset();
+ getPolarCredentials.mockReset();
+ });
+
+ it("returns the per-user BYO client id/secret when both columns are set", async () => {
+ findUnique.mockResolvedValue({
+ polarClientIdEncrypted: "enc-id",
+ polarClientSecretEncrypted: "enc-secret",
+ });
+ const creds = await getPolarClientCredentials("user-1");
+ expect(creds).toEqual({
+ clientId: "dec(enc-id)",
+ clientSecret: "dec(enc-secret)",
+ });
+ // DB hit must not consult the env fallback.
+ expect(getPolarCredentials).not.toHaveBeenCalled();
+ expect(findUnique).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ select: {
+ polarClientIdEncrypted: true,
+ polarClientSecretEncrypted: true,
+ },
+ });
+ });
+
+ it("falls back to the shared env app when the user has no BYO pair", async () => {
+ findUnique.mockResolvedValue({
+ polarClientIdEncrypted: null,
+ polarClientSecretEncrypted: null,
+ });
+ getPolarCredentials.mockReturnValue({
+ clientId: "env-id",
+ clientSecret: "env-secret",
+ });
+ const creds = await getPolarClientCredentials("user-1");
+ expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
+ expect(getPolarCredentials).toHaveBeenCalledOnce();
+ });
+
+ it("falls back to env when only one half of the BYO pair is present", async () => {
+ findUnique.mockResolvedValue({
+ polarClientIdEncrypted: "enc-id",
+ polarClientSecretEncrypted: null,
+ });
+ getPolarCredentials.mockReturnValue({
+ clientId: "env-id",
+ clientSecret: "env-secret",
+ });
+ const creds = await getPolarClientCredentials("user-1");
+ expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
+ });
+
+ it("returns null when neither the user nor the env is configured", async () => {
+ findUnique.mockResolvedValue(null);
+ getPolarCredentials.mockReturnValue(null);
+ expect(await getPolarClientCredentials("ghost")).toBeNull();
+ });
+});
diff --git a/src/lib/polar/credentials.ts b/src/lib/polar/credentials.ts
index ab1cd9fd..61a614e2 100644
--- a/src/lib/polar/credentials.ts
+++ b/src/lib/polar/credentials.ts
@@ -1,14 +1,75 @@
/**
* v1.17.0 (F4) — per-user Polar token accessors.
+ * v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
*
- * Unlike WHOOP (per-user BYO client id/secret), Polar uses a single shared
- * OAuth app whose client id/secret come from env (`getPolarCredentials`). What
- * is per-user is the granted token + Polar member id, stored encrypted on
- * `User`. Polar access tokens do not expire and carry no refresh token, so
- * there is no refresh path — a revoked grant surfaces as a 401 on the next read.
+ * Like WHOOP / Fitbit, Polar is now a per-user BYO-key integration: each user
+ * may register their own AccessLink app and store the client id/secret encrypted
+ * on `User`, with a fall-back to the shared env app for existing single-app
+ * deploys. Also per-user is the granted token + Polar member id. Polar access
+ * tokens do not expire and carry no refresh token, so there is no refresh path —
+ * a revoked grant surfaces as a 401 on the next read.
*/
import { prisma } from "@/lib/db";
-import { decrypt } from "@/lib/crypto";
+import { decrypt, encrypt } from "@/lib/crypto";
+import { getPolarCredentials, type PolarCredentials } from "./client";
+
+/**
+ * Resolve the user's Polar OAuth client id/secret, DB-first then env.
+ *
+ * v1.17.1 makes Polar a per-user BYO-key integration (mirroring WHOOP / Fitbit):
+ * a self-hoster registers their own Polar AccessLink app and pastes the client
+ * id/secret into Settings, stored encrypted on `User`. When the user has not
+ * configured per-user keys we fall back to the shared env app
+ * (`POLAR_CLIENT_ID` / `POLAR_CLIENT_SECRET`) so existing single-app deploys
+ * keep working. Returns null when neither source is configured.
+ */
+export async function getPolarClientCredentials(
+ userId: string,
+): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ polarClientIdEncrypted: true,
+ polarClientSecretEncrypted: true,
+ },
+ });
+
+ if (user?.polarClientIdEncrypted && user?.polarClientSecretEncrypted) {
+ return {
+ clientId: decrypt(user.polarClientIdEncrypted),
+ clientSecret: decrypt(user.polarClientSecretEncrypted),
+ };
+ }
+
+ // Fall back to the shared env-configured OAuth app.
+ return getPolarCredentials();
+}
+
+/** Persist the user's Polar OAuth client id/secret, encrypted at rest. */
+export async function storePolarClientCredentials(
+ userId: string,
+ clientId: string,
+ clientSecret: string,
+): Promise {
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ polarClientIdEncrypted: encrypt(clientId),
+ polarClientSecretEncrypted: encrypt(clientSecret),
+ },
+ });
+}
+
+/** Clear the user's stored Polar OAuth client id/secret. */
+export async function clearPolarClientCredentials(userId: string): Promise {
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ polarClientIdEncrypted: null,
+ polarClientSecretEncrypted: null,
+ },
+ });
+}
export interface PolarConnection {
accessToken: string;
diff --git a/src/lib/validations/oura.ts b/src/lib/validations/oura.ts
new file mode 100644
index 00000000..b444ff74
--- /dev/null
+++ b/src/lib/validations/oura.ts
@@ -0,0 +1,16 @@
+import { z } from "zod/v4";
+
+/**
+ * Per-user Oura BYO-key credentials. Each self-hoster registers their own Oura
+ * app and pastes the client id/secret into Settings. Stored encrypted on
+ * `User`. When unset, the integration falls back to the shared env-configured
+ * OAuth app.
+ */
+export const ouraCredentialsSchema = z.object({
+ // Trimmed: a trailing space or newline from the portal's copy button reaches
+ // Oura verbatim and answers as "unknown client".
+ clientId: z.string().trim().min(1).max(200),
+ clientSecret: z.string().trim().min(1).max(200),
+});
+
+export type OuraCredentialsInput = z.infer;
diff --git a/src/lib/validations/polar.ts b/src/lib/validations/polar.ts
new file mode 100644
index 00000000..c21123c1
--- /dev/null
+++ b/src/lib/validations/polar.ts
@@ -0,0 +1,16 @@
+import { z } from "zod/v4";
+
+/**
+ * Per-user Polar BYO-key credentials. Each self-hoster registers their own
+ * Polar AccessLink app and pastes the client id/secret into Settings. Stored
+ * encrypted on `User`. When unset, the integration falls back to the shared
+ * env-configured OAuth app.
+ */
+export const polarCredentialsSchema = z.object({
+ // Trimmed: a trailing space or newline from the portal's copy button reaches
+ // Polar verbatim and answers as "unknown client".
+ clientId: z.string().trim().min(1).max(200),
+ clientSecret: z.string().trim().min(1).max(200),
+});
+
+export type PolarCredentialsInput = z.infer;
From 80f3cd878019a26e2d8f7f267b10506de47e0eb2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:20:54 +0200
Subject: [PATCH 09/79] chore(migrations): renumber sleep-timeline backfill to
0163 for monotonic order
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename prisma/migrations/{0172_v1171_sleep_timeline_backfill => 0163_v1171_sleep_timeline_backfill}/migration.sql (100%)
diff --git a/prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql b/prisma/migrations/0163_v1171_sleep_timeline_backfill/migration.sql
similarity index 100%
rename from prisma/migrations/0172_v1171_sleep_timeline_backfill/migration.sql
rename to prisma/migrations/0163_v1171_sleep_timeline_backfill/migration.sql
From 5b756f01f23e64528dcdf0474a3e69b1172711d8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:21:39 +0200
Subject: [PATCH 10/79] feat(onboarding): add User.onboardingGoals for goal
persistence
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The goals step let the user toggle goal slugs but both Skip and Next
discarded the selection — the picker was decorative. Add the
onboarding_goals text array the earlier dead code already referenced so
the selection can persist and seed dashboard tiles + reminder
suggestions. Not a clinical target store; targets stay fully derived
from height/age/gender in /api/insights/targets. Empty array = no
preference; purely-additive migration with an empty-array default, no
backfill.
---
.../0163_v1171_onboarding_goals/migration.sql | 15 +++++++++++++++
prisma/schema.prisma | 9 +++++++++
2 files changed, 24 insertions(+)
create mode 100644 prisma/migrations/0163_v1171_onboarding_goals/migration.sql
diff --git a/prisma/migrations/0163_v1171_onboarding_goals/migration.sql b/prisma/migrations/0163_v1171_onboarding_goals/migration.sql
new file mode 100644
index 00000000..2eba4558
--- /dev/null
+++ b/prisma/migrations/0163_v1171_onboarding_goals/migration.sql
@@ -0,0 +1,15 @@
+-- v1.17.1 — persist the onboarding goal selection.
+--
+-- The GoalsChipPicker step let the user toggle goal slugs, but both
+-- Skip and Next discarded the selection — the picker was decorative.
+-- This adds the `onboarding_goals` text array the dead code already
+-- referenced, so the selection can seed dashboard tile pinning and
+-- reminder suggestions.
+--
+-- Purely-additive: one new column on `users`, NOT NULL with a `'{}'`
+-- (empty array) default, so every existing row keeps "no preference"
+-- without a backfill pass. The column is a personalization seed, not a
+-- clinical target store.
+
+ALTER TABLE "users"
+ ADD COLUMN "onboarding_goals" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 715611d1..7ca1c51e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -236,6 +236,15 @@ model User {
// round-trip — the column stays at 4 for completed users)
// Null = legacy pre-W14b user; the wizard treats null as 0 on mount.
onboardingStep Int @default(0) @map("onboarding_step")
+ // v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker).
+ // Seeds dashboard tile pinning + reminder suggestions; NOT a clinical
+ // target store (targets are fully derived from height/age/gender +
+ // clinical refs in /api/insights/targets). Empty [] = skipped / no
+ // preference. The slug set is the closed enum
+ // `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[]
+ // (not a relation) mirrors `insightsExcludeMetrics` precedent — the
+ // set is small and fixed, never queried relationally.
+ onboardingGoals String[] @default([]) @map("onboarding_goals")
// v1.4.15 Phase B5: tracks whether the user has finished (or
// explicitly dismissed) the spotlight tour that overlays the
// dashboard on first arrival. Distinct from the wizard at
From 5f601426a97eb98fcd0cb0a2d6315bac1419f2c6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:21:48 +0200
Subject: [PATCH 11/79] feat(onboarding): persist goal selection and seed the
dashboard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wire the goals step to actually keep its promise. The picker now posts
the chosen slugs on the step-2 submit; the step endpoint validates them
against the closed slug enum (422 on an unknown slug), persists them to
User.onboardingGoals field-by-field, and on completion seeds
dashboardWidgetsJson from the selection — promoting the goal-mapped
tiles to the top and forcing them visible.
The seed is one-time, gated on a null dashboardWidgetsJson via the
updateMany WHERE clause, so a user who already arranged tiles is never
clobbered. An empty or general-wellness-only selection leaves the
column untouched (default layout). The resulting layout is what both
the web dashboard and the iOS widgets contract read — no client
recompute. Skip posts an explicit empty array (deliberate no
preference).
---
src/app/api/onboarding/__tests__/step.test.ts | 145 ++++++++++++++++++
src/app/api/onboarding/step/route.ts | 61 +++++++-
src/components/onboarding/GoalsChipPicker.tsx | 28 ++--
src/lib/onboarding/__tests__/goals.test.ts | 104 +++++++++++++
src/lib/onboarding/goals.ts | 108 +++++++++++++
5 files changed, 431 insertions(+), 15 deletions(-)
create mode 100644 src/lib/onboarding/__tests__/goals.test.ts
create mode 100644 src/lib/onboarding/goals.ts
diff --git a/src/app/api/onboarding/__tests__/step.test.ts b/src/app/api/onboarding/__tests__/step.test.ts
index 0f8f3b06..1700626a 100644
--- a/src/app/api/onboarding/__tests__/step.test.ts
+++ b/src/app/api/onboarding/__tests__/step.test.ts
@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
updateMany: vi.fn(),
},
},
+ toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({
@@ -249,3 +250,147 @@ describe("POST /api/onboarding/step — rate limit", () => {
expect(res.status).toBe(429);
});
});
+
+describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => {
+ it("persists the selected goal slugs on the step-2 submit", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ onboardingStep: 1,
+ onboardingCompletedAt: null,
+ } as never);
+ vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
+ vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
+ onboardingStep: 2,
+ onboardingCompletedAt: null,
+ } as never);
+
+ const res = await POST(
+ req({ step: 2, goals: ["weight-management", "bp-tracking"] }),
+ );
+ expect(res.status).toBe(200);
+ const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
+ expect(data).toMatchObject({
+ onboardingStep: 2,
+ onboardingGoals: ["weight-management", "bp-tracking"],
+ });
+ });
+
+ it("persists an explicit empty array on skip (no preference)", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ onboardingStep: 1,
+ onboardingCompletedAt: null,
+ } as never);
+ vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
+ vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
+ onboardingStep: 2,
+ onboardingCompletedAt: null,
+ } as never);
+
+ await POST(req({ step: 2, goals: [] }));
+ const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
+ expect(data).toMatchObject({ onboardingGoals: [] });
+ });
+
+ it("rejects an unknown goal slug with 422", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] }));
+ expect(res.status).toBe(422);
+ expect(prisma.user.updateMany).not.toHaveBeenCalled();
+ });
+
+ it("does NOT write onboardingGoals when the field is omitted", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ onboardingStep: 1,
+ onboardingCompletedAt: null,
+ } as never);
+ vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
+ vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
+ onboardingStep: 2,
+ onboardingCompletedAt: null,
+ } as never);
+
+ await POST(req({ step: 2 }));
+ const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
+ expect(data).not.toHaveProperty("onboardingGoals");
+ });
+});
+
+describe("POST /api/onboarding/step — dashboard seeding on completion", () => {
+ it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ // 1st findUnique = the fresh step-read; 2nd = the seed-read.
+ vi.mocked(prisma.user.findUnique)
+ .mockResolvedValueOnce({
+ onboardingStep: 3,
+ onboardingCompletedAt: null,
+ } as never)
+ .mockResolvedValueOnce({
+ onboardingGoals: ["glucose-tracking"],
+ dashboardWidgetsJson: null,
+ } as never);
+ // 1st updateMany = the step claim; 2nd = the gated seed write.
+ vi.mocked(prisma.user.updateMany)
+ .mockResolvedValueOnce({ count: 1 } as never)
+ .mockResolvedValueOnce({ count: 1 } as never);
+ vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
+ onboardingStep: 4,
+ onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
+ } as never);
+
+ const res = await POST(req({ step: 4 }));
+ expect(res.status).toBe(200);
+ // The second updateMany is the seed; its WHERE pins the null gate
+ // and its data carries a dashboard layout.
+ expect(prisma.user.updateMany).toHaveBeenCalledTimes(2);
+ const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0];
+ expect(seedCall.data).toHaveProperty("dashboardWidgetsJson");
+ expect(seedCall.where).toHaveProperty("dashboardWidgetsJson");
+ });
+
+ it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique)
+ .mockResolvedValueOnce({
+ onboardingStep: 3,
+ onboardingCompletedAt: null,
+ } as never)
+ .mockResolvedValueOnce({
+ onboardingGoals: ["glucose-tracking"],
+ dashboardWidgetsJson: { version: 1, widgets: [] },
+ } as never);
+ vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
+ vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
+ onboardingStep: 4,
+ onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
+ } as never);
+
+ const res = await POST(req({ step: 4 }));
+ expect(res.status).toBe(200);
+ // Only the step-claim updateMany fired — the seed was gated out
+ // because the user already has a layout.
+ expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
+ });
+
+ it("does NOT seed when stored goals are empty (no preference)", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique)
+ .mockResolvedValueOnce({
+ onboardingStep: 3,
+ onboardingCompletedAt: null,
+ } as never)
+ .mockResolvedValueOnce({
+ onboardingGoals: [],
+ dashboardWidgetsJson: null,
+ } as never);
+ vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
+ vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
+ onboardingStep: 4,
+ onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
+ } as never);
+
+ await POST(req({ step: 4 }));
+ expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts
index 50e73f71..0ef6ee0e 100644
--- a/src/app/api/onboarding/step/route.ts
+++ b/src/app/api/onboarding/step/route.ts
@@ -10,9 +10,14 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
-import { prisma } from "@/lib/db";
+import { prisma, toJson } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
+import {
+ ONBOARDING_GOAL_SLUGS,
+ buildGoalSeededDashboardLayout,
+} from "@/lib/onboarding/goals";
/**
* v1.4.25 W14b — POST /api/onboarding/step.
@@ -42,6 +47,16 @@ import { checkRateLimit } from "@/lib/rate-limit";
const stepBodySchema = z.object({
step: z.number().int().min(1).max(4),
+ // v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2
+ // submit). Validated against the closed slug enum so an unknown slug
+ // 422s; deduped + capped at the full set size. Persisted to
+ // `User.onboardingGoals` (field-by-field, no mass assignment) and on
+ // completion (step 4) seeds the dashboard layout when the user never
+ // customized it. Omitted leaves the stored goals untouched.
+ goals: z
+ .array(z.enum(ONBOARDING_GOAL_SLUGS))
+ .max(ONBOARDING_GOAL_SLUGS.length)
+ .optional(),
});
export const POST = apiHandler(async (request: NextRequest) => {
@@ -74,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
return apiError("Invalid step payload", 422);
}
- const { step } = parsed.data;
+ const { step, goals } = parsed.data;
// Re-fetch the fresh User row so concurrent step submissions in
// separate tabs can't race past each other — the session.user
@@ -126,9 +141,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
onboardingCompletedAt: null,
onboardingStep: { in: [current] },
},
+ // Field-by-field assembly — no mass assignment. `goals` only
+ // lands when the client sent it (the step-2 submit); it is already
+ // validated against the closed slug enum by the Zod schema, so the
+ // array reaching Prisma can never contain an out-of-set value.
data: {
onboardingStep: step,
...(completing ? { onboardingCompletedAt: new Date() } : {}),
+ ...(goals !== undefined ? { onboardingGoals: goals } : {}),
},
});
if (claimed.count !== 1) {
@@ -149,11 +169,41 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
+ let goalsSeeded = false;
if (completing) {
// Mirror /api/onboarding/complete — clear the proxy-readable
// pending cookie so the next navigation drops the /onboarding
// redirect immediately.
await setOnboardingPendingCookie(false);
+
+ // v1.17.1 — seed the dashboard from the stored goal selection.
+ // ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who
+ // already arranged tiles is never clobbered. The seed promotes the
+ // goal-mapped tiles to the top and forces them visible; an empty /
+ // general-wellness-only selection produces null and leaves the
+ // column untouched (default layout). The resulting layout is what
+ // both the web dashboard and the iOS widgets contract read —
+ // server-authoritative, no client recompute.
+ const seedRow = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { onboardingGoals: true, dashboardWidgetsJson: true },
+ });
+ if (seedRow && seedRow.dashboardWidgetsJson == null) {
+ const seededLayout = buildGoalSeededDashboardLayout(
+ seedRow.onboardingGoals,
+ );
+ if (seededLayout) {
+ // updateMany so the `dashboardWidgetsJson: null` precondition
+ // rides in the WHERE clause — a concurrent Settings → Dashboard
+ // save that lands first leaves `count = 0` and the seed is
+ // skipped rather than overwriting the user's fresh layout.
+ const seeded = await prisma.user.updateMany({
+ where: { id: user.id, dashboardWidgetsJson: { equals: Prisma.JsonNull } },
+ data: { dashboardWidgetsJson: toJson(seededLayout) },
+ });
+ goalsSeeded = seeded.count === 1;
+ }
+ }
}
await auditLog("onboarding.step", {
@@ -167,7 +217,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "onboarding.step" },
- meta: { outcome: completing ? "completed" : "advanced", step },
+ meta: {
+ outcome: completing ? "completed" : "advanced",
+ step,
+ ...(goals !== undefined ? { goals_count: goals.length } : {}),
+ ...(completing ? { goals_seeded: goalsSeeded } : {}),
+ },
});
return apiSuccess({
diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx
index 63708d0b..488acd62 100644
--- a/src/components/onboarding/GoalsChipPicker.tsx
+++ b/src/components/onboarding/GoalsChipPicker.tsx
@@ -24,22 +24,19 @@ import { apiPost } from "@/lib/api/api-fetch";
* v1.4.25 W14b-Content — onboarding step 1 (goals).
*
* Multi-select chip picker for the metrics the user wants to track.
- * The goal slugs match a stable enum that v1.4.26 will land on the
- * `User` row (`User.onboardingGoals`). Until that column ships the
- * picker holds selection in component state only — there's no
- * persistence layer to drop into. The earlier localStorage block
- * had no consumer (no other surface reads the same key) and the
- * single-step wizard makes pre-selection on resume unnecessary, so
- * leaning on component state until the column lands keeps the code
- * surface honest.
+ * The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS`
+ * (`src/lib/onboarding/goals.ts`). Selection lives in component state
+ * while the step is open; on advance it is persisted server-side to
+ * `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile
+ * layout on completion and feeds reminder suggestions.
*
* CTAs:
* - Back → `/onboarding/0`
- * - Skip → advance with empty goal set
+ * - Skip → advance with empty goal set (explicit "no preference")
* - Next → advance with chosen goal set
*
- * Both Skip and Next call POST `/api/onboarding/step` with `{ step: 2 }`
- * and `router.push("/onboarding/2")`.
+ * Both Skip and Next call POST `/api/onboarding/step` with
+ * `{ step: 2, goals }` and `router.push("/onboarding/2")`.
*/
export type OnboardingGoalSlug =
@@ -122,7 +119,14 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
if (advancing) return;
setAdvancing(true);
try {
- await apiPost("/api/onboarding/step", { step: 2 });
+ // v1.17.1 — persist the chosen goal slugs alongside the step
+ // write. Skip sends an empty array (explicit "no preference"),
+ // not omission, so a resume can read the deliberate empty choice.
+ // The server seeds the dashboard from these on completion.
+ await apiPost("/api/onboarding/step", {
+ step: 2,
+ goals: Array.from(selected),
+ });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/2");
} catch (err) {
diff --git a/src/lib/onboarding/__tests__/goals.test.ts b/src/lib/onboarding/__tests__/goals.test.ts
new file mode 100644
index 00000000..039be8b0
--- /dev/null
+++ b/src/lib/onboarding/__tests__/goals.test.ts
@@ -0,0 +1,104 @@
+/**
+ * v1.17.1 — onboarding goal slug enum + dashboard seeding.
+ *
+ * Goals are a personalization seed, not a clinical target store: the
+ * only leverage they have is which dashboard tiles get promoted and
+ * made visible. These tests pin (a) the closed slug guard and (b) the
+ * one-time seed builder's promote/forces-visible/no-op contracts.
+ */
+import { describe, it, expect } from "vitest";
+
+import {
+ ONBOARDING_GOAL_SLUGS,
+ GOAL_WIDGET_SEED_MAP,
+ isOnboardingGoalSlug,
+ buildGoalSeededDashboardLayout,
+} from "../goals";
+import {
+ DEFAULT_DASHBOARD_LAYOUT,
+ type DashboardWidgetId,
+} from "@/lib/dashboard-layout";
+
+describe("isOnboardingGoalSlug", () => {
+ it("accepts every member of the closed slug set", () => {
+ for (const slug of ONBOARDING_GOAL_SLUGS) {
+ expect(isOnboardingGoalSlug(slug)).toBe(true);
+ }
+ });
+
+ it("rejects unknown / non-string values", () => {
+ expect(isOnboardingGoalSlug("not-a-goal")).toBe(false);
+ expect(isOnboardingGoalSlug("")).toBe(false);
+ expect(isOnboardingGoalSlug(null)).toBe(false);
+ expect(isOnboardingGoalSlug(42)).toBe(false);
+ });
+});
+
+describe("GOAL_WIDGET_SEED_MAP", () => {
+ it("only maps to ids the dashboard layout actually knows", () => {
+ const known = new Set(
+ DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId),
+ );
+ for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) {
+ for (const id of ids) expect(known.has(id)).toBe(true);
+ }
+ });
+
+ it("leaves general-wellness without a tile preference", () => {
+ expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]);
+ });
+});
+
+describe("buildGoalSeededDashboardLayout", () => {
+ it("returns null for an empty selection (no seed)", () => {
+ expect(buildGoalSeededDashboardLayout([])).toBeNull();
+ });
+
+ it("returns null for a general-wellness-only selection", () => {
+ expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull();
+ });
+
+ it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => {
+ expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull();
+ });
+
+ it("promotes glucose to the top and forces it visible", () => {
+ const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]);
+ expect(layout).not.toBeNull();
+ const widgets = layout!.widgets;
+ // glucose is default-invisible + ordered low; the seed lifts it to
+ // order 0 and turns both surfaces on.
+ const glucose = widgets.find((w) => w.id === "glucose");
+ expect(glucose).toMatchObject({
+ order: 0,
+ visible: true,
+ tileVisible: true,
+ });
+ });
+
+ it("promotes every mapped tile for a multi-goal selection", () => {
+ const layout = buildGoalSeededDashboardLayout([
+ "weight-management",
+ "bp-tracking",
+ ]);
+ expect(layout).not.toBeNull();
+ const widgets = layout!.widgets;
+ const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]);
+ // Every promoted id sits in the leading block and is visible.
+ for (const id of promotedIds) {
+ const w = widgets.find((x) => x.id === id);
+ expect(w?.visible).toBe(true);
+ expect(w?.tileVisible).toBe(true);
+ expect(w!.order).toBeLessThan(promotedIds.size);
+ }
+ });
+
+ it("keeps a dense, complete widget set (no tiles dropped)", () => {
+ const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]);
+ expect(layout!.widgets.length).toBe(
+ DEFAULT_DASHBOARD_LAYOUT.widgets.length,
+ );
+ const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b);
+ expect(orders).toEqual(orders.map((_, i) => i));
+ });
+});
diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts
new file mode 100644
index 00000000..4a2fc7eb
--- /dev/null
+++ b/src/lib/onboarding/goals.ts
@@ -0,0 +1,108 @@
+/**
+ * v1.17.1 — onboarding goal slugs + dashboard seeding.
+ *
+ * The closed set of goal slugs the GoalsChipPicker offers, plus the
+ * server-authoritative seeding that turns a goal selection into a
+ * personalization signal: which dashboard tiles get promoted to the
+ * top and made visible. Goals are NOT a clinical target store — the
+ * targets surface (`/api/insights/targets`) is fully derived from
+ * height/age/gender + clinical references. Their only leverage is
+ * (a) dashboard tile pinning (here) and (b) reminder suggestions
+ * (read by the reminders panel from `User.onboardingGoals`).
+ *
+ * One home for the slug enum so the API validation
+ * (`/api/onboarding/step`), the client picker, and the seeding all
+ * read the same source — same-key drift was the v1.4.16 dashboard-enum
+ * bug class.
+ */
+import {
+ DEFAULT_DASHBOARD_LAYOUT,
+ serializeDashboardLayout,
+ type DashboardLayout,
+ type DashboardWidgetId,
+} from "@/lib/dashboard-layout";
+
+/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */
+export const ONBOARDING_GOAL_SLUGS = [
+ "weight-management",
+ "bp-tracking",
+ "glucose-tracking",
+ "sleep-improvement",
+ "medication-compliance",
+ "general-wellness",
+] as const;
+
+export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number];
+
+export function isOnboardingGoalSlug(
+ value: unknown,
+): value is OnboardingGoalSlug {
+ return (
+ typeof value === "string" &&
+ (ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value)
+ );
+}
+
+/**
+ * Slug → dashboard widget ids to promote. `general-wellness` maps to
+ * nothing — it carries no tile preference, so a general-wellness-only
+ * selection leaves the default layout untouched. Ids must be members
+ * of `DASHBOARD_WIDGET_IDS`; the seed never invents an id.
+ */
+export const GOAL_WIDGET_SEED_MAP: Record<
+ OnboardingGoalSlug,
+ readonly DashboardWidgetId[]
+> = {
+ "weight-management": ["weight", "bodyFat"],
+ "bp-tracking": ["bp", "bpInTarget"],
+ "glucose-tracking": ["glucose"],
+ "sleep-improvement": ["sleep"],
+ "medication-compliance": ["medications"],
+ "general-wellness": [],
+};
+
+/**
+ * Build the seeded dashboard layout for a goal selection.
+ *
+ * Returns a layout where the goal-mapped tiles are promoted to the top
+ * of the order (preserving their relative order within the default
+ * layout) and forced visible on both surfaces; every other tile keeps
+ * its default config, shifted down. Returns `null` when there is
+ * nothing to promote (empty selection or general-wellness only) so the
+ * caller can skip the write entirely and leave the column null →
+ * default layout.
+ *
+ * This is a ONE-TIME seed: the caller MUST gate it on
+ * `User.dashboardWidgetsJson == null` so it never clobbers a user who
+ * already arranged tiles.
+ */
+export function buildGoalSeededDashboardLayout(
+ goals: readonly string[] | null | undefined,
+): DashboardLayout | null {
+ if (!goals || goals.length === 0) return null;
+ const promoted = new Set();
+ for (const slug of goals) {
+ if (!isOnboardingGoalSlug(slug)) continue;
+ for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id);
+ }
+ if (promoted.size === 0) return null;
+
+ const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
+ const promotedWidgets = base.filter((w) => promoted.has(w.id as DashboardWidgetId));
+ const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
+
+ const ordered = [...promotedWidgets, ...rest].map((w, index) => ({
+ ...w,
+ // Force the goal-mapped tiles visible on both surfaces; leave the
+ // rest exactly as the default layout had them.
+ ...(promoted.has(w.id as DashboardWidgetId)
+ ? { visible: true, tileVisible: true }
+ : {}),
+ order: index,
+ }));
+
+ return serializeDashboardLayout({
+ ...DEFAULT_DASHBOARD_LAYOUT,
+ widgets: ordered,
+ });
+}
From c8cd5aa22602a78f764d07d5c6a9be49ceb2e120 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:21:58 +0200
Subject: [PATCH 12/79] feat(onboarding): add an optional anamnesis step on
baseline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A collapsible "About your health" card on the baseline step captures
the genuinely-new baseline the app can act on — chronic conditions and
allergies — without re-asking the height/sex/birthdate already gathered
above. Both write through the existing encrypted self-context path
(PUT /api/coach/about-me), so there is no new model and no new
contract; the form preserves any existing free-text about-me on save
and only sends a field the user actually typed.
The card is collapsed by default with a clear optional framing, so the
step stays clear and the progress bar keeps its five dots; every field
is editable later in Settings. Copy is warm and medically grounded —
context, not a clinical form — with keys across all six locales. Also
re-truths the goals body copy now that the selection genuinely surfaces
on the dashboard.
---
messages/de.json | 13 +-
messages/en.json | 13 +-
messages/es.json | 13 +-
messages/fr.json | 13 +-
messages/it.json | 13 +-
messages/pl.json | 13 +-
src/components/onboarding/AnamnesisCard.tsx | 155 ++++++++++++++++++
src/components/onboarding/BaselineForm.tsx | 61 ++++++-
.../__tests__/anamnesis-card.test.tsx | 79 +++++++++
9 files changed, 363 insertions(+), 10 deletions(-)
create mode 100644 src/components/onboarding/AnamnesisCard.tsx
create mode 100644 src/components/onboarding/__tests__/anamnesis-card.test.tsx
diff --git a/messages/de.json b/messages/de.json
index 01784ec8..3f3db97a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Was möchtest du erfassen?",
- "body": "Wähle die Bereiche, die dir wichtig sind – das hilft dir zu entscheiden, was du verfolgen und als Nächstes einrichten willst.",
+ "body": "Wähle die Bereiche, die dir wichtig sind – wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.",
"helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.",
"options": {
"weightManagement": "Gewicht im Griff",
@@ -3733,6 +3733,17 @@
"title": "Erkenne das Muster",
"body": "Tageszusammenfassungen, Trend-Diagramme und ein Coach, der zeigt was sich verändert hat — alles auf deinem Gerät."
}
+ },
+ "anamnesis": {
+ "title": "Zu deiner Gesundheit (optional)",
+ "subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.",
+ "intro": "Teile so viel oder so wenig, wie du möchtest – jedes Feld ist optional und jederzeit in den Einstellungen änderbar.",
+ "conditionsLabel": "Bestehende Erkrankungen",
+ "conditionsPlaceholder": "z. B. Bluthochdruck, Typ-2-Diabetes",
+ "conditionsHint": "Hilft HealthLog, deine Werte im Kontext einzuordnen, statt Erwartbares zu melden.",
+ "allergiesLabel": "Allergien oder Unverträglichkeiten",
+ "allergiesPlaceholder": "z. B. Penicillin, Laktose",
+ "privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab – die kannst du jederzeit auf der Medikamenten-Seite ergänzen."
}
},
"gettingStarted": {
diff --git a/messages/en.json b/messages/en.json
index a78cc1a2..67925684 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "What would you like to track?",
- "body": "Pick the areas you care about — it helps you decide what to track and set up next.",
+ "body": "Pick the areas you care about — we'll surface them first on your dashboard, and they help you decide what to set up next.",
"helpHint": "One to three priorities tend to work best — you can always add more later.",
"options": {
"weightManagement": "Weight management",
@@ -3733,6 +3733,17 @@
"title": "See the pattern",
"body": "Daily summaries, trend charts, and a coach that surfaces what changed — all on your own device."
}
+ },
+ "anamnesis": {
+ "title": "About your health (optional)",
+ "subtitle": "A little context so HealthLog reads your numbers more fairly.",
+ "intro": "Share as much or as little as you like — every field is optional and you can edit it anytime in Settings.",
+ "conditionsLabel": "Ongoing conditions",
+ "conditionsPlaceholder": "e.g. high blood pressure, type 2 diabetes",
+ "conditionsHint": "Helps HealthLog frame your readings in context rather than flagging the expected.",
+ "allergiesLabel": "Allergies or intolerances",
+ "allergiesPlaceholder": "e.g. penicillin, lactose",
+ "privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here — add those anytime from the Medications page."
}
},
"gettingStarted": {
diff --git a/messages/es.json b/messages/es.json
index 07ef722c..2a34af1a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "¿Qué te gustaría registrar?",
- "body": "Elige las áreas que te interesan: te ayuda a decidir qué seguir y qué configurar a continuación.",
+ "body": "Elige las áreas que te interesan: las mostraremos primero en tu panel y te ayudarán a decidir qué configurar a continuación.",
"helpHint": "Una a tres prioridades suelen funcionar mejor — siempre podrás añadir más más adelante.",
"options": {
"weightManagement": "Control del peso",
@@ -3733,6 +3733,17 @@
"title": "Descubre los patrones",
"body": "Resúmenes diarios, gráficas de tendencias y un coach que destaca lo que cambió — todo en tu propio dispositivo."
}
+ },
+ "anamnesis": {
+ "title": "Sobre tu salud (opcional)",
+ "subtitle": "Un poco de contexto para que HealthLog interprete tus valores de forma más justa.",
+ "intro": "Comparte lo que quieras: cada campo es opcional y puedes editarlo cuando quieras en Ajustes.",
+ "conditionsLabel": "Afecciones actuales",
+ "conditionsPlaceholder": "p. ej. hipertensión, diabetes tipo 2",
+ "conditionsHint": "Ayuda a HealthLog a interpretar tus lecturas en contexto en lugar de marcar lo esperado.",
+ "allergiesLabel": "Alergias o intolerancias",
+ "allergiesPlaceholder": "p. ej. penicilina, lactosa",
+ "privacyNote": "Se almacena cifrado y solo se usa para darte mejor contexto. Aquí no te pedimos tu medicación: puedes añadirla cuando quieras desde la página de Medicación."
}
},
"gettingStarted": {
diff --git a/messages/fr.json b/messages/fr.json
index acec48c7..62b8a5f1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Que souhaitez-vous suivre ?",
- "body": "Sélectionnez les domaines qui comptent pour vous : cela vous aide à décider quoi suivre et configurer ensuite.",
+ "body": "Sélectionnez les domaines qui comptent pour vous : nous les afficherons en premier sur votre tableau de bord, et ils vous aident à décider quoi configurer ensuite.",
"helpHint": "Un à trois axes prioritaires fonctionnent généralement le mieux, mais vous pourrez en ajouter plus tard.",
"options": {
"weightManagement": "Gestion du poids",
@@ -3733,6 +3733,17 @@
"title": "Repérez les tendances",
"body": "Résumés quotidiens, graphiques de tendances et un coach qui met en avant ce qui a changé — le tout sur votre appareil."
}
+ },
+ "anamnesis": {
+ "title": "À propos de votre santé (facultatif)",
+ "subtitle": "Un peu de contexte pour que HealthLog interprète vos valeurs plus justement.",
+ "intro": "Partagez autant ou aussi peu que vous le souhaitez : chaque champ est facultatif et modifiable à tout moment dans les Réglages.",
+ "conditionsLabel": "Affections en cours",
+ "conditionsPlaceholder": "p. ex. hypertension, diabète de type 2",
+ "conditionsHint": "Aide HealthLog à replacer vos mesures dans leur contexte plutôt que de signaler l'attendu.",
+ "allergiesLabel": "Allergies ou intolérances",
+ "allergiesPlaceholder": "p. ex. pénicilline, lactose",
+ "privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici — ajoutez-les quand vous voulez depuis la page Médicaments."
}
},
"gettingStarted": {
diff --git a/messages/it.json b/messages/it.json
index e825a177..b65f81c2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Cosa vuoi monitorare?",
- "body": "Scegli gli ambiti che ti interessano: ti aiuta a decidere cosa monitorare e configurare in seguito.",
+ "body": "Scegli gli ambiti che ti interessano: li mostreremo per primi nella tua dashboard e ti aiutano a decidere cosa configurare in seguito.",
"helpHint": "Da una a tre priorità funzionano di solito meglio — potrai aggiungerne altre in qualsiasi momento.",
"options": {
"weightManagement": "Gestione del peso",
@@ -3733,6 +3733,17 @@
"title": "Scopri lo schema",
"body": "Riepiloghi giornalieri, grafici di tendenza e un coach che evidenzia i cambiamenti — tutto sul tuo dispositivo."
}
+ },
+ "anamnesis": {
+ "title": "Sulla tua salute (facoltativo)",
+ "subtitle": "Un po' di contesto perché HealthLog interpreti i tuoi valori in modo più equo.",
+ "intro": "Condividi quanto vuoi: ogni campo è facoltativo e modificabile in qualsiasi momento nelle Impostazioni.",
+ "conditionsLabel": "Condizioni in corso",
+ "conditionsPlaceholder": "es. ipertensione, diabete di tipo 2",
+ "conditionsHint": "Aiuta HealthLog a inquadrare le tue misurazioni nel contesto invece di segnalare l'atteso.",
+ "allergiesLabel": "Allergie o intolleranze",
+ "allergiesPlaceholder": "es. penicillina, lattosio",
+ "privacyNote": "Memorizzato cifrato e usato solo per darti un contesto migliore. Qui non chiediamo i tuoi farmaci: aggiungili quando vuoi dalla pagina Farmaci."
}
},
"gettingStarted": {
diff --git a/messages/pl.json b/messages/pl.json
index b551d7b0..22ee2065 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Co chcesz śledzić?",
- "body": "Wybierz obszary, które są dla Ciebie ważne — to pomaga zdecydować, co śledzić i co skonfigurować w następnej kolejności.",
+ "body": "Wybierz obszary, które są dla Ciebie ważne — pokażemy je najpierw na pulpicie i pomogą Ci zdecydować, co skonfigurować dalej.",
"helpHint": "Od jednego do trzech priorytetów zwykle sprawdza się najlepiej — możesz dodać kolejne w dowolnej chwili.",
"options": {
"weightManagement": "Kontrola wagi",
@@ -3733,6 +3733,17 @@
"title": "Zobacz wzorce",
"body": "Codzienne podsumowania, wykresy trendów i coach, który wskazuje co się zmieniło — wszystko na Twoim urządzeniu."
}
+ },
+ "anamnesis": {
+ "title": "O Twoim zdrowiu (opcjonalnie)",
+ "subtitle": "Trochę kontekstu, aby HealthLog rzetelniej odczytywał Twoje wyniki.",
+ "intro": "Podziel się tym, czym chcesz — każde pole jest opcjonalne i możesz je zmienić w dowolnej chwili w Ustawieniach.",
+ "conditionsLabel": "Aktualne schorzenia",
+ "conditionsPlaceholder": "np. nadciśnienie, cukrzyca typu 2",
+ "conditionsHint": "Pomaga HealthLog ujmować Twoje pomiary w kontekście, zamiast oznaczać to, czego należy się spodziewać.",
+ "allergiesLabel": "Alergie lub nietolerancje",
+ "allergiesPlaceholder": "np. penicylina, laktoza",
+ "privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki — dodasz je w dowolnej chwili na stronie Leki."
}
},
"gettingStarted": {
diff --git a/src/components/onboarding/AnamnesisCard.tsx b/src/components/onboarding/AnamnesisCard.tsx
new file mode 100644
index 00000000..723d6f98
--- /dev/null
+++ b/src/components/onboarding/AnamnesisCard.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { useId, useState } from "react";
+import { ChevronDown, HeartHandshake } from "lucide-react";
+
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.17.1 — optional "About your health" card on the onboarding
+ * Baseline step (3).
+ *
+ * Captures the genuinely-new baseline the app can act on — chronic
+ * conditions and allergies / intolerances — that the height/sex/dob
+ * fields above do NOT already cover. Both write through the existing
+ * encrypted self-context path (`PUT /api/coach/about-me`), so there is
+ * no new model and no new contract; the parent BaselineForm preserves
+ * any existing free-text `aboutMe` on save.
+ *
+ * Collapsed by default with a prominent skip framing — the step stays
+ * übersichtlich and every field is optional and editable later in
+ * Settings → AI. Warm, medically-grounded copy: framed as "so
+ * HealthLog reads your numbers in context," never as a clinical form.
+ *
+ * Controlled: the parent owns the values so it can persist them in the
+ * same submit as the profile fields.
+ */
+export interface AnamnesisValue {
+ conditions: string;
+ allergies: string;
+}
+
+/**
+ * Build the `PUT /api/coach/about-me` body for the anamnesis answers,
+ * or `null` when the card was left untouched (so an untouched collapsed
+ * card never round-trips). Preserves the existing free-text `aboutMe`
+ * (the PUT schema requires it and an empty value clears it) and only
+ * includes conditions / allergies when the user typed something — so an
+ * unrelated stored value is never cleared. Field-by-field assembly: no
+ * mass-spread of the value object into the body.
+ */
+export function buildAnamnesisAboutMeBody(
+ baseAboutMe: string,
+ value: AnamnesisValue,
+): Record | null {
+ const conditions = value.conditions.trim();
+ const allergies = value.allergies.trim();
+ if (!conditions && !allergies) return null;
+ const body: Record = { aboutMe: baseAboutMe };
+ if (conditions) body.conditions = conditions;
+ if (allergies) body.allergies = allergies;
+ return body;
+}
+
+export interface AnamnesisCardProps {
+ value: AnamnesisValue;
+ onChange: (next: AnamnesisValue) => void;
+ disabled?: boolean;
+}
+
+/** Per-field cap mirrors ABOUT_ME_FIELD_MAX_CHARS on the server. */
+const FIELD_MAX = 500;
+
+export function AnamnesisCard({ value, onChange, disabled }: AnamnesisCardProps) {
+ const { t } = useTranslations();
+ const [expanded, setExpanded] = useState(false);
+ const conditionsId = useId();
+ const allergiesId = useId();
+ const panelId = useId();
+
+ return (
+
+
+
+ {expanded ? (
+
+
+ {t("onboarding.anamnesis.intro")}
+
+
+
+
+
+
+
+
+
+
+
+ {t("onboarding.anamnesis.privacyNote")}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/onboarding/BaselineForm.tsx b/src/components/onboarding/BaselineForm.tsx
index ba933c75..d7a27ec5 100644
--- a/src/components/onboarding/BaselineForm.tsx
+++ b/src/components/onboarding/BaselineForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
@@ -19,7 +19,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
-import { apiPost, apiPut } from "@/lib/api/api-fetch";
+import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import {
+ AnamnesisCard,
+ buildAnamnesisAboutMeBody,
+ type AnamnesisValue,
+} from "@/components/onboarding/AnamnesisCard";
/**
* v1.4.25 W14b-Content — onboarding step 3 (baseline).
@@ -35,9 +40,12 @@ import { apiPost, apiPut } from "@/lib/api/api-fetch";
*
* Submit flow on "Save and continue":
* 1. PUT profile (best-effort — empty fields are skipped).
- * 2. POST step:4 — flips `onboardingCompletedAt` server-side and
+ * 2. PUT /api/coach/about-me — only when the optional anamnesis card
+ * (v1.17.1) was filled; preserves any existing `aboutMe` and
+ * writes conditions / allergies encrypted at rest.
+ * 3. POST step:4 — flips `onboardingCompletedAt` server-side and
* clears the proxy cookie.
- * 3. router.push("/onboarding/4") — the done screen.
+ * 4. router.push("/onboarding/4") — the done screen.
*
* "Skip" advances without writing profile data; the wizard still
* completes onboarding (the user can fill profile later from
@@ -66,6 +74,37 @@ export function BaselineForm() {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
+ // v1.17.1 — optional anamnesis (conditions + allergies). Persisted
+ // through the existing encrypted self-context path
+ // (`PUT /api/coach/about-me`). We read the current self-context once
+ // so a returning/resuming user's free-text `aboutMe` is preserved on
+ // save (the PUT schema requires `aboutMe`, and an empty value clears
+ // it). A fresh user has no self-context, so `baseAboutMe` stays "".
+ const [anamnesis, setAnamnesis] = useState({
+ conditions: "",
+ allergies: "",
+ });
+ const [baseAboutMe, setBaseAboutMe] = useState("");
+
+ useEffect(() => {
+ let active = true;
+ void apiGet<{ aboutMe: string | null }>("/api/coach/about-me")
+ .then((ctx) => {
+ if (active && typeof ctx.aboutMe === "string") {
+ setBaseAboutMe(ctx.aboutMe);
+ }
+ })
+ .catch(() => {
+ // Non-fatal — onboarding must never block on the self-context
+ // read. A fresh user has none; on error we keep the "" base,
+ // which the PUT below only sends when the user actually typed
+ // an anamnesis answer.
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
function patch(
key: K,
value: BaselineFormState[K],
@@ -92,6 +131,14 @@ export function BaselineForm() {
if (Object.keys(profileBody).length > 0) {
await apiPut("/api/auth/profile", profileBody);
}
+
+ // Anamnesis — only write when the user actually filled a field
+ // (the helper returns null for an untouched card, so a
+ // collapsed card never round-trips).
+ const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
+ if (aboutMeBody) {
+ await apiPut("/api/coach/about-me", aboutMeBody);
+ }
}
await apiPost("/api/onboarding/step", { step: 4 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
@@ -210,6 +257,12 @@ export function BaselineForm() {
+
+
diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts
index 983d019b..5110c1e9 100644
--- a/src/lib/validations/__tests__/measurement.test.ts
+++ b/src/lib/validations/__tests__/measurement.test.ts
@@ -206,6 +206,26 @@ describe("measurement validation", () => {
expect(parsed.data.deviceType).toBeUndefined();
}
});
+
+ // v1.17.1 — the `stats:`-prefix overwrite contract is deliberately
+ // batch-scoped. The single manual POST must never accept an
+ // `externalId`, which would open a client-controlled overwrite vector
+ // on the `(userId, type, source, externalId)` unique key. The schema
+ // strips it (no passthrough), so the parsed value never carries one.
+ it("does not surface an externalId on the manual create schema (overwrite contract)", () => {
+ const parsed = createMeasurementSchema.safeParse({
+ ...validBase,
+ type: "WEIGHT",
+ value: 82,
+ externalId: "stats:HKQuantityTypeIdentifierBodyMass:2026-04-27",
+ });
+ expect(parsed.success).toBe(true);
+ if (parsed.success) {
+ expect(
+ (parsed.data as Record).externalId,
+ ).toBeUndefined();
+ }
+ });
});
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
From 988ba1196e439961a47137840bf2287923df4071 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:33:05 +0200
Subject: [PATCH 17/79] feat(reminders): server-authoritative next-due engine
for preventive-care reminders
Add the Vorsorge (measurement) reminder cadence layer: a thin adapter that
maps a reminder onto the canonical recurrence engine so a rolling
"every N days" or an RFC-5545 rrule cadence is driven by the same code the
medication next-due line uses. nextDueAt is computed and stored on the
server, so web and iPhone read identical numbers. Covers the DTO serializer
and the create/update validation schemas (exactly one of intervalDays or
rrule; BP auto-resolves on the SYS sentinel).
---
.../__tests__/scheduling.test.ts | 100 +++++++++++
src/lib/measurement-reminders/dto.ts | 47 +++++
src/lib/measurement-reminders/scheduling.ts | 120 +++++++++++++
src/lib/validations/measurement-reminders.ts | 162 ++++++++++++++++++
4 files changed, 429 insertions(+)
create mode 100644 src/lib/measurement-reminders/__tests__/scheduling.test.ts
create mode 100644 src/lib/measurement-reminders/dto.ts
create mode 100644 src/lib/measurement-reminders/scheduling.ts
create mode 100644 src/lib/validations/measurement-reminders.ts
diff --git a/src/lib/measurement-reminders/__tests__/scheduling.test.ts b/src/lib/measurement-reminders/__tests__/scheduling.test.ts
new file mode 100644
index 00000000..e49caa45
--- /dev/null
+++ b/src/lib/measurement-reminders/__tests__/scheduling.test.ts
@@ -0,0 +1,100 @@
+/**
+ * v1.17.1 — server-authoritative next-due computation for Vorsorge
+ * reminders. Pins the rolling + rrule cadence contract that web ↔ iOS
+ * both read off `nextDueAt`.
+ */
+import { describe, it, expect } from "vitest";
+
+import {
+ computeReminderNextDueAt,
+ type ReminderScheduleInput,
+} from "../scheduling";
+
+const TZ = "Europe/Berlin";
+
+function base(overrides: Partial): ReminderScheduleInput {
+ return {
+ intervalDays: null,
+ rrule: null,
+ anchorDate: null,
+ notifyHour: 9,
+ lastSatisfiedAt: null,
+ createdAt: new Date("2026-06-01T08:00:00Z"),
+ ...overrides,
+ };
+}
+
+describe("computeReminderNextDueAt", () => {
+ it("returns null when neither intervalDays nor rrule is set", () => {
+ const now = new Date("2026-06-10T07:00:00Z");
+ expect(computeReminderNextDueAt(base({}), TZ, now)).toBeNull();
+ });
+
+ it("rolling, never satisfied: first due AT the anchor day notify-hour", () => {
+ // Anchor 2026-06-10; notifyHour 9 local (Berlin = UTC+2 in June → 07:00Z).
+ const reminder = base({
+ intervalDays: 7,
+ anchorDate: new Date("2026-06-10T00:00:00Z"),
+ notifyHour: 9,
+ createdAt: new Date("2026-06-09T00:00:00Z"),
+ });
+ // "after" before the anchor → the first due is the anchor's slot.
+ const due = computeReminderNextDueAt(
+ reminder,
+ TZ,
+ new Date("2026-06-09T12:00:00Z"),
+ );
+ expect(due).not.toBeNull();
+ // 09:00 Berlin on 2026-06-10 == 07:00Z (DST).
+ expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z");
+ });
+
+ it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => {
+ const reminder = base({
+ intervalDays: 7,
+ lastSatisfiedAt: new Date("2026-06-10T15:00:00Z"),
+ notifyHour: 9,
+ });
+ const due = computeReminderNextDueAt(
+ reminder,
+ TZ,
+ new Date("2026-06-10T16:00:00Z"),
+ );
+ expect(due).not.toBeNull();
+ // 7 days after the satisfy → 2026-06-17, slot at 09:00 Berlin = 07:00Z.
+ expect(due!.toISOString()).toBe("2026-06-17T07:00:00.000Z");
+ });
+
+ it("rolling honours the interval length (30 days)", () => {
+ const reminder = base({
+ intervalDays: 30,
+ lastSatisfiedAt: new Date("2026-06-01T15:00:00Z"),
+ notifyHour: 9,
+ });
+ const due = computeReminderNextDueAt(
+ reminder,
+ TZ,
+ new Date("2026-06-02T00:00:00Z"),
+ );
+ expect(due!.toISOString()).toBe("2026-07-01T07:00:00.000Z");
+ });
+
+ it("rrule annual: walks to the next yearly occurrence after now", () => {
+ // FREQ=YEARLY anchored on the createdAt day (2026-06-01).
+ const reminder = base({
+ rrule: "FREQ=YEARLY",
+ anchorDate: new Date("2026-06-01T00:00:00Z"),
+ createdAt: new Date("2026-06-01T00:00:00Z"),
+ notifyHour: 9,
+ });
+ // After the 2026 occurrence → expect the 2027 one.
+ const due = computeReminderNextDueAt(
+ reminder,
+ TZ,
+ new Date("2026-06-02T00:00:00Z"),
+ );
+ expect(due).not.toBeNull();
+ expect(due!.getUTCFullYear()).toBe(2027);
+ expect(due!.getUTCMonth()).toBe(5); // June (0-indexed)
+ });
+});
diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts
new file mode 100644
index 00000000..14ddd223
--- /dev/null
+++ b/src/lib/measurement-reminders/dto.ts
@@ -0,0 +1,47 @@
+/**
+ * v1.17.1 — DTO serializer for Vorsorge (measurement) reminders.
+ *
+ * Maps the Prisma `MeasurementReminder` row onto the canonical
+ * `MeasurementReminderDTO` the web list, dashboard tile, and iOS client
+ * all mirror. Dates serialise to ISO-8601 with offset.
+ */
+import type { MeasurementReminder } from "@/generated/prisma/client";
+import type { MeasurementReminderType } from "@/lib/validations/measurement-reminders";
+
+export interface MeasurementReminderDtoShape {
+ id: string;
+ label: string;
+ measurementType: MeasurementReminderType | null;
+ intervalDays: number | null;
+ rrule: string | null;
+ anchorDate: string | null;
+ notifyHour: number;
+ location: string | null;
+ nextDueAt: string | null;
+ lastSatisfiedAt: string | null;
+ enabled: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export function toMeasurementReminderDto(
+ row: MeasurementReminder,
+): MeasurementReminderDtoShape {
+ return {
+ id: row.id,
+ label: row.label,
+ measurementType: row.measurementType as MeasurementReminderType | null,
+ intervalDays: row.intervalDays,
+ rrule: row.rrule,
+ anchorDate: row.anchorDate ? row.anchorDate.toISOString() : null,
+ notifyHour: row.notifyHour,
+ location: row.location,
+ nextDueAt: row.nextDueAt ? row.nextDueAt.toISOString() : null,
+ lastSatisfiedAt: row.lastSatisfiedAt
+ ? row.lastSatisfiedAt.toISOString()
+ : null,
+ enabled: row.enabled,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+}
diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts
new file mode 100644
index 00000000..85644863
--- /dev/null
+++ b/src/lib/measurement-reminders/scheduling.ts
@@ -0,0 +1,120 @@
+/**
+ * v1.17.1 — server-authoritative next-due computation for Vorsorge
+ * (measurement) reminders.
+ *
+ * Reuses the canonical medication recurrence engine
+ * (`src/lib/medications/scheduling/recurrence.ts`) so a Vorsorge cadence
+ * is driven by exactly the same code that powers the medication
+ * "nextDueAt" line — web ↔ iOS read identical numbers (server-authoritative
+ * per project memory: iOS consumes the resolved DTO, never recomputes).
+ *
+ * A `MeasurementReminder` maps onto the engine's `CanonicalSchedule` +
+ * `RecurrenceContext` as follows:
+ *
+ * - rolling `intervalDays` → `rollingIntervalDays`, anchored on
+ * `lastSatisfiedAt ?? anchorDate ?? createdAt`. With no satisfy yet
+ * the first due is AT the anchor (not anchor + N); once satisfied the
+ * `+ N` cadence begins, exactly like a rolling medication's
+ * last-intake anchor.
+ * - `rrule` → passed straight through (RFC-5545).
+ * - the single `notifyHour` → the schedule's one `timesOfDay` entry, so
+ * the slot fires at the user's chosen local hour (DST-safe — the
+ * engine applies the time in the user's IANA timezone).
+ *
+ * Pure: no DB access. The caller fetches the reminder row + the user's
+ * timezone and threads them in.
+ */
+import {
+ type CanonicalSchedule,
+ type RecurrenceContext,
+ nextOccurrenceAfter,
+} from "@/lib/medications/scheduling/recurrence";
+
+/**
+ * The reminder fields this module reads. A subset of the Prisma row so
+ * tests can construct it without the full model.
+ */
+export interface ReminderScheduleInput {
+ intervalDays: number | null;
+ rrule: string | null;
+ anchorDate: Date | null;
+ notifyHour: number;
+ lastSatisfiedAt: Date | null;
+ createdAt: Date;
+}
+
+function hourToHhmm(hour: number): string {
+ const safe = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9;
+ return `${safe.toString().padStart(2, "0")}:00`;
+}
+
+/**
+ * Build the `(CanonicalSchedule, RecurrenceContext)` pair the engine
+ * consumes for one Vorsorge reminder.
+ *
+ * The rolling anchor is threaded through the engine's `lastIntakeAt`
+ * (after a satisfy) and `medication.startsOn` (the first-due anchor when
+ * never satisfied) so the rolling path's "first dose AT the anchor, then
+ * + N after the first satisfy" semantics apply verbatim.
+ */
+export function buildReminderRecurrence(
+ reminder: ReminderScheduleInput,
+ timeZone: string,
+): { schedule: CanonicalSchedule; ctx: RecurrenceContext } {
+ const hhmm = hourToHhmm(reminder.notifyHour);
+
+ const schedule: CanonicalSchedule = {
+ id: "measurement-reminder",
+ rrule: reminder.rrule,
+ rollingIntervalDays: reminder.intervalDays,
+ timesOfDay: [hhmm],
+ daysOfWeek: null,
+ windowStart: hhmm,
+ windowEnd: hhmm,
+ reminderGraceMinutes: null,
+ scheduleType: "SCHEDULED",
+ cyclicOnWeeks: null,
+ cyclicOffWeeks: null,
+ };
+
+ // First-due anchor when never satisfied: anchorDate ?? createdAt. After
+ // a satisfy the rolling path re-anchors on `lastIntakeAt + N`, so we
+ // feed `lastSatisfiedAt` through `lastIntakeAt`.
+ const startsOn = reminder.anchorDate ?? reminder.createdAt;
+
+ const ctx: RecurrenceContext = {
+ medication: {
+ id: "measurement-reminder",
+ startsOn,
+ endsOn: null,
+ oneShot: false,
+ createdAt: reminder.createdAt,
+ },
+ timeZone: timeZone || "Europe/Berlin",
+ lastIntakeAt: reminder.lastSatisfiedAt,
+ };
+
+ return { schedule, ctx };
+}
+
+/**
+ * Compute the canonical next-due instant for a reminder, strictly after
+ * `after`. Returns `null` when the cadence is uncomputable (no interval +
+ * no rrule) or the engine finds no future occurrence.
+ *
+ * `after` defaults to `now` but the caller can floor it (e.g. to the last
+ * satisfy instant) so a freshly-satisfied reminder advances past the
+ * current due cycle.
+ */
+export function computeReminderNextDueAt(
+ reminder: ReminderScheduleInput,
+ timeZone: string,
+ after: Date,
+): Date | null {
+ if (reminder.intervalDays === null && reminder.rrule === null) {
+ return null;
+ }
+ const { schedule, ctx } = buildReminderRecurrence(reminder, timeZone);
+ const occurrence = nextOccurrenceAfter(schedule, after, ctx);
+ return occurrence?.at ?? null;
+}
diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts
new file mode 100644
index 00000000..fe295c18
--- /dev/null
+++ b/src/lib/validations/measurement-reminders.ts
@@ -0,0 +1,162 @@
+/**
+ * v1.17.1 — Measurement / Vorsorge (preventive-care) reminders.
+ *
+ * A reminder class distinct from the medication loop: "measure BP every
+ * 7 days", "annual blood panel", "log weight weekly". Tied to a
+ * measurement TYPE (auto-resolving when a matching reading lands) or to a
+ * free-text checklist label (resolving only on a manual "Erledigt").
+ *
+ * The cadence vocabulary reuses the medication recurrence engine: a
+ * `intervalDays` rolling cadence OR an RFC-5545 `rrule`. The server
+ * computes the canonical `nextDueAt` so web ↔ iOS show identical numbers
+ * (server-authoritative; the client consumes the resolved DTO, never
+ * recomputes).
+ */
+import { z } from "zod/v4";
+
+/**
+ * The auto-resolve target types. Kept as an explicit allow-list rather
+ * than the full `MeasurementType` enum: a Vorsorge reminder only makes
+ * sense for the metrics a user actively measures on a cadence (BP,
+ * weight, glucose, pulse, SpO2, body composition, body temperature).
+ * Free-text reminders pass `null` and resolve only on a manual satisfy.
+ *
+ * BP is matched on `BLOOD_PRESSURE_SYS` as the canonical sentinel — a BP
+ * reading is two rows (SYS + DIA) and matching either would double-count;
+ * SYS is the agreed "a BP was measured" anchor.
+ */
+export const measurementReminderTypeEnum = z
+ .enum([
+ "WEIGHT",
+ "BLOOD_PRESSURE_SYS",
+ "PULSE",
+ "BLOOD_GLUCOSE",
+ "OXYGEN_SATURATION",
+ "BODY_FAT",
+ "BODY_TEMPERATURE",
+ ])
+ .meta({
+ id: "MeasurementReminderType",
+ description:
+ "Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.",
+ });
+
+export type MeasurementReminderType = z.infer<
+ typeof measurementReminderTypeEnum
+>;
+
+/**
+ * RFC-5545 RRULE guard. The recurrence engine parses it; here we only
+ * bound the length and require the `FREQ=` token so a typo doesn't reach
+ * the engine. Full validation is the engine's job (it tolerates a
+ * malformed rule by emitting no slots rather than throwing).
+ */
+const rruleField = z
+ .string()
+ .min(1)
+ .max(512)
+ .refine((v) => /FREQ=/i.test(v), {
+ message: "rrule must contain a FREQ= component",
+ });
+
+/**
+ * Shared cadence refinement: exactly one of `intervalDays` / `rrule` is
+ * required on create. Both NULL would leave `nextDueAt` uncomputable.
+ */
+const cadenceRefinement = (
+ data: { intervalDays?: number | null; rrule?: string | null },
+ ctx: z.RefinementCtx,
+): void => {
+ const hasInterval =
+ data.intervalDays !== undefined && data.intervalDays !== null;
+ const hasRrule = data.rrule !== undefined && data.rrule !== null;
+ if (!hasInterval && !hasRrule) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Provide either intervalDays or rrule",
+ path: ["intervalDays"],
+ });
+ }
+ if (hasInterval && hasRrule) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Provide only one of intervalDays or rrule",
+ path: ["rrule"],
+ });
+ }
+};
+
+export const createMeasurementReminderSchema = z
+ .object({
+ label: z.string().trim().min(1).max(120),
+ measurementType: measurementReminderTypeEnum.nullish(),
+ intervalDays: z.number().int().min(1).max(3650).nullish(),
+ rrule: rruleField.nullish(),
+ anchorDate: z.iso.datetime({ offset: true }).nullish(),
+ notifyHour: z.number().int().min(0).max(23).default(9),
+ location: z.string().trim().max(200).nullish(),
+ enabled: z.boolean().default(true),
+ })
+ .superRefine(cadenceRefinement)
+ .meta({
+ id: "MeasurementReminderCreate",
+ description:
+ "Create a Vorsorge reminder. Exactly one of intervalDays (rolling, every N days) or rrule (RFC-5545, e.g. FREQ=YEARLY) is required. measurementType enables auto-resolve from an incoming reading; omit it for a free-text checklist reminder.",
+ });
+
+export type CreateMeasurementReminderInput = z.infer<
+ typeof createMeasurementReminderSchema
+>;
+
+/**
+ * Update is fully partial. Cadence fields stay mutually-exclusive but
+ * are not required (an edit that only changes the label leaves the
+ * cadence alone). The route re-derives `nextDueAt` after applying the
+ * patch.
+ */
+export const updateMeasurementReminderSchema = z
+ .object({
+ label: z.string().trim().min(1).max(120).optional(),
+ measurementType: measurementReminderTypeEnum.nullish(),
+ intervalDays: z.number().int().min(1).max(3650).nullish(),
+ rrule: rruleField.nullish(),
+ anchorDate: z.iso.datetime({ offset: true }).nullish(),
+ notifyHour: z.number().int().min(0).max(23).optional(),
+ location: z.string().trim().max(200).nullish(),
+ enabled: z.boolean().optional(),
+ })
+ .meta({
+ id: "MeasurementReminderUpdate",
+ description:
+ "Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies.",
+ });
+
+export type UpdateMeasurementReminderInput = z.infer<
+ typeof updateMeasurementReminderSchema
+>;
+
+/**
+ * The canonical DTO every surface (web list, dashboard tile, iOS) mirrors.
+ * `nextDueAt` is the server-computed next-due instant.
+ */
+export const measurementReminderDto = z
+ .object({
+ id: z.string(),
+ label: z.string(),
+ measurementType: measurementReminderTypeEnum.nullable(),
+ intervalDays: z.number().int().nullable(),
+ rrule: z.string().nullable(),
+ anchorDate: z.iso.datetime({ offset: true }).nullable(),
+ notifyHour: z.number().int(),
+ location: z.string().nullable(),
+ nextDueAt: z.iso.datetime({ offset: true }).nullable(),
+ lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
+ enabled: z.boolean(),
+ createdAt: z.iso.datetime({ offset: true }),
+ updatedAt: z.iso.datetime({ offset: true }),
+ })
+ .meta({
+ id: "MeasurementReminderDTO",
+ description:
+ "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy.",
+ });
From 0a4f00f850bf16a8276a907ec2aa5d05319d9bd6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:33:13 +0200
Subject: [PATCH 18/79] feat(reminders): CRUD + manual satisfy routes for
preventive-care reminders
Add /api/measurement-reminders (list, create) and /{id} (get, patch,
delete) plus /{id}/satisfy. Every route wraps in apiHandler, parses with
Zod, narrows userId from the session, and builds the data object
field-by-field. Create and patch recompute the server-authoritative
nextDueAt; satisfy stamps lastSatisfiedAt and re-anchors the next-due.
Delete soft-deletes for tombstone parity.
---
.../api/measurement-reminders/[id]/route.ts | 209 ++++++++++++++++++
.../[id]/satisfy/route.ts | 73 ++++++
src/app/api/measurement-reminders/route.ts | 137 ++++++++++++
3 files changed, 419 insertions(+)
create mode 100644 src/app/api/measurement-reminders/[id]/route.ts
create mode 100644 src/app/api/measurement-reminders/[id]/satisfy/route.ts
create mode 100644 src/app/api/measurement-reminders/route.ts
diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts
new file mode 100644
index 00000000..5813cca2
--- /dev/null
+++ b/src/app/api/measurement-reminders/[id]/route.ts
@@ -0,0 +1,209 @@
+/**
+ * v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete.
+ *
+ * PATCH re-derives the server-authoritative `nextDueAt` after applying
+ * the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone)
+ * for parity with the rest of the tree.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { auditLog } from "@/lib/auth/audit";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiSuccess,
+ apiError,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
+import {
+ computeReminderNextDueAt,
+ type ReminderScheduleInput,
+} from "@/lib/measurement-reminders/scheduling";
+import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+const DEFAULT_TIMEZONE = "Europe/Berlin";
+
+async function resolveTimezone(userId: string): Promise {
+ const row = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { timezone: true },
+ });
+ return row?.timezone || DEFAULT_TIMEZONE;
+}
+
+export const GET = apiHandler(
+ async (_request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const reminder = await prisma.measurementReminder.findFirst({
+ where: { id, deletedAt: null },
+ });
+ if (!reminder || reminder.userId !== user.id) {
+ return apiError("Measurement reminder not found", 404);
+ }
+
+ annotate({
+ action: { name: "measurement-reminders.get" },
+ meta: { reminderId: id },
+ });
+
+ return apiSuccess(toMeasurementReminderDto(reminder));
+ },
+);
+
+export const PATCH = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const existing = await prisma.measurementReminder.findFirst({
+ where: { id, deletedAt: null },
+ });
+ if (!existing || existing.userId !== user.id) {
+ return apiError("Measurement reminder not found", 404);
+ }
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = updateMeasurementReminderSchema.safeParse(body);
+ if (!parsed.success) {
+ const issues = sanitiseZodIssues(parsed.error.issues);
+ annotate({
+ action: { name: "measurement-reminders.update.validation-failed" },
+ meta: { issue_count: issues.length, reminderId: id },
+ });
+ const auditIssues = sanitiseZodIssues(parsed.error.issues, {
+ stripValuesFromMessage: true,
+ });
+ prisma.auditLog
+ .create({
+ data: {
+ userId: user.id,
+ action: "measurement-reminders.update.validation-failed",
+ details: JSON.stringify({ issues: auditIssues, reminderId: id }),
+ },
+ })
+ .catch(() => {
+ /* swallow — 422 response is the contract */
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const data = parsed.data;
+
+ // Field-by-field — no mass assignment.
+ const updateData: Record = {};
+ if (data.label !== undefined) updateData.label = data.label;
+ if (data.measurementType !== undefined) {
+ updateData.measurementType = data.measurementType;
+ }
+ if (data.intervalDays !== undefined) {
+ updateData.intervalDays = data.intervalDays;
+ // Setting one cadence clears the other so the engine reads exactly
+ // one dispatch family.
+ if (data.intervalDays !== null && data.rrule === undefined) {
+ updateData.rrule = null;
+ }
+ }
+ if (data.rrule !== undefined) {
+ updateData.rrule = data.rrule;
+ if (data.rrule !== null && data.intervalDays === undefined) {
+ updateData.intervalDays = null;
+ }
+ }
+ if (data.anchorDate !== undefined) {
+ updateData.anchorDate =
+ data.anchorDate != null ? new Date(data.anchorDate) : null;
+ }
+ if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour;
+ if (data.location !== undefined) updateData.location = data.location;
+ if (data.enabled !== undefined) updateData.enabled = data.enabled;
+
+ // Recompute next-due against the merged cadence. Floor the search at
+ // the last-satisfied instant (or now) so a cadence edit re-anchors
+ // cleanly off the user's last fulfilment.
+ const timezone = await resolveTimezone(user.id);
+ const now = new Date();
+ const merged: ReminderScheduleInput = {
+ intervalDays:
+ (updateData.intervalDays as number | null | undefined) ??
+ existing.intervalDays,
+ rrule: (updateData.rrule as string | null | undefined) ?? existing.rrule,
+ anchorDate:
+ (updateData.anchorDate as Date | null | undefined) ??
+ existing.anchorDate,
+ notifyHour:
+ (updateData.notifyHour as number | undefined) ?? existing.notifyHour,
+ lastSatisfiedAt: existing.lastSatisfiedAt,
+ createdAt: existing.createdAt,
+ };
+ const after =
+ existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
+ ? existing.lastSatisfiedAt
+ : now;
+ updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
+
+ const updated = await prisma.measurementReminder.update({
+ where: { id },
+ data: updateData,
+ });
+
+ await auditLog("measurementReminder.update", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { reminderId: id },
+ });
+
+ annotate({
+ action: { name: "measurement-reminders.update" },
+ meta: { reminderId: id },
+ });
+
+ return apiSuccess(toMeasurementReminderDto(updated));
+ },
+);
+
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const existing = await prisma.measurementReminder.findUnique({
+ where: { id },
+ });
+ if (!existing || existing.userId !== user.id) {
+ return apiError("Measurement reminder not found", 404);
+ }
+
+ // Soft-delete (tombstone) parity with the rest of the tree. A
+ // re-delete of an already-tombstoned row is idempotent.
+ await prisma.measurementReminder.update({
+ where: { id },
+ data: { deletedAt: new Date() },
+ });
+
+ await auditLog("measurementReminder.delete", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { reminderId: id },
+ });
+
+ annotate({
+ action: { name: "measurement-reminders.delete" },
+ meta: { reminderId: id },
+ });
+
+ return apiSuccess({ deleted: true });
+ },
+);
diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts
new file mode 100644
index 00000000..574a88ed
--- /dev/null
+++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts
@@ -0,0 +1,73 @@
+/**
+ * v1.17.1 — manual "Erledigt" for a Vorsorge reminder.
+ *
+ * Stamps `lastSatisfiedAt = now` and recomputes the server-authoritative
+ * `nextDueAt` past now. Free-text (no measurementType) reminders resolve
+ * ONLY through this path; typed reminders auto-resolve in the cron when a
+ * matching reading lands, but a manual satisfy still works for them.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { auditLog } from "@/lib/auth/audit";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import {
+ computeReminderNextDueAt,
+ type ReminderScheduleInput,
+} from "@/lib/measurement-reminders/scheduling";
+import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+const DEFAULT_TIMEZONE = "Europe/Berlin";
+
+export const POST = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const existing = await prisma.measurementReminder.findFirst({
+ where: { id, deletedAt: null },
+ });
+ if (!existing || existing.userId !== user.id) {
+ return apiError("Measurement reminder not found", 404);
+ }
+
+ const userRow = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { timezone: true },
+ });
+ const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
+
+ const now = new Date();
+ const scheduleInput: ReminderScheduleInput = {
+ intervalDays: existing.intervalDays,
+ rrule: existing.rrule,
+ anchorDate: existing.anchorDate,
+ notifyHour: existing.notifyHour,
+ lastSatisfiedAt: now,
+ createdAt: existing.createdAt,
+ };
+ const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
+
+ const updated = await prisma.measurementReminder.update({
+ where: { id },
+ data: { lastSatisfiedAt: now, nextDueAt },
+ });
+
+ await auditLog("measurementReminder.satisfy", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { reminderId: id },
+ });
+
+ annotate({
+ action: { name: "measurement-reminders.satisfy" },
+ meta: { reminderId: id },
+ });
+
+ return apiSuccess(toMeasurementReminderDto(updated));
+ },
+);
diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts
new file mode 100644
index 00000000..0a27715b
--- /dev/null
+++ b/src/app/api/measurement-reminders/route.ts
@@ -0,0 +1,137 @@
+/**
+ * v1.17.1 — Vorsorge (measurement) reminders CRUD: list + create.
+ *
+ * Server-authoritative `nextDueAt` is computed via the canonical
+ * medication recurrence engine so web ↔ iOS read identical numbers.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { auditLog } from "@/lib/auth/audit";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { withIdempotency } from "@/lib/idempotency";
+import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
+import {
+ computeReminderNextDueAt,
+ type ReminderScheduleInput,
+} from "@/lib/measurement-reminders/scheduling";
+import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
+
+const DEFAULT_TIMEZONE = "Europe/Berlin";
+
+async function resolveTimezone(userId: string): Promise {
+ const row = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { timezone: true },
+ });
+ return row?.timezone || DEFAULT_TIMEZONE;
+}
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+
+ const reminders = await prisma.measurementReminder.findMany({
+ where: { userId: user.id, deletedAt: null },
+ // Most-urgent first; a null next-due (uncomputable / disabled) sinks
+ // to the end so the actionable items float to the top.
+ orderBy: [{ nextDueAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
+ });
+
+ annotate({
+ action: { name: "measurement-reminders.list" },
+ meta: { total: reminders.length },
+ });
+
+ return apiSuccess(reminders.map(toMeasurementReminderDto));
+});
+
+async function postReminder(request: NextRequest): Promise {
+ const { user } = await requireAuth();
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = createMeasurementReminderSchema.safeParse(body);
+ if (!parsed.success) {
+ const issues = sanitiseZodIssues(parsed.error.issues);
+ annotate({
+ action: { name: "measurement-reminders.create.validation-failed" },
+ meta: { issue_count: issues.length },
+ });
+ const auditIssues = sanitiseZodIssues(parsed.error.issues, {
+ stripValuesFromMessage: true,
+ });
+ prisma.auditLog
+ .create({
+ data: {
+ userId: user.id,
+ action: "measurement-reminders.create.validation-failed",
+ details: JSON.stringify({ issues: auditIssues }),
+ },
+ })
+ .catch(() => {
+ /* swallow — 422 response is the contract */
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const data = parsed.data;
+
+ const timezone = await resolveTimezone(user.id);
+ const now = new Date();
+ const anchorDate =
+ data.anchorDate != null ? new Date(data.anchorDate) : null;
+
+ // Server-authoritative next-due. No satisfy yet → anchors on
+ // anchorDate ?? createdAt (createdAt ≈ now for a fresh row).
+ const scheduleInput: ReminderScheduleInput = {
+ intervalDays: data.intervalDays ?? null,
+ rrule: data.rrule ?? null,
+ anchorDate,
+ notifyHour: data.notifyHour,
+ lastSatisfiedAt: null,
+ createdAt: now,
+ };
+ const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
+
+ // Field-by-field — no mass assignment.
+ const created = await prisma.measurementReminder.create({
+ data: {
+ userId: user.id,
+ label: data.label,
+ measurementType: data.measurementType ?? null,
+ intervalDays: data.intervalDays ?? null,
+ rrule: data.rrule ?? null,
+ anchorDate,
+ notifyHour: data.notifyHour,
+ location: data.location ?? null,
+ enabled: data.enabled,
+ nextDueAt,
+ },
+ });
+
+ await auditLog("measurementReminder.create", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { reminderId: created.id },
+ });
+
+ annotate({
+ action: { name: "measurement-reminders.create" },
+ meta: { reminderId: created.id, hasType: data.measurementType != null },
+ });
+
+ return apiSuccess(toMeasurementReminderDto(created), 201);
+}
+
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postReminder));
From 383963d3b4dd21e92e88b79e7cf5104fc9c4bd87 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:33:22 +0200
Subject: [PATCH 19/79] feat(reminders): every-15-min dispatcher for
preventive-care reminders
Add the measurement-reminder cron, mirroring the mood-reminder shape: a
pure due-predicate (past nextDueAt AND inside the reminder's local
notify-hour) and a tick runner that dispatches through the existing
notification cascade. Auto-resolve runs in the cron, not on the ingest
hot path: a matching reading of the reminder's type advances
lastSatisfiedAt and skips the nudge (BP on the SYS sentinel; free-text
reminders resolve only on a manual satisfy). Dedup is the nextDueAt
advance past now, so no ledger table is needed. The queue is registered in
the all-queues list, scheduled, and bound to its worker; a registration
guard test pins that wiring.
---
.../measurement-reminder-queue.test.ts | 60 ++++
.../__tests__/measurement-reminder.test.ts | 287 +++++++++++++++
src/lib/jobs/measurement-reminder.ts | 330 ++++++++++++++++++
src/lib/jobs/reminder-worker.ts | 32 ++
src/lib/jobs/reminder/mood-cycle-checks.ts | 40 +++
5 files changed, 749 insertions(+)
create mode 100644 src/lib/jobs/__tests__/measurement-reminder-queue.test.ts
create mode 100644 src/lib/jobs/__tests__/measurement-reminder.test.ts
create mode 100644 src/lib/jobs/measurement-reminder.ts
diff --git a/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts
new file mode 100644
index 00000000..e159e626
--- /dev/null
+++ b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts
@@ -0,0 +1,60 @@
+/**
+ * v1.17.1 — measurement-reminder queue registration guard.
+ *
+ * Same source-text-grep approach as the cycle / mood / drain registration
+ * guards: assert the Vorsorge-reminder queue is declared, listed in
+ * `allQueues` (the v1.4.37 dead-queue lesson — an unregistered queue is
+ * never provisioned and the schedule silently no-ops), scheduled on its
+ * cron, and wired to a `boss.work` handler that delegates to
+ * `runMeasurementReminderTick`. No pg-boss / Prisma boot.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const source =
+ readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") +
+ readFileSync(
+ join(__dirname, "..", "reminder", "mood-cycle-checks.ts"),
+ "utf8",
+ );
+
+describe("reminder-worker — measurement-reminder wiring", () => {
+ it("imports the measurement-reminder tick runner", () => {
+ expect(source).toMatch(
+ /from\s*["']@\/lib\/jobs\/measurement-reminder["']/,
+ );
+ expect(source).toMatch(/\brunMeasurementReminderTick\b/);
+ });
+
+ it("declares the queue + cron constants", () => {
+ expect(source).toMatch(
+ /MEASUREMENT_REMINDER_QUEUE\s*=\s*["']measurement-reminder-check["']/,
+ );
+ expect(source).toMatch(
+ /MEASUREMENT_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/,
+ );
+ });
+
+ it("registers the queue in the allQueues loop", () => {
+ const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
+ expect(allQueuesMatch).not.toBeNull();
+ expect(allQueuesMatch![1]).toMatch(/\bMEASUREMENT_REMINDER_QUEUE\b/);
+ });
+
+ it("schedules the measurement-reminder cron", () => {
+ expect(source).toMatch(
+ /\[MEASUREMENT_REMINDER_QUEUE,\s*MEASUREMENT_REMINDER_CRON\]/,
+ );
+ });
+
+ it("registers a boss.work handler that runs the tick", () => {
+ expect(source).toMatch(
+ /boss\.work[\s\S]{0,200}MEASUREMENT_REMINDER_QUEUE[\s\S]{0,200}handleMeasurementReminderCheck/,
+ );
+ expect(source).toMatch(
+ /handleMeasurementReminderCheck[\s\S]{0,400}runMeasurementReminderTick/,
+ );
+ });
+});
diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts
new file mode 100644
index 00000000..5c6ad36d
--- /dev/null
+++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts
@@ -0,0 +1,287 @@
+/**
+ * v1.17.1 — Vorsorge (measurement) reminder dispatcher unit tests.
+ *
+ * Pins the contract:
+ * - Due-predicate window: fires only when past-due AND inside the
+ * reminder's local notify-hour (08:59 → no, 09:00 → yes, 09:59 →
+ * yes, 10:00 → no). A disabled reminder / null nextDueAt never fires.
+ * - Auto-resolve: a matching reading of the reminder's measurementType
+ * logged since the last satisfy advances lastSatisfiedAt + recomputes
+ * nextDueAt and suppresses the nudge. Free-text reminders never
+ * auto-resolve.
+ * - Ledger-free dedup: a successful dispatch advances nextDueAt past
+ * now so the same due cycle never re-fires.
+ * - clientManaged suppression skips the APNs send but still advances.
+ *
+ * The Prisma surface is stubbed manually to avoid a testcontainer boot.
+ */
+import { describe, it, expect, vi } from "vitest";
+
+import {
+ evaluateMeasurementReminderDue,
+ runMeasurementReminderTick,
+} from "../measurement-reminder";
+import type { NotificationPayload } from "@/lib/notifications/types";
+import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
+
+type DispatchFn = (payload: NotificationPayload) => Promise;
+
+const OK: DispatchOutcome = {
+ dispatched: true,
+ channelsAttempted: 1,
+ channelsSucceeded: 1,
+};
+
+const TZ = "Europe/Berlin";
+
+// 09:00 Berlin in June = 07:00Z.
+const NINE_LOCAL = new Date("2026-06-15T07:00:00Z");
+
+describe("evaluateMeasurementReminderDue — window boundary", () => {
+ const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) };
+
+ it("08:59 local → not in hour window", () => {
+ const d = evaluateMeasurementReminderDue(
+ reminder,
+ TZ,
+ new Date("2026-06-15T06:59:00Z"),
+ );
+ expect(d.isDue).toBe(true);
+ expect(d.inHourWindow).toBe(false);
+ expect(d.fire).toBe(false);
+ });
+
+ it("09:00 local → fires", () => {
+ const d = evaluateMeasurementReminderDue(reminder, TZ, NINE_LOCAL);
+ expect(d.fire).toBe(true);
+ });
+
+ it("09:59 local → still in window", () => {
+ const d = evaluateMeasurementReminderDue(
+ reminder,
+ TZ,
+ new Date("2026-06-15T07:59:00Z"),
+ );
+ expect(d.fire).toBe(true);
+ });
+
+ it("10:00 local → outside window", () => {
+ const d = evaluateMeasurementReminderDue(
+ reminder,
+ TZ,
+ new Date("2026-06-15T08:00:00Z"),
+ );
+ expect(d.inHourWindow).toBe(false);
+ expect(d.fire).toBe(false);
+ });
+
+ it("not yet due → never fires", () => {
+ const d = evaluateMeasurementReminderDue(
+ { enabled: true, notifyHour: 9, nextDueAt: new Date("2099-01-01") },
+ TZ,
+ NINE_LOCAL,
+ );
+ expect(d.isDue).toBe(false);
+ expect(d.fire).toBe(false);
+ });
+
+ it("disabled / null nextDueAt → never fires", () => {
+ expect(
+ evaluateMeasurementReminderDue(
+ { enabled: false, notifyHour: 9, nextDueAt: new Date(0) },
+ TZ,
+ NINE_LOCAL,
+ ).fire,
+ ).toBe(false);
+ expect(
+ evaluateMeasurementReminderDue(
+ { enabled: true, notifyHour: 9, nextDueAt: null },
+ TZ,
+ NINE_LOCAL,
+ ).fire,
+ ).toBe(false);
+ });
+});
+
+interface FakeReminder {
+ id: string;
+ measurementType: string | null;
+ intervalDays: number | null;
+ rrule: string | null;
+ anchorDate: Date | null;
+ notifyHour: number;
+ location: string | null;
+ nextDueAt: Date | null;
+ lastSatisfiedAt: Date | null;
+ enabled: boolean;
+ createdAt: Date;
+ user: {
+ id: string;
+ timezone: string;
+ locale: string | null;
+ notificationPrefs: unknown;
+ };
+}
+
+function makePrisma(opts: {
+ reminders: FakeReminder[];
+ measurementMatch?: { measuredAt: Date } | null;
+}) {
+ const updates: Array<{ id: string; data: Record }> = [];
+ const prisma = {
+ measurementReminder: {
+ findMany: vi.fn(async () => opts.reminders),
+ update: vi.fn(
+ async ({
+ where,
+ data,
+ }: {
+ where: { id: string };
+ data: Record;
+ }) => {
+ updates.push({ id: where.id, data });
+ return { id: where.id, ...data };
+ },
+ ),
+ },
+ measurement: {
+ findFirst: vi.fn(async () => opts.measurementMatch ?? null),
+ },
+ };
+ return { prisma, updates };
+}
+
+function reminder(overrides: Partial): FakeReminder {
+ return {
+ id: "r1",
+ measurementType: "BLOOD_PRESSURE_SYS",
+ intervalDays: 7,
+ rrule: null,
+ anchorDate: null,
+ notifyHour: 9,
+ location: null,
+ nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL
+ lastSatisfiedAt: null,
+ enabled: true,
+ createdAt: new Date("2026-06-01T00:00:00Z"),
+ user: {
+ id: "u1",
+ timezone: TZ,
+ locale: "de",
+ notificationPrefs: null,
+ },
+ ...overrides,
+ };
+}
+
+describe("runMeasurementReminderTick", () => {
+ it("auto-resolves a typed reminder when a matching reading landed", async () => {
+ const matchAt = new Date("2026-06-14T18:00:00Z");
+ const { prisma, updates } = makePrisma({
+ reminders: [reminder({})],
+ measurementMatch: { measuredAt: matchAt },
+ });
+ const dispatch = vi.fn(async () => OK);
+
+ const summary = await runMeasurementReminderTick(
+ prisma as never,
+ NINE_LOCAL,
+ { dispatch },
+ );
+
+ expect(summary.autoResolved).toBe(1);
+ expect(summary.dispatched).toBe(0);
+ expect(dispatch).not.toHaveBeenCalled();
+ // Advanced lastSatisfiedAt to the reading instant + recomputed nextDueAt.
+ expect(updates).toHaveLength(1);
+ expect(updates[0].data.lastSatisfiedAt).toEqual(matchAt);
+ expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
+ });
+
+ it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => {
+ const { prisma, updates } = makePrisma({
+ reminders: [reminder({})],
+ measurementMatch: null,
+ });
+ const dispatch = vi.fn(async () => OK);
+
+ const summary = await runMeasurementReminderTick(
+ prisma as never,
+ NINE_LOCAL,
+ { dispatch },
+ );
+
+ expect(summary.dispatched).toBe(1);
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ expect(dispatch.mock.calls[0][0].eventType).toBe("MEASUREMENT_REMINDER");
+ // nextDueAt advanced strictly past now.
+ expect(updates).toHaveLength(1);
+ const advanced = updates[0].data.nextDueAt as Date;
+ expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
+ });
+
+ it("free-text reminder never auto-resolves (no measurement query match path)", async () => {
+ const { prisma } = makePrisma({
+ reminders: [reminder({ measurementType: null })],
+ // Even if a reading existed, a free-text reminder must not query it.
+ measurementMatch: { measuredAt: new Date("2026-06-14T18:00:00Z") },
+ });
+ const dispatch = vi.fn(async () => OK);
+
+ const summary = await runMeasurementReminderTick(
+ prisma as never,
+ NINE_LOCAL,
+ { dispatch },
+ );
+
+ expect(summary.autoResolved).toBe(0);
+ expect(summary.dispatched).toBe(1);
+ expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("suppresses the push under clientManaged but still advances nextDueAt", async () => {
+ const { prisma, updates } = makePrisma({
+ reminders: [
+ reminder({
+ measurementType: null,
+ user: {
+ id: "u1",
+ timezone: TZ,
+ locale: "de",
+ notificationPrefs: { measurementReminder: { clientManaged: true } },
+ },
+ }),
+ ],
+ measurementMatch: null,
+ });
+ const dispatch = vi.fn(async () => OK);
+
+ const summary = await runMeasurementReminderTick(
+ prisma as never,
+ NINE_LOCAL,
+ { dispatch },
+ );
+
+ expect(summary.skippedClientManaged).toBe(1);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(updates).toHaveLength(1);
+ expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
+ });
+
+ it("skips a reminder outside its notify-hour window", async () => {
+ const { prisma } = makePrisma({
+ reminders: [reminder({ measurementType: null })],
+ measurementMatch: null,
+ });
+ const dispatch = vi.fn(async () => OK);
+
+ const summary = await runMeasurementReminderTick(
+ prisma as never,
+ new Date("2026-06-15T08:00:00Z"), // 10:00 Berlin — outside the 09 window
+ { dispatch },
+ );
+
+ expect(summary.skippedOutsideWindow).toBe(1);
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts
new file mode 100644
index 00000000..7223d76e
--- /dev/null
+++ b/src/lib/jobs/measurement-reminder.ts
@@ -0,0 +1,330 @@
+/**
+ * v1.17.1 — Vorsorge (measurement) reminder dispatcher.
+ *
+ * Mirrors the mood-reminder cron (`mood-reminder.ts`): a separate module,
+ * a pure due-predicate, a `runMeasurementReminderTick(prisma, now)`
+ * runner. The cron fires every 15 minutes so it picks up every IANA
+ * timezone crossing the reminder's `notifyHour` without one cron entry
+ * per zone.
+ *
+ * Differences from the mood reminder:
+ *
+ * 1. No dedicated dispatch-ledger table. The reminder's own
+ * `nextDueAt` IS the dedup guard: after a successful dispatch the
+ * runner advances `nextDueAt` to the next occurrence strictly past
+ * now, so the same due cycle never re-fires. This collapses one
+ * table + one cleanup queue versus the mood pattern.
+ * 2. Auto-resolve from an incoming measurement. Before deciding to
+ * fire, the runner checks whether a matching reading of the
+ * reminder's `measurementType` has landed since the last satisfy
+ * (BP matched on `BLOOD_PRESSURE_SYS`). If so it advances
+ * `lastSatisfiedAt` + recomputes `nextDueAt` and skips the nudge —
+ * the user who already measured today never gets nagged. This is a
+ * query in the cron, NOT a hook on the hot iOS batch-ingest path.
+ * Free-text reminders (no `measurementType`) never auto-resolve;
+ * they advance only on a manual satisfy.
+ * 3. Per-reminder `clientManaged` suppression of the server-side APNs
+ * (the medication `clientManaged` precedent) is applied for the
+ * whole user via `notificationPrefs.measurementReminder.clientManaged`.
+ */
+import type { PrismaClient } from "@/generated/prisma/client";
+import type { MeasurementType } from "@/generated/prisma/client";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import type { Locale } from "@/lib/i18n/config";
+import { defaultLocale, locales } from "@/lib/i18n/config";
+import { wallClockInTz } from "@/lib/tz/wall-clock";
+import { dispatchNotification } from "@/lib/notifications/dispatcher";
+import { getEvent } from "@/lib/logging/context";
+import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs";
+import {
+ computeReminderNextDueAt,
+ type ReminderScheduleInput,
+} from "@/lib/measurement-reminders/scheduling";
+
+export interface MeasurementReminderSummary {
+ candidatesScanned: number;
+ inWindow: number;
+ dispatched: number;
+ autoResolved: number;
+ skippedNotDue: number;
+ skippedOutsideWindow: number;
+ skippedClientManaged: number;
+ skippedNoChannel: number;
+ failed: number;
+}
+
+function resolveLocale(locale: string | null | undefined): Locale {
+ return locales.includes(locale as Locale)
+ ? (locale as Locale)
+ : defaultLocale;
+}
+
+/**
+ * Pure due-predicate: at this instant, is the reminder due AND inside its
+ * local notify-hour window?
+ *
+ * "Due" = `nextDueAt != null` and `now >= nextDueAt`. The hour gate keeps
+ * a reminder that became overdue overnight from firing at 03:00 — it
+ * waits for the user's chosen `notifyHour` to come round in their local
+ * timezone. Pulled out so the unit tests can pin the window boundary
+ * (08:59 → no, 09:00 → yes, 09:59 → yes, 10:00 → no) without the DB.
+ */
+export function evaluateMeasurementReminderDue(
+ reminder: {
+ enabled: boolean;
+ nextDueAt: Date | null;
+ notifyHour: number;
+ },
+ timezone: string,
+ now: Date,
+): { fire: boolean; inHourWindow: boolean; isDue: boolean } {
+ if (!reminder.enabled || reminder.nextDueAt === null) {
+ return { fire: false, inHourWindow: false, isDue: false };
+ }
+ const isDue = now.getTime() >= reminder.nextDueAt.getTime();
+ const parts = wallClockInTz(now, timezone || "Europe/Berlin");
+ const inHourWindow = parts.hour === reminder.notifyHour;
+ return { fire: isDue && inHourWindow, inHourWindow, isDue };
+}
+
+/**
+ * Build the localised title + body for the Vorsorge push.
+ */
+export function buildMeasurementReminderPayload(
+ locale: string | null | undefined,
+ label: string,
+ location: string | null,
+): { title: string; body: string } {
+ const t = getServerTranslator(resolveLocale(locale)).t;
+ const base = t("measurementReminders.pushBody", { label });
+ const body = location
+ ? `${base} ${t("measurementReminders.pushLocation", { location })}`
+ : base;
+ return { title: t("measurementReminders.pushTitle"), body };
+}
+
+/**
+ * The canonical sentinel measurement type the cron queries for
+ * auto-resolve. BP is two rows (SYS + DIA); SYS is the "a BP was measured"
+ * anchor (matching both would double-count).
+ */
+function autoResolveQueryType(
+ reminderType: MeasurementType | null,
+): MeasurementType | null {
+ return reminderType;
+}
+
+/**
+ * Run one Vorsorge-reminder cron tick. Iterates every live, enabled
+ * reminder, auto-resolves the typed ones against incoming readings,
+ * and dispatches a `MEASUREMENT_REMINDER` push for any that are due
+ * inside the user's local notify-hour window.
+ */
+export async function runMeasurementReminderTick(
+ prisma: PrismaClient,
+ now: Date,
+ options: {
+ dispatch?: typeof dispatchNotification;
+ } = {},
+): Promise {
+ const dispatchImpl = options.dispatch ?? dispatchNotification;
+
+ const summary: MeasurementReminderSummary = {
+ candidatesScanned: 0,
+ inWindow: 0,
+ dispatched: 0,
+ autoResolved: 0,
+ skippedNotDue: 0,
+ skippedOutsideWindow: 0,
+ skippedClientManaged: 0,
+ skippedNoChannel: 0,
+ failed: 0,
+ };
+
+ const reminders = await prisma.measurementReminder.findMany({
+ where: { deletedAt: null, enabled: true },
+ include: {
+ user: {
+ select: {
+ id: true,
+ timezone: true,
+ locale: true,
+ notificationPrefs: true,
+ },
+ },
+ },
+ });
+
+ for (const reminder of reminders) {
+ summary.candidatesScanned += 1;
+ try {
+ const timezone = reminder.user.timezone || "Europe/Berlin";
+
+ // ── Auto-resolve from an incoming measurement ──────────────────
+ // Cheap guard, in the cron (not on the ingest path). Only typed
+ // reminders auto-resolve; free-text ones wait for a manual satisfy.
+ const queryType = autoResolveQueryType(reminder.measurementType);
+ if (queryType !== null) {
+ // Match readings logged AFTER the last satisfy (or, never
+ // satisfied, after the anchor). A reading inside the current due
+ // cycle means the user already measured — re-anchor + skip the
+ // nudge. `deletedAt: null` so a tombstoned reading never counts.
+ const sinceFloor =
+ reminder.lastSatisfiedAt ??
+ reminder.anchorDate ??
+ reminder.createdAt;
+ const match = await prisma.measurement.findFirst({
+ where: {
+ userId: reminder.user.id,
+ type: queryType,
+ deletedAt: null,
+ measuredAt: { gt: sinceFloor },
+ },
+ orderBy: { measuredAt: "desc" },
+ select: { measuredAt: true },
+ });
+ if (match) {
+ const scheduleInput: ReminderScheduleInput = {
+ intervalDays: reminder.intervalDays,
+ rrule: reminder.rrule,
+ anchorDate: reminder.anchorDate,
+ notifyHour: reminder.notifyHour,
+ lastSatisfiedAt: match.measuredAt,
+ createdAt: reminder.createdAt,
+ };
+ const nextDueAt = computeReminderNextDueAt(
+ scheduleInput,
+ timezone,
+ match.measuredAt,
+ );
+ await prisma.measurementReminder.update({
+ where: { id: reminder.id },
+ data: { lastSatisfiedAt: match.measuredAt, nextDueAt },
+ });
+ summary.autoResolved += 1;
+ continue;
+ }
+ }
+
+ const decision = evaluateMeasurementReminderDue(
+ {
+ enabled: reminder.enabled,
+ nextDueAt: reminder.nextDueAt,
+ notifyHour: reminder.notifyHour,
+ },
+ timezone,
+ now,
+ );
+
+ if (!decision.isDue) {
+ summary.skippedNotDue += 1;
+ continue;
+ }
+ if (!decision.inHourWindow) {
+ summary.skippedOutsideWindow += 1;
+ continue;
+ }
+
+ summary.inWindow += 1;
+
+ // Per-user client-managed suppression (the medication precedent).
+ if (
+ isMeasurementReminderClientManaged(reminder.user.notificationPrefs)
+ ) {
+ getEvent()?.addMeta(
+ "measurement_reminder.suppressed_client_managed",
+ reminder.id,
+ );
+ summary.skippedClientManaged += 1;
+ // Advance past this cycle anyway so a client-managed reminder does
+ // not pin the server tick re-evaluating the same overdue slot
+ // every 15 minutes for the rest of the day.
+ await advanceNextDue(prisma, reminder, timezone, now);
+ continue;
+ }
+
+ const { title, body } = buildMeasurementReminderPayload(
+ reminder.user.locale,
+ reminder.label,
+ reminder.location,
+ );
+
+ const outcome = await dispatchImpl({
+ eventType: "MEASUREMENT_REMINDER",
+ userId: reminder.user.id,
+ title,
+ message: body,
+ metadata: {
+ scheduledAt: now.toISOString(),
+ reminderId: reminder.id,
+ },
+ });
+
+ // No channel succeeded — leave `nextDueAt` where it is so the next
+ // tick (or the user's next channel) retries. The reminder simply
+ // stays overdue, which the surface already shows as "überfällig".
+ if (!outcome.dispatched) {
+ summary.skippedNoChannel += 1;
+ continue;
+ }
+
+ // Dispatch succeeded — advance `nextDueAt` past now so this due
+ // cycle never re-fires (the ledger-free dedup guard).
+ await advanceNextDue(prisma, reminder, timezone, now);
+ summary.dispatched += 1;
+ } catch (err: unknown) {
+ summary.failed += 1;
+ const message = err instanceof Error ? err.message : String(err);
+ getEvent()?.addWarning(
+ `measurement-reminder per-reminder dispatch failed for ${reminder.id}: ${message}`,
+ );
+ }
+ }
+
+ return summary;
+}
+
+/**
+ * Advance `nextDueAt` to the next occurrence strictly after `now`. Used
+ * after a successful dispatch (and after a client-managed skip) as the
+ * ledger-free dedup guard. Does NOT touch `lastSatisfiedAt` — a fired
+ * reminder is not "satisfied", it just rolls to its next slot.
+ */
+async function advanceNextDue(
+ prisma: PrismaClient,
+ reminder: {
+ id: string;
+ intervalDays: number | null;
+ rrule: string | null;
+ anchorDate: Date | null;
+ notifyHour: number;
+ lastSatisfiedAt: Date | null;
+ createdAt: Date;
+ },
+ timezone: string,
+ now: Date,
+): Promise {
+ // The dedup guard must move the slot strictly forward. For an `rrule`
+ // the engine already walks to the next strictly-after-now occurrence,
+ // so passing the row as-is is correct. For a ROLLING reminder the
+ // engine anchors the first-due slot AT `anchorDate ?? createdAt` when
+ // never satisfied, which stays ≤ now and would re-fire every tick — so
+ // re-anchor the rolling cadence on `now` (a fire is the rhythm
+ // restarting from this dispatch) to roll it forward by exactly one
+ // interval. This does NOT advance `lastSatisfiedAt`: a fired reminder
+ // is not satisfied, just rescheduled past the slot it nagged on.
+ const rolling = reminder.intervalDays !== null;
+ const scheduleInput: ReminderScheduleInput = {
+ intervalDays: reminder.intervalDays,
+ rrule: reminder.rrule,
+ anchorDate: reminder.anchorDate,
+ notifyHour: reminder.notifyHour,
+ lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt,
+ createdAt: reminder.createdAt,
+ };
+ const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
+ await prisma.measurementReminder.update({
+ where: { id: reminder.id },
+ data: { nextDueAt },
+ });
+}
diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts
index 57aa681f..f2187e30 100644
--- a/src/lib/jobs/reminder-worker.ts
+++ b/src/lib/jobs/reminder-worker.ts
@@ -239,8 +239,10 @@ import {
import {
MoodReminderPayload,
CycleReminderPayload,
+ MeasurementReminderPayload,
handleMoodReminderCheck,
handleCycleReminderCheck,
+ handleMeasurementReminderCheck,
} from "./reminder/mood-cycle-checks";
import {
HostMetricSamplePayload,
@@ -500,6 +502,17 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
const CYCLE_REMINDER_QUEUE = "cycle-reminder-check";
const CYCLE_REMINDER_CRON = "*/15 * * * *";
+
+// v1.17.1 — every-15-min tick for Vorsorge (measurement) reminders.
+// Same cadence + short-circuit shape as the mood / cycle reminders: the
+// handler only fires a reminder whose `nextDueAt` is past AND whose local
+// time is the reminder's `notifyHour`, so the 15-min cadence picks up
+// every IANA timezone crossing that hour without one cron entry per zone.
+// Dedup is the reminder's own `nextDueAt` advance — no ledger table.
+
+const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check";
+
+const MEASUREMENT_REMINDER_CRON = "*/15 * * * *";
// v1.4.38 — the per-sample cutoff hours constant now lives on the
// helper module so the worker, the admin route, and the CLI all read
// the same source of truth. Re-export pulled in alongside
@@ -716,6 +729,11 @@ export async function startReminderWorker() {
// without this entry the every-15-min schedule silently no-ops and the
// cycle dispatcher never fires (the v1.4.37 dead-queue class).
CYCLE_REMINDER_QUEUE,
+ // v1.17.1 — Vorsorge (measurement) reminder cron tick. Same pg-boss
+ // v12 createQueue contract; without this entry pg-boss never
+ // provisions the queue and the every-15-min schedule silently no-ops
+ // (the v1.4.37 dead-queue class).
+ MEASUREMENT_REMINDER_QUEUE,
// v1.4.49 — push-attempt ledger cleanup. Same createQueue contract
// as the other cleanup jobs; the daily schedule below would
// silently no-op without this entry.
@@ -908,6 +926,11 @@ export async function startReminderWorker() {
// hour, so the cron costs ~one prediction-row scan per tick for the
// opted-in cohort.
[CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON],
+ // v1.17.1 — every-15-min tick for the Vorsorge (measurement) reminder.
+ // The handler short-circuits unless a reminder is past-due AND the
+ // user's local time matches the reminder's notifyHour, so the cron
+ // costs ~one reminder-row scan per tick for the active cohort.
+ [MEASUREMENT_REMINDER_QUEUE, MEASUREMENT_REMINDER_CRON],
// v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts.
[PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON],
// v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight
@@ -1193,6 +1216,15 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleCycleReminderCheck,
);
+ // v1.17.1 — single-flight Vorsorge (measurement) reminder worker.
+ // localConcurrency=1 keeps two ticks from racing the `nextDueAt`
+ // advance that anchors the per-cycle idempotency, exactly like the
+ // mood / cycle reminder workers.
+ await boss.work(
+ MEASUREMENT_REMINDER_QUEUE,
+ { localConcurrency: 1 },
+ handleMeasurementReminderCheck,
+ );
// v1.4.49 — daily prune of the push-attempt ledger. Single-flight
// matches every other cleanup queue; two ticks racing on the same
// DELETE statement is wasted work and the second tick's payload
diff --git a/src/lib/jobs/reminder/mood-cycle-checks.ts b/src/lib/jobs/reminder/mood-cycle-checks.ts
index cc94c338..655a5b2b 100644
--- a/src/lib/jobs/reminder/mood-cycle-checks.ts
+++ b/src/lib/jobs/reminder/mood-cycle-checks.ts
@@ -9,6 +9,7 @@ import { recordError } from "@/lib/jobs/worker-status";
import { withBackgroundEvent } from "@/lib/logging/background";
import { runMoodReminderTick } from "@/lib/jobs/mood-reminder";
import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder";
+import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder";
import { getWorkerPrisma } from "./shared";
export interface MoodReminderPayload {
@@ -19,6 +20,10 @@ export interface CycleReminderPayload {
triggeredAt: string;
}
+export interface MeasurementReminderPayload {
+ triggeredAt: string;
+}
+
export async function handleMoodReminderCheck(
jobs: Job[],
) {
@@ -80,3 +85,38 @@ export async function handleCycleReminderCheck(
}
});
}
+
+/**
+ * v1.17.1 — every-15-min Vorsorge (measurement) reminder tick. Thin shim
+ * around `runMeasurementReminderTick` so the unit tests exercise the
+ * due-predicate + auto-resolve + advance logic without pg-boss.
+ */
+export async function handleMeasurementReminderCheck(
+ jobs: Job[],
+) {
+ void jobs;
+ await withBackgroundEvent("job.measurement_reminder", async (evt) => {
+ const prisma = getWorkerPrisma();
+ try {
+ const summary = await runMeasurementReminderTick(prisma, new Date());
+ evt.setBackground({
+ task_name: "job.measurement_reminder",
+ result: {
+ candidates_scanned: summary.candidatesScanned,
+ in_window: summary.inWindow,
+ dispatched: summary.dispatched,
+ auto_resolved: summary.autoResolved,
+ skipped_not_due: summary.skippedNotDue,
+ skipped_outside_window: summary.skippedOutsideWindow,
+ skipped_client_managed: summary.skippedClientManaged,
+ skipped_no_channel: summary.skippedNoChannel,
+ failed: summary.failed,
+ },
+ });
+ } catch (err) {
+ evt.setError(err);
+ recordError();
+ throw err;
+ }
+ });
+}
From 8058d81590babf24c9986560a57766f4b79f2f08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:33:30 +0200
Subject: [PATCH 20/79] feat(notifications): MEASUREMENT_REMINDER event type
and client-managed gate
Register the MEASUREMENT_REMINDER event type (default on at the channel
layer; the real gate is the per-reminder enabled flag) and add the
notificationPrefs.measurementReminder.clientManaged sub-object plus its
cron-side resolver, mirroring the medication and cycle client-managed
precedent. The event rides the generic APNs category path and stays
non-time-sensitive, so it delivers as a plain banner with no client
change.
---
.../__tests__/route.test.ts | 33 +++++++++++
src/lib/notifications/types.ts | 15 +++++
.../__tests__/notification-prefs.test.ts | 57 +++++++++++++++++++
src/lib/validations/notification-prefs.ts | 47 +++++++++++++++
4 files changed, 152 insertions(+)
diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts
index ae16929e..46dbd045 100644
--- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts
+++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts
@@ -103,6 +103,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -135,6 +138,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -169,6 +175,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
});
@@ -214,6 +223,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -235,6 +247,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
},
},
});
@@ -260,6 +275,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
},
next: {
medication: {
@@ -277,6 +295,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
},
changed: ["medication"],
}),
@@ -347,6 +368,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
},
},
});
@@ -382,6 +406,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -403,6 +430,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
},
},
});
@@ -443,6 +473,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
},
},
});
diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts
index 129bfe8a..ca4eb675 100644
--- a/src/lib/notifications/types.ts
+++ b/src/lib/notifications/types.ts
@@ -56,6 +56,16 @@ export const EVENT_TYPES = [
// refill or threshold change. ON at the channel layer — the real
// gate is the per-user threshold in `notificationPrefs.medication`.
"MEDICATION_LOW_STOCK",
+ // v1.17.1 — Vorsorge (measurement / preventive-care) reminder. Fired by
+ // the every-15-min measurement-reminder cron when a user-created
+ // reminder ("measure BP every 7 days", "annual blood panel") is due
+ // inside the user's local notify-hour window. ON by default at the
+ // channel layer (see EVENT_DEFAULT_ENABLED): these are reminders the
+ // user explicitly created, so silence-by-default would be surprising.
+ // The real gate is the per-reminder `enabled` flag plus the per-user
+ // `notificationPrefs.measurementReminder.clientManaged` suppression.
+ // NOT time-sensitive — a Vorsorge nudge is not a Focus-bypass case.
+ "MEASUREMENT_REMINDER",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -102,6 +112,11 @@ export const EVENT_DEFAULT_ENABLED: Record = {
// push per medication per crossing. An explicit per-channel
// `NotificationPreference` row still wins.
MEDICATION_LOW_STOCK: true,
+ // v1.17.1 — ON at the channel layer; the real gates are the per-reminder
+ // `enabled` flag and the per-user `measurementReminder.clientManaged`
+ // suppression the cron reads. An explicit per-channel
+ // `NotificationPreference` row still wins.
+ MEASUREMENT_REMINDER: true,
};
export const CHANNEL_TYPE_LABELS: Record = {
diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts
index eef97b83..813f2c8a 100644
--- a/src/lib/validations/__tests__/notification-prefs.test.ts
+++ b/src/lib/validations/__tests__/notification-prefs.test.ts
@@ -6,6 +6,7 @@ import {
DEFAULT_NOTIFICATION_PREFS,
DEFAULT_REORDER_LEAD_DAYS,
isCycleReminderClientManaged,
+ isMeasurementReminderClientManaged,
isMedicationReminderClientManaged,
notificationPrefsSchema,
parseNotificationPrefs,
@@ -109,6 +110,9 @@ describe("parseNotificationPrefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -129,6 +133,9 @@ describe("parseNotificationPrefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -151,6 +158,9 @@ describe("parseNotificationPrefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -189,6 +199,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -215,6 +228,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -238,6 +254,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -261,6 +280,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -285,6 +307,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
});
});
@@ -356,6 +381,38 @@ describe("isCycleReminderClientManaged — cron-skip gate", () => {
});
});
+describe("isMeasurementReminderClientManaged — cron-skip gate", () => {
+ it("returns false for a null / undefined row (server-managed default)", () => {
+ expect(isMeasurementReminderClientManaged(null)).toBe(false);
+ expect(isMeasurementReminderClientManaged(undefined)).toBe(false);
+ });
+
+ it("returns false when the user has not opted in", () => {
+ expect(
+ isMeasurementReminderClientManaged({
+ measurementReminder: { clientManaged: false },
+ }),
+ ).toBe(false);
+ });
+
+ it("returns true when the iOS app owns local Vorsorge reminders", () => {
+ expect(
+ isMeasurementReminderClientManaged({
+ measurementReminder: { clientManaged: true },
+ }),
+ ).toBe(true);
+ });
+
+ it("is independent of the cycle / medication client-managed flags", () => {
+ expect(
+ isMeasurementReminderClientManaged({
+ cycle: { clientManaged: true },
+ medication: { deliveryDefault: "client" },
+ }),
+ ).toBe(false);
+ });
+});
+
describe("resolveMoodReminderHour — cron hour gate", () => {
it("returns the default hour for a null row", () => {
expect(resolveMoodReminderHour(null)).toBe(DEFAULT_MOOD_REMINDER_HOUR);
diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts
index 92e00d7f..0f405c70 100644
--- a/src/lib/validations/notification-prefs.ts
+++ b/src/lib/validations/notification-prefs.ts
@@ -89,6 +89,19 @@ const cyclePrefsSchema = z
})
.partial();
+/**
+ * v1.17.1 — measurement / Vorsorge reminder category schema.
+ * `clientManaged` mirrors the medication + cycle gate: when the iOS app
+ * owns local Vorsorge reminders it flips this to `true` and the
+ * server-side measurement-reminder cron suppresses its APNs send for that
+ * user. Sub-object form keeps the layout open for future Vorsorge knobs.
+ */
+const measurementReminderPrefsSchema = z
+ .object({
+ clientManaged: z.boolean(),
+ })
+ .partial();
+
/**
* v1.15.20 — coach category schema. `nudgesEnabled` gates the proactive
* Coach nudge cron (05:15 Europe/Berlin): default ON (the nudge is an
@@ -134,6 +147,7 @@ export const notificationPrefsSchema = z
mood: moodPrefsSchema,
cycle: cyclePrefsSchema,
coach: coachPrefsSchema,
+ measurementReminder: measurementReminderPrefsSchema,
})
.partial();
@@ -187,6 +201,14 @@ export interface NotificationPrefs {
/** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */
nudgeFrequency: "weekly" | "biweekly";
};
+ measurementReminder: {
+ /**
+ * v1.17.1 — when true the iOS app owns local Vorsorge reminders and
+ * the server-side measurement-reminder cron suppresses its APNs send
+ * (the medication / cycle `clientManaged` precedent).
+ */
+ clientManaged: boolean;
+ };
}
/**
@@ -235,6 +257,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
+ measurementReminder: {
+ clientManaged: false,
+ },
};
/**
@@ -279,6 +304,10 @@ export function resolveNotificationPrefs(
...base.coach,
...(incoming.coach ?? {}),
},
+ measurementReminder: {
+ ...base.measurementReminder,
+ ...(incoming.measurementReminder ?? {}),
+ },
});
}
@@ -307,6 +336,7 @@ function applyDeliveryDefaultMapping(
mood: { ...prefs.mood },
cycle: { ...prefs.cycle },
coach: { ...prefs.coach },
+ measurementReminder: { ...prefs.measurementReminder },
};
}
@@ -335,6 +365,16 @@ export function isCycleReminderClientManaged(raw: unknown): boolean {
return parseNotificationPrefs(raw).cycle.clientManaged === true;
}
+/**
+ * v1.17.1 — cron-side helper. Returns `true` when the user has opted in
+ * to client-managed Vorsorge (measurement) reminders so the server-side
+ * push should be suppressed. Tolerates a null / missing prefs row.
+ * Mirrors `isMedicationReminderClientManaged` / `isCycleReminderClientManaged`.
+ */
+export function isMeasurementReminderClientManaged(raw: unknown): boolean {
+ return parseNotificationPrefs(raw).measurementReminder.clientManaged === true;
+}
+
/**
* v1.7.0 — resolve the effective delivery channel for a device. The
* per-device override wins; otherwise the user-level roaming default;
@@ -426,6 +466,9 @@ function cloneDefaults(): NotificationPrefs {
mood: { ...DEFAULT_NOTIFICATION_PREFS.mood },
cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle },
coach: { ...DEFAULT_NOTIFICATION_PREFS.coach },
+ measurementReminder: {
+ ...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
+ },
};
}
@@ -447,5 +490,9 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs {
...DEFAULT_NOTIFICATION_PREFS.coach,
...(input.coach ?? {}),
},
+ measurementReminder: {
+ ...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
+ ...(input.measurementReminder ?? {}),
+ },
});
}
From c6045482c3e2dac2211d42f9cd8b67aa27e27da0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:33:37 +0200
Subject: [PATCH 21/79] =?UTF-8?q?feat(reminders):=20preventive-care=20surf?=
=?UTF-8?q?ace=20=E2=80=94=20wann,=20was,=20wo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add the Vorsorge page and section: an upcoming-reminders list sorted by
server-computed next-due, with a create form (measurement-linked or
free-text, cadence, notify hour, location) and a per-card Erledigt and
delete action. Cards stay neutral-coloured regardless of due state — the
status reads through a discreet badge only, never an alarming tint. Copy
ships in all six locales.
---
messages/de.json | 51 +++
messages/en.json | 51 +++
messages/es.json | 51 +++
messages/fr.json | 51 +++
messages/it.json | 51 +++
messages/pl.json | 51 +++
src/app/vorsorge/page.tsx | 31 ++
.../vorsorge-section.tsx | 369 ++++++++++++++++++
src/hooks/use-measurement-reminders.ts | 97 +++++
src/lib/query-keys/index.ts | 2 +
src/lib/query-keys/measurement-reminders.ts | 21 +
11 files changed, 826 insertions(+)
create mode 100644 src/app/vorsorge/page.tsx
create mode 100644 src/components/measurement-reminders/vorsorge-section.tsx
create mode 100644 src/hooks/use-measurement-reminders.ts
create mode 100644 src/lib/query-keys/measurement-reminders.ts
diff --git a/messages/de.json b/messages/de.json
index 01784ec8..28f7422d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6208,5 +6208,56 @@
"deleteN": "Löschen ({count})",
"clearSelection": "Auswahl aufheben",
"selectionBarLabel": "Auswahlaktionen"
+ },
+ "measurementReminders": {
+ "sectionTitle": "Vorsorge",
+ "sectionDescription": "Erinnerungen, was wann und wo zu messen oder zu prüfen ist.",
+ "addButton": "Erinnerung hinzufügen",
+ "markDone": "Erledigt",
+ "delete": "Löschen",
+ "disabledBadge": "Aus",
+ "nextDue": {
+ "none": "Nicht geplant",
+ "today": "Heute fällig",
+ "tomorrow": "Morgen fällig",
+ "inDays": "In {days} Tagen"
+ },
+ "overdueByDays": "Überfällig seit {days} Tagen",
+ "cadence": {
+ "everyNDays": "Alle {days} Tage",
+ "custom": "Eigener Zeitplan"
+ },
+ "location": {
+ "prefix": "Bei: {location}"
+ },
+ "types": {
+ "WEIGHT": "Gewicht",
+ "BLOOD_PRESSURE_SYS": "Blutdruck",
+ "PULSE": "Puls",
+ "BLOOD_GLUCOSE": "Blutzucker",
+ "OXYGEN_SATURATION": "Sauerstoffsättigung",
+ "BODY_FAT": "Körperfett",
+ "BODY_TEMPERATURE": "Körpertemperatur"
+ },
+ "empty": {
+ "title": "Noch keine Erinnerungen",
+ "description": "Füge eine Erinnerung hinzu, um z. B. regelmäßig den Blutdruck zu messen oder ein jährliches Blutbild zu planen."
+ },
+ "form": {
+ "label": "Bezeichnung",
+ "labelPlaceholder": "z. B. Blutdruck messen",
+ "kind": "Art",
+ "kindType": "Mit einer Messung verknüpft",
+ "kindFreeText": "Freitext-Prüfung",
+ "measurementType": "Messwert",
+ "cadence": "Wiederholung",
+ "notifyHour": "Erinnern um",
+ "location": "Wo (optional)",
+ "locationPlaceholder": "z. B. Hausarzt, Labor",
+ "save": "Speichern"
+ },
+ "pushTitle": "Vorsorge-Erinnerung",
+ "pushBody": "Fällig: {label}",
+ "pushLocation": "(bei {location})"
}
}
diff --git a/messages/en.json b/messages/en.json
index a78cc1a2..88748e36 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6208,5 +6208,56 @@
"deleteN": "Delete ({count})",
"clearSelection": "Clear selection",
"selectionBarLabel": "Selection actions"
+ },
+ "measurementReminders": {
+ "sectionTitle": "Preventive care",
+ "sectionDescription": "Reminders for what to measure or check, when, and where.",
+ "addButton": "Add reminder",
+ "markDone": "Done",
+ "delete": "Delete",
+ "disabledBadge": "Off",
+ "nextDue": {
+ "none": "Not scheduled",
+ "today": "Due today",
+ "tomorrow": "Due tomorrow",
+ "inDays": "In {days} days"
+ },
+ "overdueByDays": "Overdue by {days} days",
+ "cadence": {
+ "everyNDays": "Every {days} days",
+ "custom": "Custom schedule"
+ },
+ "location": {
+ "prefix": "At: {location}"
+ },
+ "types": {
+ "WEIGHT": "Weight",
+ "BLOOD_PRESSURE_SYS": "Blood pressure",
+ "PULSE": "Pulse",
+ "BLOOD_GLUCOSE": "Blood glucose",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_FAT": "Body fat",
+ "BODY_TEMPERATURE": "Body temperature"
+ },
+ "empty": {
+ "title": "No reminders yet",
+ "description": "Add a reminder to measure your blood pressure on a cadence, or to schedule an annual blood panel."
+ },
+ "form": {
+ "label": "Label",
+ "labelPlaceholder": "e.g. Measure blood pressure",
+ "kind": "Type",
+ "kindType": "Linked to a measurement",
+ "kindFreeText": "Free-text check",
+ "measurementType": "Measurement",
+ "cadence": "Repeat",
+ "notifyHour": "Remind at",
+ "location": "Where (optional)",
+ "locationPlaceholder": "e.g. GP, Lab",
+ "save": "Save"
+ },
+ "pushTitle": "Preventive-care reminder",
+ "pushBody": "Time for: {label}",
+ "pushLocation": "(at {location})"
}
}
diff --git a/messages/es.json b/messages/es.json
index 07ef722c..d8c7e315 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6208,5 +6208,56 @@
"deleteN": "Eliminar ({count})",
"clearSelection": "Borrar selección",
"selectionBarLabel": "Acciones de selección"
+ },
+ "measurementReminders": {
+ "sectionTitle": "Prevención",
+ "sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.",
+ "addButton": "Añadir recordatorio",
+ "markDone": "Hecho",
+ "delete": "Eliminar",
+ "disabledBadge": "Apagado",
+ "nextDue": {
+ "none": "Sin programar",
+ "today": "Vence hoy",
+ "tomorrow": "Vence mañana",
+ "inDays": "En {days} días"
+ },
+ "overdueByDays": "Vencido hace {days} días",
+ "cadence": {
+ "everyNDays": "Cada {days} días",
+ "custom": "Programación personalizada"
+ },
+ "location": {
+ "prefix": "En: {location}"
+ },
+ "types": {
+ "WEIGHT": "Peso",
+ "BLOOD_PRESSURE_SYS": "Presión arterial",
+ "PULSE": "Pulso",
+ "BLOOD_GLUCOSE": "Glucosa en sangre",
+ "OXYGEN_SATURATION": "Saturación de oxígeno",
+ "BODY_FAT": "Grasa corporal",
+ "BODY_TEMPERATURE": "Temperatura corporal"
+ },
+ "empty": {
+ "title": "Aún no hay recordatorios",
+ "description": "Añade un recordatorio para medir la presión arterial con regularidad o programar un análisis de sangre anual."
+ },
+ "form": {
+ "label": "Etiqueta",
+ "labelPlaceholder": "p. ej. Medir la presión arterial",
+ "kind": "Tipo",
+ "kindType": "Vinculado a una medición",
+ "kindFreeText": "Comprobación de texto libre",
+ "measurementType": "Medición",
+ "cadence": "Repetir",
+ "notifyHour": "Recordar a las",
+ "location": "Dónde (opcional)",
+ "locationPlaceholder": "p. ej. Médico, Laboratorio",
+ "save": "Guardar"
+ },
+ "pushTitle": "Recordatorio de prevención",
+ "pushBody": "Toca: {label}",
+ "pushLocation": "(en {location})"
}
}
diff --git a/messages/fr.json b/messages/fr.json
index acec48c7..3831846a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6208,5 +6208,56 @@
"deleteN": "Supprimer ({count})",
"clearSelection": "Effacer la sélection",
"selectionBarLabel": "Actions de sélection"
+ },
+ "measurementReminders": {
+ "sectionTitle": "Prévention",
+ "sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.",
+ "addButton": "Ajouter un rappel",
+ "markDone": "Terminé",
+ "delete": "Supprimer",
+ "disabledBadge": "Désactivé",
+ "nextDue": {
+ "none": "Non planifié",
+ "today": "À faire aujourd'hui",
+ "tomorrow": "À faire demain",
+ "inDays": "Dans {days} jours"
+ },
+ "overdueByDays": "En retard de {days} jours",
+ "cadence": {
+ "everyNDays": "Tous les {days} jours",
+ "custom": "Planning personnalisé"
+ },
+ "location": {
+ "prefix": "Chez : {location}"
+ },
+ "types": {
+ "WEIGHT": "Poids",
+ "BLOOD_PRESSURE_SYS": "Tension artérielle",
+ "PULSE": "Pouls",
+ "BLOOD_GLUCOSE": "Glycémie",
+ "OXYGEN_SATURATION": "Saturation en oxygène",
+ "BODY_FAT": "Masse grasse",
+ "BODY_TEMPERATURE": "Température corporelle"
+ },
+ "empty": {
+ "title": "Aucun rappel pour l'instant",
+ "description": "Ajoutez un rappel pour mesurer régulièrement votre tension ou planifier un bilan sanguin annuel."
+ },
+ "form": {
+ "label": "Libellé",
+ "labelPlaceholder": "ex. Mesurer la tension",
+ "kind": "Type",
+ "kindType": "Lié à une mesure",
+ "kindFreeText": "Vérification libre",
+ "measurementType": "Mesure",
+ "cadence": "Répétition",
+ "notifyHour": "Rappeler à",
+ "location": "Où (facultatif)",
+ "locationPlaceholder": "ex. Médecin, Laboratoire",
+ "save": "Enregistrer"
+ },
+ "pushTitle": "Rappel de prévention",
+ "pushBody": "C'est l'heure : {label}",
+ "pushLocation": "(chez {location})"
}
}
diff --git a/messages/it.json b/messages/it.json
index e825a177..6c959630 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6208,5 +6208,56 @@
"deleteN": "Elimina ({count})",
"clearSelection": "Cancella selezione",
"selectionBarLabel": "Azioni di selezione"
+ },
+ "measurementReminders": {
+ "sectionTitle": "Prevenzione",
+ "sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.",
+ "addButton": "Aggiungi promemoria",
+ "markDone": "Fatto",
+ "delete": "Elimina",
+ "disabledBadge": "Disattivato",
+ "nextDue": {
+ "none": "Non pianificato",
+ "today": "In scadenza oggi",
+ "tomorrow": "In scadenza domani",
+ "inDays": "Tra {days} giorni"
+ },
+ "overdueByDays": "In ritardo di {days} giorni",
+ "cadence": {
+ "everyNDays": "Ogni {days} giorni",
+ "custom": "Pianificazione personalizzata"
+ },
+ "location": {
+ "prefix": "Presso: {location}"
+ },
+ "types": {
+ "WEIGHT": "Peso",
+ "BLOOD_PRESSURE_SYS": "Pressione sanguigna",
+ "PULSE": "Battito",
+ "BLOOD_GLUCOSE": "Glicemia",
+ "OXYGEN_SATURATION": "Saturazione di ossigeno",
+ "BODY_FAT": "Grasso corporeo",
+ "BODY_TEMPERATURE": "Temperatura corporea"
+ },
+ "empty": {
+ "title": "Ancora nessun promemoria",
+ "description": "Aggiungi un promemoria per misurare regolarmente la pressione o pianificare un esame del sangue annuale."
+ },
+ "form": {
+ "label": "Etichetta",
+ "labelPlaceholder": "es. Misurare la pressione",
+ "kind": "Tipo",
+ "kindType": "Collegato a una misurazione",
+ "kindFreeText": "Controllo a testo libero",
+ "measurementType": "Misurazione",
+ "cadence": "Ripeti",
+ "notifyHour": "Ricorda alle",
+ "location": "Dove (facoltativo)",
+ "locationPlaceholder": "es. Medico, Laboratorio",
+ "save": "Salva"
+ },
+ "pushTitle": "Promemoria di prevenzione",
+ "pushBody": "È ora di: {label}",
+ "pushLocation": "(presso {location})"
}
}
diff --git a/messages/pl.json b/messages/pl.json
index b551d7b0..cb4719c4 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6208,5 +6208,56 @@
"deleteN": "Usuń ({count})",
"clearSelection": "Wyczyść zaznaczenie",
"selectionBarLabel": "Akcje zaznaczenia"
+ },
+ "measurementReminders": {
+ "sectionTitle": "Profilaktyka",
+ "sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.",
+ "addButton": "Dodaj przypomnienie",
+ "markDone": "Gotowe",
+ "delete": "Usuń",
+ "disabledBadge": "Wył.",
+ "nextDue": {
+ "none": "Niezaplanowane",
+ "today": "Termin dzisiaj",
+ "tomorrow": "Termin jutro",
+ "inDays": "Za {days} dni"
+ },
+ "overdueByDays": "Zaległe od {days} dni",
+ "cadence": {
+ "everyNDays": "Co {days} dni",
+ "custom": "Własny harmonogram"
+ },
+ "location": {
+ "prefix": "U: {location}"
+ },
+ "types": {
+ "WEIGHT": "Waga",
+ "BLOOD_PRESSURE_SYS": "Ciśnienie krwi",
+ "PULSE": "Tętno",
+ "BLOOD_GLUCOSE": "Glukoza we krwi",
+ "OXYGEN_SATURATION": "Saturacja tlenem",
+ "BODY_FAT": "Tkanka tłuszczowa",
+ "BODY_TEMPERATURE": "Temperatura ciała"
+ },
+ "empty": {
+ "title": "Brak przypomnień",
+ "description": "Dodaj przypomnienie, aby regularnie mierzyć ciśnienie krwi lub zaplanować coroczne badanie krwi."
+ },
+ "form": {
+ "label": "Etykieta",
+ "labelPlaceholder": "np. Zmierz ciśnienie krwi",
+ "kind": "Typ",
+ "kindType": "Powiązane z pomiarem",
+ "kindFreeText": "Sprawdzenie tekstowe",
+ "measurementType": "Pomiar",
+ "cadence": "Powtarzaj",
+ "notifyHour": "Przypomnij o",
+ "location": "Gdzie (opcjonalnie)",
+ "locationPlaceholder": "np. Lekarz, Laboratorium",
+ "save": "Zapisz"
+ },
+ "pushTitle": "Przypomnienie profilaktyczne",
+ "pushBody": "Czas na: {label}",
+ "pushLocation": "(u {location})"
}
}
diff --git a/src/app/vorsorge/page.tsx b/src/app/vorsorge/page.tsx
new file mode 100644
index 00000000..6873b785
--- /dev/null
+++ b/src/app/vorsorge/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+
+import { useAuth } from "@/hooks/use-auth";
+import { useMounted } from "@/hooks/use-mounted";
+import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
+
+/**
+ * v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated
+ * feature surface for "wann muss ich was wo machen". Auth-gated; the
+ * section component owns the list + create flow.
+ */
+export default function VorsorgePage() {
+ const { isAuthenticated, isLoading } = useAuth();
+ const mounted = useMounted();
+
+ if (!mounted || isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
new file mode 100644
index 00000000..b8690097
--- /dev/null
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -0,0 +1,369 @@
+"use client";
+
+/**
+ * v1.17.1 — Vorsorge (preventive-care / measurement) reminder surface.
+ *
+ * Answers "wann muss ich was wo machen": a list of upcoming reminders
+ * sorted by server-computed next-due, each card showing the label, the
+ * cadence, the location, and a relative next-due badge. Per the project
+ * rule the card stays NEUTRAL regardless of due state — no alarming
+ * red/green tint; status reads through a discreet badge only.
+ *
+ * The server is authoritative for `nextDueAt`; this component renders it
+ * relative to "now" but never recomputes the cadence.
+ */
+import { useState } from "react";
+import { CheckCircle2, Plus, Trash2 } from "lucide-react";
+
+import { useTranslations } from "@/lib/i18n/context";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { EmptyState } from "@/components/ui/empty-state";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import {
+ useMeasurementReminders,
+ useMeasurementReminderMutations,
+ type MeasurementReminder,
+} from "@/hooks/use-measurement-reminders";
+
+const TYPE_OPTIONS = [
+ "WEIGHT",
+ "BLOOD_PRESSURE_SYS",
+ "PULSE",
+ "BLOOD_GLUCOSE",
+ "OXYGEN_SATURATION",
+ "BODY_FAT",
+ "BODY_TEMPERATURE",
+] as const;
+
+const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function relativeDueKey(
+ nextDueAt: string | null,
+ now: number,
+): { key: string; days: number } {
+ if (!nextDueAt) return { key: "nextDue.none", days: 0 };
+ const due = new Date(nextDueAt).getTime();
+ // Compare calendar-day deltas so "today" / "in 1 day" read cleanly.
+ const deltaDays = Math.round((due - now) / DAY_MS);
+ if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) };
+ if (deltaDays === 0) return { key: "nextDue.today", days: 0 };
+ if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };
+ return { key: "nextDue.inDays", days: deltaDays };
+}
+
+export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
+ const { t } = useTranslations();
+ const { data: reminders, isLoading } = useMeasurementReminders(enabled);
+ const { create, remove, satisfy } = useMeasurementReminderMutations();
+ const [showForm, setShowForm] = useState(false);
+
+ // Form state.
+ const [label, setLabel] = useState("");
+ const [kind, setKind] = useState<"type" | "freeText">("type");
+ const [measurementType, setMeasurementType] =
+ useState("BLOOD_PRESSURE_SYS");
+ const [intervalDays, setIntervalDays] = useState(7);
+ const [notifyHour, setNotifyHour] = useState(9);
+ const [location, setLocation] = useState("");
+
+ // Wall-clock anchor for the relative-due labels, captured once at mount
+ // via a lazy state initializer so the impure Date.now() stays out of
+ // render (the repo's purity + set-state-in-effect rules both reject the
+ // alternatives). The relative labels are coarse (day granularity), so a
+ // mount-time snapshot is plenty fresh.
+ const [now] = useState(() => Date.now());
+
+ function resetForm() {
+ setLabel("");
+ setKind("type");
+ setMeasurementType("BLOOD_PRESSURE_SYS");
+ setIntervalDays(7);
+ setNotifyHour(9);
+ setLocation("");
+ }
+
+ function submit() {
+ const trimmed = label.trim();
+ if (!trimmed) return;
+ create.mutate(
+ {
+ label: trimmed,
+ measurementType: kind === "type" ? measurementType : null,
+ intervalDays,
+ notifyHour,
+ location: location.trim() || null,
+ },
+ {
+ onSuccess: () => {
+ resetForm();
+ setShowForm(false);
+ },
+ },
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/labs/lab-trend-sparkline.tsx b/src/components/labs/lab-trend-sparkline.tsx
new file mode 100644
index 00000000..28844fe9
--- /dev/null
+++ b/src/components/labs/lab-trend-sparkline.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+/**
+ * v1.17.1 — minimal inline trend sparkline for an analyte's readings.
+ *
+ * A dependency-free SVG polyline (the project defers Recharts for tiny
+ * inline trends; this matches the doctor-report PDF's vector-polyline
+ * approach). Rendered only when an analyte has ≥ 2 readings. Stroke uses
+ * the neutral `currentColor` so it inherits the calm muted-foreground tone
+ * of its row — no status colour, in keeping with the no-alarming-colour
+ * ethos.
+ *
+ * `values` are passed oldest → newest.
+ */
+export function LabTrendSparkline({
+ values,
+ width = 72,
+ height = 20,
+}: {
+ values: number[];
+ width?: number;
+ height?: number;
+}) {
+ if (values.length < 2) return null;
+
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = max - min || 1;
+ const stepX = width / (values.length - 1);
+ const pad = 2;
+ const usableH = height - pad * 2;
+
+ const points = values
+ .map((v, i) => {
+ const x = i * stepX;
+ // Invert Y so a higher value sits higher on screen.
+ const y = pad + usableH - ((v - min) / span) * usableH;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(" ");
+
+ return (
+
+ );
+}
diff --git a/src/components/labs/reference-range-badge.tsx b/src/components/labs/reference-range-badge.tsx
new file mode 100644
index 00000000..2b4aa5a1
--- /dev/null
+++ b/src/components/labs/reference-range-badge.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { ArrowDown, ArrowUp, Minus } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { useTranslations } from "@/lib/i18n/context";
+import type { ReferenceRangeStatus } from "@/lib/validations/labs";
+
+/**
+ * v1.17.1 — reference-range badge.
+ *
+ * Renders the server-computed `rangeStatus` as a calm, informative marker.
+ * The project's no-alarming-colour ethos is a hard rule here: an
+ * out-of-range value is NOT painted red. It reads as a neutral `secondary`
+ * badge with a quiet direction arrow (↓ below / ↑ above), exactly the same
+ * weight as the in-range and unknown states. The number leaving the
+ * reference window is information a clinician scans — not an alarm to the
+ * user, who may be perfectly aware of (and managing) the value.
+ *
+ * `unknown` (the lab reported no usable bounds) renders nothing — there is
+ * no verdict to show, and an empty badge would be noise.
+ */
+export function ReferenceRangeBadge({
+ status,
+}: {
+ status: ReferenceRangeStatus;
+}) {
+ const { t } = useTranslations();
+
+ if (status === "unknown") return null;
+
+ if (status === "in-range") {
+ return (
+
+
+ {t("labs.range.inRange")}
+
+ );
+ }
+
+ // Below / above — both neutral `secondary`, distinguished only by the
+ // arrow direction and label. No red, no amber.
+ const Icon = status === "below" ? ArrowDown : ArrowUp;
+ const label =
+ status === "below" ? t("labs.range.below") : t("labs.range.above");
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts
new file mode 100644
index 00000000..0acce295
--- /dev/null
+++ b/src/components/labs/types.ts
@@ -0,0 +1,23 @@
+import type { ReferenceRangeStatus } from "@/lib/validations/labs";
+
+/** A lab-result row as the list / create endpoints serialise it. */
+export interface LabResultDto {
+ id: string;
+ panel: string | null;
+ analyte: string;
+ value: number;
+ unit: string;
+ referenceLow: number | null;
+ referenceHigh: number | null;
+ takenAt: string;
+ source: string;
+ hasNote: boolean;
+ rangeStatus: ReferenceRangeStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface LabResultListResponse {
+ results: LabResultDto[];
+ meta: { total: number; limit: number; offset: number };
+}
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx
index 1cc4b47d..3dd18a6e 100644
--- a/src/components/layout/bottom-nav.tsx
+++ b/src/components/layout/bottom-nav.tsx
@@ -6,6 +6,7 @@ import {
Bug,
Droplets,
Dumbbell,
+ FlaskConical,
Home,
Lightbulb,
MoreHorizontal,
@@ -70,6 +71,7 @@ const PRIMARY_RIGHT: ReadonlyArray = [
const MORE_HUB: ReadonlyArray = [
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
+ { href: "/labs", tKey: "nav.labs", icon: FlaskConical },
{ href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx
index 4d53ce6d..18ba6e77 100644
--- a/src/components/layout/sidebar-nav.tsx
+++ b/src/components/layout/sidebar-nav.tsx
@@ -8,6 +8,7 @@ import {
ChevronsLeft,
ChevronsRight,
Droplets,
+ FlaskConical,
Home,
Lightbulb,
LogOut,
@@ -66,6 +67,9 @@ const navItems = [
icon: Pill,
tourId: "nav-medications",
},
+ // v1.17.1 — structured lab-result store. Pairs with the Vorsorge
+ // annual-blood-panel reminder (which records its result here).
+ { href: "/labs", tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs" },
// v1.4.15 Phase B5: `tourId` values match `data-tour-id` lookups
// performed by the onboarding tour. Keep these stable — renaming
// them silently breaks the spotlight cutout for that step.
diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx
index dd1a5a25..61e96d18 100644
--- a/src/components/settings/health-record-export-panel.tsx
+++ b/src/components/settings/health-record-export-panel.tsx
@@ -56,6 +56,7 @@ interface SectionState {
compliance: boolean;
mood: boolean;
bmi: boolean;
+ labs: boolean;
}
const DEFAULT_SECTIONS: SectionState = {
@@ -76,6 +77,7 @@ const DEFAULT_SECTIONS: SectionState = {
compliance: true,
mood: false, // privacy default
bmi: true,
+ labs: true,
};
function buildSelectionSections(s: SectionState) {
@@ -102,6 +104,7 @@ function buildSelectionSections(s: SectionState) {
medications: { list: s.medList, compliance: s.compliance },
mood: s.mood,
bmi: s.bmi,
+ labs: s.labs,
};
}
@@ -411,6 +414,11 @@ export function HealthRecordExportPanel() {
checked={sections.mood}
onToggle={() => toggle("mood")}
/>
+ toggle("labs")}
+ />
{isPdfLike && (
diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts
index 0b6a54a3..25fbcc29 100644
--- a/src/lib/openapi/registry.ts
+++ b/src/lib/openapi/registry.ts
@@ -72,6 +72,7 @@ const openApiBase: Pick<
{ name: "Export" },
{ name: "Sync" },
{ name: "Cycle" },
+ { name: "Labs" },
{ name: "Admin" },
{ name: "Meta" },
],
diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts
index 24bf8281..847832eb 100644
--- a/src/lib/openapi/routes/index.ts
+++ b/src/lib/openapi/routes/index.ts
@@ -29,6 +29,7 @@ import { cyclePaths } from "./cycle";
import { devicePaths } from "./devices";
import { healthRecordPaths } from "./health-record";
import { insightsPaths } from "./insights";
+import { labsPaths } from "./labs";
import { measurementPaths } from "./measurements";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
@@ -57,6 +58,7 @@ export const openApiPaths: NonNullable = {
...moodPaths,
...settingsPaths,
...consentPaths,
+ ...labsPaths,
};
export const openApiComponents: NonNullable = {
diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts
new file mode 100644
index 00000000..f4cfeb9c
--- /dev/null
+++ b/src/lib/openapi/routes/labs.ts
@@ -0,0 +1,204 @@
+/**
+ * OpenAPI route table for the structured lab-result store (`/api/labs`).
+ *
+ * Part of the OpenAPI route table; aggregated in `./index.ts`. The request
+ * bodies + query reuse the runtime Zod schemas from `@/lib/validations/labs`
+ * so the wire contract stays single-source. Response shapes are declared
+ * here (the route serialises a derived `rangeStatus` + `hasNote` the input
+ * schema doesn't carry).
+ */
+import type { ZodOpenApiObject } from "zod-openapi";
+import { z } from "zod/v4";
+
+import {
+ createLabResultSchema,
+ listLabResultsSchema,
+ updateLabResultSchema,
+} from "@/lib/validations/labs";
+
+import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
+
+createLabResultSchema.meta({
+ id: "CreateLabResultRequest",
+ description:
+ "Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). `analyte` + `unit` are free-form (a lab prints its own naming). Reference bounds are independently optional; when both are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest.",
+});
+
+updateLabResultSchema.meta({
+ id: "UpdateLabResultRequest",
+ description:
+ "Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it.",
+});
+
+listLabResultsSchema.meta({
+ id: "ListLabResultsQuery",
+ description:
+ "Query params for the lab-result list: optional `analyte` (exact) + `panel` filters, an inclusive `from`/`to` date range, and `limit` (≤ 500) / `offset` pagination. Defaults to `takenAt` DESC.",
+});
+
+const rangeStatusEnum = z
+ .enum(["in-range", "below", "above", "unknown"])
+ .meta({
+ id: "LabReferenceRangeStatus",
+ description:
+ "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
+ });
+
+const labResultRow = z
+ .object({
+ id: z.string(),
+ panel: z.string().nullable(),
+ analyte: z.string(),
+ value: z.number(),
+ unit: z.string(),
+ referenceLow: z.number().nullable(),
+ referenceHigh: z.number().nullable(),
+ takenAt: z.string(),
+ source: z.string(),
+ hasNote: z.boolean(),
+ rangeStatus: rangeStatusEnum,
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ })
+ .meta({
+ id: "LabResult",
+ description:
+ "A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
+ });
+
+const labResultDetail = labResultRow
+ .omit({ hasNote: true })
+ .extend({ note: z.string().nullable() })
+ .meta({
+ id: "LabResultDetail",
+ description:
+ "Single lab result including its decrypted free-text `note` (or null).",
+ });
+
+const listResponse = z
+ .object({
+ results: z.array(labResultRow),
+ meta: z.object({
+ total: z.number(),
+ limit: z.number(),
+ offset: z.number(),
+ }),
+ })
+ .meta({ id: "ListLabResultsResponse" });
+
+const notFound = {
+ "404": {
+ description: "Lab result not found (or owned by another user).",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+};
+
+export const labsPaths: NonNullable = {
+ "/api/labs": {
+ get: {
+ tags: ["Labs"],
+ summary: "List the caller's lab results",
+ description:
+ "Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted note itself is not echoed).",
+ requestParams: { query: listLabResultsSchema },
+ responses: {
+ "200": {
+ description: "Lab-result list.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(listResponse, "ListLabResultsEnvelope"),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ post: {
+ tags: ["Labs"],
+ summary: "Record a lab result",
+ description:
+ "Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.",
+ requestBody: {
+ required: true,
+ content: { "application/json": { schema: createLabResultSchema } },
+ },
+ responses: {
+ "201": {
+ description: "Created lab result.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(labResultRow, "CreateLabResultResponse"),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/labs/{id}": {
+ get: {
+ tags: ["Labs"],
+ summary: "Fetch a single lab result",
+ description:
+ "Returns the lab result including its decrypted `note`. Cross-user rows surface as 404.",
+ requestParams: { path: z.object({ id: z.string() }) },
+ responses: {
+ "200": {
+ description: "Lab-result detail.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(labResultDetail, "GetLabResultResponse"),
+ },
+ },
+ },
+ ...notFound,
+ ...stdResponses,
+ },
+ },
+ put: {
+ tags: ["Labs"],
+ summary: "Edit a lab result",
+ description:
+ "Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound. Audits as `labResult.update`.",
+ requestParams: { path: z.object({ id: z.string() }) },
+ requestBody: {
+ required: true,
+ content: { "application/json": { schema: updateLabResultSchema } },
+ },
+ responses: {
+ "200": {
+ description: "Updated lab result.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(labResultRow, "UpdateLabResultResponse"),
+ },
+ },
+ },
+ ...notFound,
+ ...stdResponses,
+ },
+ },
+ delete: {
+ tags: ["Labs"],
+ summary: "Delete a lab result",
+ description:
+ "Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`.",
+ requestParams: { path: z.object({ id: z.string() }) },
+ responses: {
+ "200": {
+ description: "Deletion succeeded.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ z.object({ deleted: z.boolean() }),
+ "DeleteLabResultResponse",
+ ),
+ },
+ },
+ },
+ ...notFound,
+ ...stdResponses,
+ },
+ },
+ },
+};
From 44eed87e892dd4631ca24228ca4191315dafa6d8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:44:44 +0200
Subject: [PATCH 26/79] fix(vorsorge): pair the loading spinner with
motion-reduce:animate-none
---
src/app/vorsorge/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/vorsorge/page.tsx b/src/app/vorsorge/page.tsx
index 6873b785..f13df7dd 100644
--- a/src/app/vorsorge/page.tsx
+++ b/src/app/vorsorge/page.tsx
@@ -18,7 +18,7 @@ export default function VorsorgePage() {
if (!mounted || isLoading) {
return (
-
+
);
}
From fd5cc783774d6d2b2ca4784f5985883c92360cf8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:53:22 +0200
Subject: [PATCH 27/79] refactor(mood): read the dashboard and analytics mood
series from one engine
The dashboard snapshot and the /api/mood/analytics route each carried a
hand-copied rollup read, coverage fallback, and daily summarize of the
same numbers. Two reads of one metric is the seam where the dashboard
tile and the insights mood sparkline can drift apart.
Extract buildMoodDailySeries as the single mood engine and route both
callers through it, so the same mood number reads identically on the
dashboard, the insights surface, and the iOS client.
---
src/app/api/mood/analytics/route.ts | 168 ++----------------
.../analytics/__tests__/mood-series.test.ts | 94 ++++++++++
src/lib/analytics/mood-series.ts | 149 ++++++++++++++++
src/lib/dashboard/snapshot.ts | 75 +-------
4 files changed, 264 insertions(+), 222 deletions(-)
create mode 100644 src/lib/analytics/__tests__/mood-series.test.ts
create mode 100644 src/lib/analytics/mood-series.ts
diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts
index 2d8f69a0..5774a109 100644
--- a/src/app/api/mood/analytics/route.ts
+++ b/src/app/api/mood/analytics/route.ts
@@ -1,177 +1,33 @@
-import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
-import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
- ensureUserMoodRollupsFresh,
- readMoodDayRollups,
-} from "@/lib/rollups/mood-rollups";
+ buildMoodDailySeries,
+ type MoodDailySeries,
+} from "@/lib/analytics/mood-series";
export const dynamic = "force-dynamic";
-/**
- * v1.4.39 W-MOOD — five-year window for the rollup-tier read.
- *
- * Mirrors the legacy "unbounded findMany" semantics in practice: the
- * legacy code passed every mood the user had ever logged into Node.
- * The rollup tier stores one row per day, so a five-year cap is
- * cheap (at most ~1 800 rows) and still covers every user the
- * product has historic data for.
- */
-const ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000;
-
-/** Aggregate multiple mood entries per day into daily averages. */
-function aggregateDailyAverages(
- records: Array<{ date: string; score: number }>,
-) {
- const byDay = new Map();
- for (const record of records) {
- const current = byDay.get(record.date) ?? { sum: 0, count: 0 };
- current.sum += record.score;
- current.count += 1;
- byDay.set(record.date, current);
- }
- return Array.from(byDay.entries())
- .sort(([a], [b]) => a.localeCompare(b))
- .map(([day, stats]) => ({
- date: day,
- score: Math.round((stats.sum / stats.count) * 100) / 100,
- samples: stats.count,
- }));
-}
-
-interface MoodAnalyticsResult {
- entries: Array<{ date: string; score: number; samples: number }>;
- summary: ReturnType;
- entryCount: number;
-}
-
-/**
- * Format the rollup bucket's UTC `bucket_start` as a YYYY-MM-DD
- * label. The legacy live path emitted the row's TZ-anchored `date`
- * column; the rollup tier anchors on UTC midnight (same convention
- * as the measurement rollup tier). For tenants within ±3 h of UTC
- * (Berlin year-round) the two labels agree on every entry whose
- * timestamp doesn't straddle the UTC boundary — i.e. every realistic
- * mood log, which a human submits during waking hours.
- *
- * Trade-off (QA Specialist-H1, v1.4.39): on DST fall-back nights the
- * UTC anchor and the user's local wall-clock day-key can diverge by
- * one calendar day. Example: `2025-10-25T23:30:00Z` is 00:30 local
- * in Europe/Berlin on `2025-10-26` (one hour after the fall-back
- * transition); the rollup row is keyed on `2025-10-25` (UTC) while
- * the legacy live-fallback path would emit `2025-10-26`. This is
- * pinned by the route-parity DST test. v1.5 per-user-tz bucketing
- * (audit P7) anchors the rollup on the same day-key the legacy path
- * uses, closing the gap.
- */
-function utcDayLabel(d: Date): string {
- const yyyy = d.getUTCFullYear();
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
- const dd = String(d.getUTCDate()).padStart(2, "0");
- return `${yyyy}-${mm}-${dd}`;
-}
-
-async function buildMoodAnalyticsResponse(
- userId: string,
-): Promise {
- // v1.4.39 W-MOOD — fire-and-forget warm-up so the next cold mount
- // for this user lands on the rollup tier even when the boot-time
- // backfill hasn't reached them yet.
- void ensureUserMoodRollupsFresh(userId);
-
- const since = new Date(Date.now() - ROLLUP_WINDOW_MS);
- const rollups = await readMoodDayRollups(userId, since);
-
- if (rollups.length > 0) {
- // Fast path — one DAY-rollup row per calendar day. The legacy
- // `aggregateDailyAverages` collapse is unnecessary because the
- // rollup already carries the daily mean; we recreate its output
- // shape directly so the response stays byte-compatible.
- const entries = rollups
- .map((r) => ({
- date: utcDayLabel(r.bucketStart),
- score: Math.round(r.mean * 100) / 100,
- samples: r.count,
- }))
- .sort((a, b) => a.date.localeCompare(b.date));
-
- // Summarize feeds the slope7/30/90 windows. One DataPoint per
- // day (mean) is the right resolution — the legacy path passed
- // per-entry points, but the slope-window outputs match because
- // a power user's mood is typically 1/day. Multi-entry days
- // contribute their daily mean exactly once, which is the same
- // semantic the dashboard tile renders.
- const dataPoints: DataPoint[] = rollups.map((r) => ({
- date: r.bucketStart,
- value: r.mean,
- }));
- const summary = summarize(dataPoints);
- const entryCount = rollups.reduce((s, r) => s + r.count, 0);
-
- annotate({ meta: { mood_analytics_path: "rollup" } });
-
- return { entries, summary, entryCount };
- }
-
- // Coverage-fallback — the user has no rollup rows yet. Probe for
- // raw mood entries; when the table is empty for this user we
- // return the same empty envelope the legacy path emitted. When
- // mood entries exist but rollups don't (legacy account before the
- // boot-backfill has caught up), we run the legacy live walk ONCE
- // so the request still gets a correct response — the warm-up
- // fired above will mint the rollups for the next request.
- const moodEntries = await prisma.moodEntry.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the analytics fallback.
- where: { userId, deletedAt: null },
- orderBy: { moodLoggedAt: "asc" },
- select: { date: true, score: true, moodLoggedAt: true },
- });
-
- const entries = aggregateDailyAverages(
- moodEntries.map((e) => ({ date: e.date, score: e.score })),
- );
-
- // QA UX-H1 (v1.4.39): feed `summarize()` per-day means, not per-entry
- // scores. The rollup fast-path emits one DataPoint per calendar day
- // (the rollup's `mean`); the live fallback used to pass every raw
- // entry, which silently shifted `summary.count / latest / min / max
- // / mean / avg7 / avg30 / slope30` on power-user multi-entry days.
- // Pre-aggregating through `aggregateDailyAverages` keeps the two
- // branches byte-identical on multi-entry days too. Date anchor is
- // local-noon for the day so the slope x-axis spans whole-day units —
- // mirrors the dashboard tile's intuition (one number per day).
- const dataPoints: DataPoint[] = entries.map((e) => ({
- date: new Date(`${e.date}T12:00:00.000Z`),
- value: e.score,
- }));
-
- const summary = summarize(dataPoints);
-
- annotate({
- meta: {
- mood_analytics_path: moodEntries.length === 0 ? "rollup" : "live",
- },
- });
-
- return { entries, summary, entryCount: moodEntries.length };
-}
-
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
+ // v1.17.1 — single mood engine: the route and the dashboard snapshot
+ // both read through `buildMoodDailySeries`, so the dashboard tile, the
+ // insights mood sparkline, and the iOS client all see the same number.
const result = await cached(
- caches.moodAnalytics as ServerCache,
+ caches.moodAnalytics as ServerCache,
user.id,
- () => buildMoodAnalyticsResponse(user.id),
+ () => buildMoodDailySeries(user.id),
annotate,
);
annotate({
action: { name: "mood.analytics" },
- meta: { entryCount: result.entryCount },
+ meta: {
+ entryCount: result.entryCount,
+ mood_analytics_path: result.source,
+ },
});
return apiSuccess({ entries: result.entries, summary: result.summary });
diff --git a/src/lib/analytics/__tests__/mood-series.test.ts b/src/lib/analytics/__tests__/mood-series.test.ts
new file mode 100644
index 00000000..07ec2422
--- /dev/null
+++ b/src/lib/analytics/__tests__/mood-series.test.ts
@@ -0,0 +1,94 @@
+/**
+ * v1.17.1 — the single mood engine `buildMoodDailySeries`.
+ *
+ * Pins the one-engine contract: the dashboard snapshot (`buildMoodBlock`)
+ * and the `/api/mood/analytics` route both read through this function, so
+ * the same number must read identically on dashboard + insights. Covers
+ * the rollup fast-path and the legacy live fallback, and asserts the live
+ * fallback collapses multi-entry days into the daily mean exactly once.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ moodEntry: { findMany: vi.fn() },
+ moodEntryRollup: { findMany: vi.fn() },
+ },
+}));
+
+vi.mock("@/lib/rollups/mood-rollups", async () => {
+ const actual = await vi.importActual<
+ typeof import("@/lib/rollups/mood-rollups")
+ >("@/lib/rollups/mood-rollups");
+ return {
+ ...actual,
+ ensureUserMoodRollupsFresh: vi
+ .fn()
+ .mockResolvedValue({ recomputed: false }),
+ readMoodDayRollups: vi.fn(),
+ };
+});
+
+import { buildMoodDailySeries } from "../mood-series";
+import { prisma } from "@/lib/db";
+import { readMoodDayRollups } from "@/lib/rollups/mood-rollups";
+
+beforeEach(() => {
+ vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+ vi.mocked(readMoodDayRollups).mockResolvedValue([] as never);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("buildMoodDailySeries", () => {
+ it("reads the rollup tier and skips the raw walk when DAY rows exist", async () => {
+ vi.mocked(readMoodDayRollups).mockResolvedValue([
+ { bucketStart: new Date("2026-06-01T00:00:00Z"), mean: 4, count: 2 },
+ { bucketStart: new Date("2026-06-02T00:00:00Z"), mean: 3, count: 1 },
+ ] as never);
+
+ const series = await buildMoodDailySeries("user-1");
+
+ expect(series.source).toBe("rollup");
+ expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
+ expect(series.entries).toEqual([
+ { date: "2026-06-01", score: 4, samples: 2 },
+ { date: "2026-06-02", score: 3, samples: 1 },
+ ]);
+ // entryCount sums the per-day samples (2 + 1).
+ expect(series.entryCount).toBe(3);
+ expect(series.summary.count).toBe(2);
+ });
+
+ it("falls back to the live walk once and collapses multi-entry days to the daily mean", async () => {
+ vi.mocked(readMoodDayRollups).mockResolvedValue([] as never);
+ vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
+ { date: "2026-06-01", score: 5 },
+ { date: "2026-06-01", score: 3 },
+ { date: "2026-06-02", score: 4 },
+ ] as never);
+
+ const series = await buildMoodDailySeries("user-1");
+
+ expect(series.source).toBe("live");
+ // Two distinct calendar days; day one is the mean of its two entries.
+ expect(series.entries).toEqual([
+ { date: "2026-06-01", score: 4, samples: 2 },
+ { date: "2026-06-02", score: 4, samples: 1 },
+ ]);
+ expect(series.entryCount).toBe(3);
+ // summarize sees one DataPoint per day, not per raw entry.
+ expect(series.summary.count).toBe(2);
+ });
+
+ it("returns an empty series for a user with no rollups and no raw entries", async () => {
+ const series = await buildMoodDailySeries("user-1");
+ expect(series.entries).toEqual([]);
+ expect(series.entryCount).toBe(0);
+ // An empty table reads as the rollup tier for annotation parity.
+ expect(series.source).toBe("rollup");
+ expect(series.summary.count).toBe(0);
+ });
+});
diff --git a/src/lib/analytics/mood-series.ts b/src/lib/analytics/mood-series.ts
new file mode 100644
index 00000000..6076602f
--- /dev/null
+++ b/src/lib/analytics/mood-series.ts
@@ -0,0 +1,149 @@
+/**
+ * v1.17.1 — single mood daily-series engine.
+ *
+ * Before this module the dashboard snapshot (`buildMoodBlock` in
+ * `src/lib/dashboard/snapshot.ts`) and the `/api/mood/analytics` route
+ * (`buildMoodAnalyticsResponse`) each carried a hand-copied rollup read +
+ * coverage fallback + `summarize()` of the same numbers. Two engines for
+ * one metric is the classic seam where the dashboard and the insights
+ * surface can read mood differently. This module is the one canonical
+ * read; both callers delegate to it so the same number reads identically
+ * on the dashboard, the insights mood sparkline, and the iOS client.
+ */
+import type { PrismaClient } from "@/generated/prisma/client";
+import { prisma } from "@/lib/db";
+import { summarize, type DataPoint } from "@/lib/analytics/trends";
+import {
+ ensureUserMoodRollupsFresh,
+ readMoodDayRollups,
+} from "@/lib/rollups/mood-rollups";
+
+/**
+ * Five-year window for the rollup-tier read. The rollup tier stores one
+ * row per day, so a five-year cap is cheap (at most ~1 800 rows) and
+ * still covers every user the product has historic data for. The legacy
+ * route + snapshot both used this same window.
+ */
+export const MOOD_SERIES_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000;
+
+/** One daily mood point: the day's average score + how many entries fed it. */
+export interface MoodDailyEntry {
+ date: string;
+ score: number;
+ samples: number;
+}
+
+export interface MoodDailySeries {
+ entries: MoodDailyEntry[];
+ summary: ReturnType;
+ /** Total raw mood entries behind the series (sum of `samples`). */
+ entryCount: number;
+ /** Which tier answered — for wide-event annotation by the caller. */
+ source: "rollup" | "live";
+}
+
+/**
+ * Format a UTC `Date` as a YYYY-MM-DD label. The rollup tier anchors on
+ * UTC midnight (same convention as the measurement rollup tier). For
+ * tenants within ±3 h of UTC (Berlin year-round) the label agrees with
+ * the local wall-clock day on every entry whose timestamp doesn't
+ * straddle the UTC boundary — i.e. every realistic mood log. v1.5
+ * per-user-tz bucketing (audit P7) closes the DST fall-back-night gap.
+ */
+function utcDayLabel(d: Date): string {
+ const yyyy = d.getUTCFullYear();
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const dd = String(d.getUTCDate()).padStart(2, "0");
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+/** Aggregate multiple raw mood entries per day into daily averages. */
+function aggregateDailyAverages(
+ records: Array<{ date: string; score: number }>,
+): MoodDailyEntry[] {
+ const byDay = new Map();
+ for (const record of records) {
+ const current = byDay.get(record.date) ?? { sum: 0, count: 0 };
+ current.sum += record.score;
+ current.count += 1;
+ byDay.set(record.date, current);
+ }
+ return Array.from(byDay.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([day, stats]) => ({
+ date: day,
+ score: Math.round((stats.sum / stats.count) * 100) / 100,
+ samples: stats.count,
+ }));
+}
+
+/**
+ * Canonical mood daily series for a user. Rollup fast-path first; a single
+ * live walk only on coverage miss (and the warm-up below mints the rollups
+ * for the next read). Feeds `summarize()` per-day means in both branches so
+ * the slope/latest/min/max windows are byte-identical on multi-entry days.
+ *
+ * `client` lets the snapshot builder pass its own Prisma handle; defaults to
+ * the shared singleton for the route caller.
+ */
+export async function buildMoodDailySeries(
+ userId: string,
+ client: PrismaClient = prisma,
+): Promise {
+ // Fire-and-forget warm-up so the next cold mount for this user lands on
+ // the rollup tier even when the boot-time backfill hasn't reached them.
+ void ensureUserMoodRollupsFresh(userId);
+
+ const since = new Date(Date.now() - MOOD_SERIES_WINDOW_MS);
+ const rollups = await readMoodDayRollups(userId, since);
+
+ if (rollups.length > 0) {
+ const entries = rollups
+ .map((r) => ({
+ date: utcDayLabel(r.bucketStart),
+ score: Math.round(r.mean * 100) / 100,
+ samples: r.count,
+ }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+ const dataPoints: DataPoint[] = rollups.map((r) => ({
+ date: r.bucketStart,
+ value: r.mean,
+ }));
+ const summary = summarize(dataPoints);
+ const entryCount = rollups.reduce((s, r) => s + r.count, 0);
+ return { entries, summary, entryCount, source: "rollup" };
+ }
+
+ // Coverage fallback — no rollup rows yet. Run the legacy live walk ONCE;
+ // the warm-up above mints the rollups for the next request.
+ const moodEntries = await client.moodEntry.findMany({
+ // v1.7.0 sync — exclude tombstoned rows from the fallback.
+ where: { userId, deletedAt: null },
+ orderBy: { moodLoggedAt: "asc" },
+ select: { date: true, score: true },
+ });
+
+ const entries = aggregateDailyAverages(
+ moodEntries.map((e: { date: string; score: number }) => ({
+ date: e.date,
+ score: e.score,
+ })),
+ );
+
+ // Date anchor is local-noon for the day so the slope x-axis spans
+ // whole-day units — mirrors the dashboard tile's one-number-per-day.
+ const dataPoints: DataPoint[] = entries.map((e) => ({
+ date: new Date(`${e.date}T12:00:00.000Z`),
+ value: e.score,
+ }));
+ const summary = summarize(dataPoints);
+
+ // An empty table reads as "rollup" (nothing to walk) for annotation
+ // parity with the historic route; a non-empty live walk reads "live".
+ return {
+ entries,
+ summary,
+ entryCount: moodEntries.length,
+ source: moodEntries.length === 0 ? "rollup" : "live",
+ };
+}
diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts
index 14e7e328..ebf86b95 100644
--- a/src/lib/dashboard/snapshot.ts
+++ b/src/lib/dashboard/snapshot.ts
@@ -61,10 +61,7 @@ import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
-import {
- ensureUserMoodRollupsFresh,
- readMoodDayRollups,
-} from "@/lib/rollups/mood-rollups";
+import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import {
resolveDashboardLayout,
DASHBOARD_WIDGET_CATALOGUE_IDS,
@@ -85,9 +82,6 @@ import type { HealthScoreBand } from "@/lib/analytics/health-score";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
-/** Five-year mood window — mirrors `/api/mood/analytics`. */
-const MOOD_ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000;
-
const GLUCOSE_CONTEXTS = [
"FASTING",
"POSTPRANDIAL",
@@ -339,14 +333,6 @@ export interface SnapshotUserInput {
dashboardWidgetsJson: unknown;
}
-/** Format a UTC `Date` as a YYYY-MM-DD label (matches mood rollup tier). */
-function utcDayLabel(d: Date): string {
- const yyyy = d.getUTCFullYear();
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
- const dd = String(d.getUTCDate()).padStart(2, "0");
- return `${yyyy}-${mm}-${dd}`;
-}
-
/** Wall-clock hour in `tz`, used for the server-side greeting. */
function hourInTimezone(now: Date, tz: string): number {
try {
@@ -383,63 +369,20 @@ function enrichLastSeen(
return out;
}
-/** Read-only mood block, lifting the rollup-tier read from `/api/mood/analytics`. */
+/**
+ * Read-only mood block. v1.17.1 — delegates to the single mood engine
+ * `buildMoodDailySeries` so the dashboard tile reads the exact numbers
+ * `/api/mood/analytics` (the insights mood sparkline source) returns.
+ */
async function buildMoodBlock(
- prisma: PrismaClient,
+ client: PrismaClient,
userId: string,
): Promise<{
summary: DataSummary | null;
entries: DashboardSnapshotMoodEntry[];
}> {
- void ensureUserMoodRollupsFresh(userId);
-
- const since = new Date(Date.now() - MOOD_ROLLUP_WINDOW_MS);
- const rollups = await readMoodDayRollups(userId, since);
-
- if (rollups.length > 0) {
- const entries = rollups
- .map((r) => ({
- date: utcDayLabel(r.bucketStart),
- score: Math.round(r.mean * 100) / 100,
- samples: r.count,
- }))
- .sort((a, b) => a.date.localeCompare(b.date));
- const dataPoints: DataPoint[] = rollups.map((r) => ({
- date: r.bucketStart,
- value: r.mean,
- }));
- return { summary: summarize(dataPoints), entries };
- }
-
- // Coverage fallback — mirror the live walk in `/api/mood/analytics`.
- const moodEntries = await prisma.moodEntry.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the dashboard snapshot.
- where: { userId, deletedAt: null },
- orderBy: { moodLoggedAt: "asc" },
- select: { date: true, score: true },
- });
- if (moodEntries.length === 0) {
- return { summary: summarize([]), entries: [] };
- }
- const byDay = new Map();
- for (const e of moodEntries) {
- const cur = byDay.get(e.date) ?? { sum: 0, count: 0 };
- cur.sum += e.score;
- cur.count += 1;
- byDay.set(e.date, cur);
- }
- const entries = Array.from(byDay.entries())
- .sort(([a], [b]) => a.localeCompare(b))
- .map(([day, stats]) => ({
- date: day,
- score: Math.round((stats.sum / stats.count) * 100) / 100,
- samples: stats.count,
- }));
- const dataPoints: DataPoint[] = entries.map((e) => ({
- date: new Date(`${e.date}T12:00:00.000Z`),
- value: e.score,
- }));
- return { summary: summarize(dataPoints), entries };
+ const series = await buildMoodDailySeries(userId, client);
+ return { summary: series.summary, entries: series.entries };
}
/**
From b1a81bb65e9af1589e62c191d1e492d432081466 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:57:15 +0200
Subject: [PATCH 28/79] refactor(insights): unify the learning-state gates
behind one primitive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Glucose, sleep debt, chronotype, the trajectory forecast, and the
composite-score anatomy each hand-rolled the warm 'still learning your X'
paragraph — five copies with different markup, markers, and no shared
accessibility contract. The calm voice held only because each author
copied the tone by hand.
Add a LearningGate primitive that owns the presentation and behaviour of
a learning state (the muted body, an optional caveat line, the learning
marker, and the polite live region) and repoint all five surfaces to it.
Each keeps its own card chrome and its metric-specific copy; only the
gate's look and behaviour are now shared, so they can no longer drift.
---
.../insights/derived/score-anatomy-view.tsx | 15 +--
.../derived/trajectory-forecast-card.tsx | 32 +++----
.../glucose/glucose-clinical-panel.tsx | 26 +++---
.../insights/sleep/chronotype-card.tsx | 7 +-
.../insights/sleep/sleep-debt-card.tsx | 7 +-
.../ui/__tests__/learning-gate.test.tsx | 52 +++++++++++
src/components/ui/learning-gate.tsx | 92 +++++++++++++++++++
7 files changed, 187 insertions(+), 44 deletions(-)
create mode 100644 src/components/ui/__tests__/learning-gate.test.tsx
create mode 100644 src/components/ui/learning-gate.tsx
diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx
index 8a9e5e15..885f24d2 100644
--- a/src/components/insights/derived/score-anatomy-view.tsx
+++ b/src/components/insights/derived/score-anatomy-view.tsx
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
+import { LearningGate } from "@/components/ui/learning-gate";
import type {
DerivedConfidence,
DerivedCoverage,
@@ -180,13 +181,13 @@ export function ScoreAnatomyView({
{insufficient ? (
-
);
diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx
index 5558feb0..6a864d9e 100644
--- a/src/components/insights/sleep/sleep-debt-card.tsx
+++ b/src/components/insights/sleep/sleep-debt-card.tsx
@@ -4,6 +4,7 @@ import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { LearningGate } from "@/components/ui/learning-gate";
import type { SleepDebtDto } from "./use-sleep-rhythm";
/** Whole hours + minutes from a minute total, for the headline figure. */
@@ -36,12 +37,12 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
-
- {t("insights.sleep.debt.learning", {
+
+ />
);
diff --git a/src/components/ui/__tests__/learning-gate.test.tsx b/src/components/ui/__tests__/learning-gate.test.tsx
new file mode 100644
index 00000000..7a043e43
--- /dev/null
+++ b/src/components/ui/__tests__/learning-gate.test.tsx
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { LearningGate } from "../learning-gate";
+
+describe("", () => {
+ it("renders the message in a polite live region marked as learning", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("Still learning your sleep.");
+ expect(html).toContain('role="status"');
+ expect(html).toContain('aria-live="polite"');
+ expect(html).toContain('data-state="learning"');
+ expect(html).toContain('data-slot="learning-gate"');
+ });
+
+ it("renders an optional caveat as a secondary line", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("About 5 more days needed.");
+ expect(html).toContain('data-slot="learning-gate-caveat"');
+ });
+
+ it("omits the caveat line when none is given", () => {
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain("learning-gate-caveat");
+ });
+
+ it("supports a per-surface body slot override for existing selectors", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('data-slot="score-anatomy-insufficient"');
+ });
+
+ it("bordered variant adds the dashed standalone surface", () => {
+ const inline = renderToStaticMarkup();
+ const bordered = renderToStaticMarkup(
+ ,
+ );
+ expect(inline).not.toContain("border-dashed");
+ expect(bordered).toContain("border-dashed");
+ });
+});
diff --git a/src/components/ui/learning-gate.tsx b/src/components/ui/learning-gate.tsx
new file mode 100644
index 00000000..7befc324
--- /dev/null
+++ b/src/components/ui/learning-gate.tsx
@@ -0,0 +1,92 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.17.1 — the one "we're still learning your X" gate.
+ *
+ * Before this primitive, glucose, sleep-debt, chronotype, the trajectory
+ * forecast, and the composite-score anatomy each hand-rolled the warm
+ * "not enough data yet" paragraph — five copies with different markers,
+ * different `data-slot` values, and no shared a11y contract. The calm
+ * voice held only because each author copied the tone by hand, which is
+ * exactly the drift that bites the next metric author.
+ *
+ * `LearningGate` owns the *presentation and behaviour* of a learning
+ * state — the muted body paragraph, an optional secondary caveat line,
+ * the `data-state="learning"` marker, and the polite live region — while
+ * the caller passes the metric-specific localized copy and keeps its own
+ * card title / icon chrome. One look, one behaviour, copy still per-metric.
+ *
+ * Variants:
+ * - `"inline"` (default): a bare muted paragraph for use INSIDE a card
+ * that already has its own header (glucose / sleep / trajectory).
+ * - `"bordered"`: a dashed-border centered note for STANDALONE use where
+ * there is no surrounding card chrome (score anatomy).
+ */
+export interface LearningGateProps
+ extends Omit, "title"> {
+ /** Localized primary "still learning" sentence. */
+ message: React.ReactNode;
+ /** Optional secondary line — a caveat or a "N more days" nudge. */
+ caveat?: React.ReactNode;
+ variant?: "inline" | "bordered";
+ /** Compact density for tiles / narrow rails. */
+ compact?: boolean;
+ /**
+ * Optional override for the body `data-slot`, so existing per-surface
+ * test selectors (e.g. `glucose-learning-body`,
+ * `trajectory-insufficient`, `score-anatomy-insufficient`) keep
+ * resolving after the repoint.
+ */
+ bodySlot?: string;
+}
+
+export function LearningGate({
+ message,
+ caveat,
+ variant = "inline",
+ compact = false,
+ bodySlot,
+ className,
+ ...props
+}: LearningGateProps) {
+ const body = (
+
+ {message}
+
+ );
+
+ return (
+
+ {body}
+ {caveat ? (
+
+ {caveat}
+
+ ) : null}
+
+ );
+}
From a5ec4ef8ddf8c861251b506dc98a1bd34621401c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:58:11 +0200
Subject: [PATCH 29/79] feat(notifications): add silent medication-intake
cross-device sync push
Introduce the MEDICATION_INTAKE_SYNC event and the server-side dispatcher
that wakes a user's other iOS devices after a dose is logged on one of
them, so a running Live Activity or Home-Screen widget reconciles against
the canonical server state.
The dispatch is APNs-only by construction: it sends a silent
apns-push-type: background push (content-available, no alert) carrying
the affected medicationId + scheduledFor, and never fans out to Telegram,
ntfy, or Web Push, which have no silent-sync transport. A new raw APNs
send helper carries the background and liveactivity push types without
the alert/sound/badge enrichment the alert path injects. Devices APNs
reports as permanently dead are reaped; the originating device is
excluded from the fan-out via its registered device token.
When a device has a stored Live Activity push token, an additional
liveactivity end push goes to the per-Activity topic so the lock-screen
Activity ends immediately rather than waiting for the next background
wake.
---
.../__tests__/medication-intake-sync.test.ts | 326 ++++++++++++++++++
.../notifications/medication-intake-sync.ts | 282 +++++++++++++++
src/lib/notifications/senders/apns.ts | 114 ++++++
src/lib/notifications/types.ts | 16 +
4 files changed, 738 insertions(+)
create mode 100644 src/lib/notifications/__tests__/medication-intake-sync.test.ts
create mode 100644 src/lib/notifications/medication-intake-sync.ts
diff --git a/src/lib/notifications/__tests__/medication-intake-sync.test.ts b/src/lib/notifications/__tests__/medication-intake-sync.test.ts
new file mode 100644
index 00000000..ad6485b2
--- /dev/null
+++ b/src/lib/notifications/__tests__/medication-intake-sync.test.ts
@@ -0,0 +1,326 @@
+/**
+ * v1.17.1 (#22) — medication-intake cross-device sync dispatcher tests.
+ *
+ * Exercises the server half of iOS issue #22:
+ * 1. Silent background push to the user's OTHER devices (origin skip).
+ * 2. APNs-only: the dispatch never reaches the dispatcher cascade, so
+ * Telegram / ntfy / Web Push senders are never imported here.
+ * 3. Live Activity end push only when a `liveActivityPushToken` is stored.
+ * 4. Bulk de-dup: one silent push per device per distinct slot.
+ * 5. Explicit APNS-channel opt-out suppresses the fan-out.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const rawSendMock = vi.fn();
+const loadConfigMock = vi.fn();
+const recordPushAttemptMock = vi.fn();
+
+vi.mock("@/lib/notifications/senders/apns", () => ({
+ loadApnsConfig: () => loadConfigMock(),
+ sendApnsRawPush: (...args: unknown[]) => rawSendMock(...args),
+}));
+
+vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
+ recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
+}));
+
+vi.mock("@/lib/logging/context", () => ({
+ getEvent: () => ({ addWarning: vi.fn(), addMeta: vi.fn() }),
+}));
+
+const deviceFindMany = vi.fn();
+const channelFindUnique = vi.fn();
+const deviceDeleteMany = vi.fn();
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ device: {
+ findMany: (...a: unknown[]) => deviceFindMany(...a),
+ deleteMany: (...a: unknown[]) => deviceDeleteMany(...a),
+ },
+ notificationChannel: {
+ findUnique: (...a: unknown[]) => channelFindUnique(...a),
+ },
+ },
+}));
+
+import {
+ dispatchMedicationIntakeSync,
+ dispatchMedicationIntakeSyncBulk,
+} from "@/lib/notifications/medication-intake-sync";
+
+const APNS_CONFIG = { bundleId: "test.healthlog.ios" };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ loadConfigMock.mockReturnValue(APNS_CONFIG);
+ channelFindUnique.mockResolvedValue({ enabled: true, preferences: [] });
+ rawSendMock.mockResolvedValue({ ok: true, status: 200 });
+ deviceDeleteMany.mockResolvedValue({ count: 0 });
+});
+
+describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", () => {
+ it("sends a silent background push to every device EXCEPT the origin", async () => {
+ deviceFindMany.mockResolvedValue([
+ {
+ id: "d-origin",
+ token: "origin-token",
+ apnsToken: "aaaa1111",
+ apnsEnvironment: "production",
+ liveActivityPushToken: null,
+ },
+ {
+ id: "d-other",
+ token: "other-token",
+ apnsToken: "bbbb2222",
+ apnsEnvironment: "sandbox",
+ liveActivityPushToken: null,
+ },
+ ]);
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ originDeviceToken: "origin-token",
+ });
+
+ // Exactly one push — to the non-origin device, silent background.
+ expect(rawSendMock).toHaveBeenCalledTimes(1);
+ const call = rawSendMock.mock.calls[0][0];
+ expect(call.deviceToken).toBe("bbbb2222");
+ expect(call.pushType).toBe("background");
+ expect(call.priority).toBe(5);
+ expect(call.payload).toMatchObject({
+ aps: { "content-available": 1 },
+ eventType: "MEDICATION_INTAKE_SYNC",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ });
+ // No alert/sound/badge keys in the silent payload.
+ expect(call.payload.aps.alert).toBeUndefined();
+ expect(call.payload.aps.sound).toBeUndefined();
+ });
+
+ it("skips nothing when the origin token has no matching device (web caller)", async () => {
+ deviceFindMany.mockResolvedValue([
+ {
+ id: "d1",
+ token: "tok-1",
+ apnsToken: "aaaa1111",
+ apnsEnvironment: "sandbox",
+ liveActivityPushToken: null,
+ },
+ ]);
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ originDeviceToken: "no-such-token",
+ });
+
+ expect(rawSendMock).toHaveBeenCalledTimes(1);
+ expect(rawSendMock.mock.calls[0][0].deviceToken).toBe("aaaa1111");
+ });
+
+ it("records a skipped attempt when only the origin device exists", async () => {
+ deviceFindMany.mockResolvedValue([
+ {
+ id: "d-origin",
+ token: "origin-token",
+ apnsToken: "aaaa1111",
+ apnsEnvironment: "sandbox",
+ liveActivityPushToken: null,
+ },
+ ]);
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ originDeviceToken: "origin-token",
+ });
+
+ expect(rawSendMock).not.toHaveBeenCalled();
+ expect(recordPushAttemptMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channel: "APNS",
+ eventType: "MEDICATION_INTAKE_SYNC",
+ result: "skipped",
+ reason: "intake_sync_no_other_devices",
+ }),
+ );
+ });
+});
+
+describe("dispatchMedicationIntakeSync — Live Activity push", () => {
+ it("sends a liveactivity end push only for devices with a stored token", async () => {
+ deviceFindMany.mockResolvedValue([
+ {
+ id: "d1",
+ token: "tok-1",
+ apnsToken: "aaaa1111",
+ apnsEnvironment: "production",
+ liveActivityPushToken: "la-token-1",
+ },
+ ]);
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ });
+
+ // One silent + one liveactivity push.
+ expect(rawSendMock).toHaveBeenCalledTimes(2);
+ const types = rawSendMock.mock.calls.map((c) => c[0].pushType);
+ expect(types).toEqual(["background", "liveactivity"]);
+
+ const la = rawSendMock.mock.calls.find(
+ (c) => c[0].pushType === "liveactivity",
+ )![0];
+ expect(la.deviceToken).toBe("la-token-1");
+ expect(la.topic).toBe("test.healthlog.ios.push-type.liveactivity");
+ expect(la.payload.aps.event).toBe("end");
+ });
+
+ it("sends no liveactivity push when no token is stored", async () => {
+ deviceFindMany.mockResolvedValue([
+ {
+ id: "d1",
+ token: "tok-1",
+ apnsToken: "aaaa1111",
+ apnsEnvironment: "sandbox",
+ liveActivityPushToken: null,
+ },
+ ]);
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ });
+
+ expect(rawSendMock).toHaveBeenCalledTimes(1);
+ expect(rawSendMock.mock.calls[0][0].pushType).toBe("background");
+ });
+});
+
+describe("dispatchMedicationIntakeSync — gating", () => {
+ it("no-ops silently when APNs is not configured", async () => {
+ loadConfigMock.mockReturnValue(null);
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ });
+
+ expect(deviceFindMany).not.toHaveBeenCalled();
+ expect(rawSendMock).not.toHaveBeenCalled();
+ expect(recordPushAttemptMock).not.toHaveBeenCalled();
+ });
+
+ it("suppresses the fan-out on an explicit APNS-channel opt-out", async () => {
+ channelFindUnique.mockResolvedValue({
+ enabled: true,
+ preferences: [{ enabled: false }],
+ });
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ });
+
+ expect(deviceFindMany).not.toHaveBeenCalled();
+ expect(rawSendMock).not.toHaveBeenCalled();
+ expect(recordPushAttemptMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ result: "skipped",
+ reason: "intake_sync_disabled",
+ }),
+ );
+ });
+
+ it("suppresses when the APNS channel row itself is disabled", async () => {
+ channelFindUnique.mockResolvedValue({ enabled: false, preferences: [] });
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ });
+
+ expect(rawSendMock).not.toHaveBeenCalled();
+ });
+
+ it("reaps devices APNs reports as permanently dead", async () => {
+ deviceFindMany.mockResolvedValue([
+ {
+ id: "d-dead",
+ token: "tok-1",
+ apnsToken: "aaaa1111",
+ apnsEnvironment: "sandbox",
+ liveActivityPushToken: null,
+ },
+ ]);
+ rawSendMock.mockResolvedValue({
+ ok: false,
+ reason: "BadDeviceToken",
+ shouldDisable: true,
+ });
+
+ await dispatchMedicationIntakeSync({
+ userId: "u1",
+ medicationId: "m1",
+ scheduledFor: "2026-06-14T07:00:00.000Z",
+ });
+
+ expect(deviceDeleteMany).toHaveBeenCalledWith({
+ where: { id: { in: ["d-dead"] } },
+ });
+ expect(recordPushAttemptMock).toHaveBeenCalledWith(
+ expect.objectContaining({ result: "error" }),
+ );
+ });
+});
+
+describe("dispatchMedicationIntakeSyncBulk — slot de-dup", () => {
+ it("fires one silent push per device per DISTINCT slot, not per row", async () => {
+ deviceFindMany.mockResolvedValue([
+ {
+ id: "d-other",
+ token: "other-token",
+ apnsToken: "bbbb2222",
+ apnsEnvironment: "sandbox",
+ liveActivityPushToken: null,
+ },
+ ]);
+
+ await dispatchMedicationIntakeSyncBulk({
+ userId: "u1",
+ originDeviceToken: "origin-token",
+ slots: [
+ { medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" },
+ // duplicate of the first — must collapse
+ { medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" },
+ { medicationId: "m1", scheduledFor: "2026-06-14T19:00:00.000Z" },
+ { medicationId: "m2", scheduledFor: "2026-06-14T07:00:00.000Z" },
+ ],
+ });
+
+ // 3 distinct slots × 1 recipient device = 3 silent pushes.
+ expect(rawSendMock).toHaveBeenCalledTimes(3);
+ const slots = rawSendMock.mock.calls.map((c) => [
+ c[0].payload.medicationId,
+ c[0].payload.scheduledFor,
+ ]);
+ expect(slots).toEqual([
+ ["m1", "2026-06-14T07:00:00.000Z"],
+ ["m1", "2026-06-14T19:00:00.000Z"],
+ ["m2", "2026-06-14T07:00:00.000Z"],
+ ]);
+ });
+});
diff --git a/src/lib/notifications/medication-intake-sync.ts b/src/lib/notifications/medication-intake-sync.ts
new file mode 100644
index 00000000..90f20af8
--- /dev/null
+++ b/src/lib/notifications/medication-intake-sync.ts
@@ -0,0 +1,282 @@
+/**
+ * v1.17.1 (#22) — server-authoritative medication-intake cross-device sync.
+ *
+ * When a dose is logged / skipped / snoozed on ONE device, the user's
+ * OTHER iOS devices need to reconcile: end or update the running Live
+ * Activity, refresh the Home-Screen widget, drop the local reminder. The
+ * server is the single source of truth for the intake state, so it owns
+ * the fan-out. This module dispatches two APNs pushes per affected device:
+ *
+ * 1. A SILENT background push (`apns-push-type: background`,
+ * `content-available: 1`, no alert) carrying
+ * `{ eventType: "MEDICATION_INTAKE_SYNC", medicationId, scheduledFor }`.
+ * The iOS `NotificationService` consumes it and runs
+ * `BackgroundSyncCoordinator.runMedicationReconcile`.
+ *
+ * 2. A Live Activity end/update push (`apns-push-type: liveactivity`,
+ * topic `.push-type.liveactivity`) to any device that has a
+ * stored `liveActivityPushToken`, so the lock-screen Activity ends
+ * immediately rather than waiting for the next background wake.
+ *
+ * APNs-ONLY by design. This event has no Telegram / ntfy / Web Push
+ * semantics (those channels have no silent-sync transport), so it never
+ * touches the dispatcher cascade — it talks straight to the APNs senders.
+ *
+ * Origin skip: the device that POSTed the mutation already knows the new
+ * state and must not be woken. The caller passes the originating device's
+ * registered `token` (the value the iOS client sends as `X-Device-Id`);
+ * the matching `Device` row is excluded from both fan-outs. A web caller —
+ * or a token with no matching row — skips nothing, which is harmless
+ * (the iOS reconcile is idempotent).
+ *
+ * Best-effort: every failure is swallowed + annotated. A sync-push miss
+ * must never fail the intake mutation that triggered it; the canonical
+ * row is already persisted.
+ */
+import { prisma } from "@/lib/db";
+import { getEvent } from "@/lib/logging/context";
+import {
+ loadApnsConfig,
+ sendApnsRawPush,
+} from "@/lib/notifications/senders/apns";
+import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
+import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types";
+
+const EVENT_TYPE = "MEDICATION_INTAKE_SYNC";
+
+export interface IntakeSyncInput {
+ userId: string;
+ medicationId: string;
+ /** ISO-8601 scheduled-for instant of the affected dose slot. */
+ scheduledFor: string;
+ /**
+ * The originating device's registered `Device.token` (sent by the iOS
+ * client as the `X-Device-Id` header). The matching device is excluded
+ * from the fan-out. `null` / unmatched skips nothing.
+ */
+ originDeviceToken?: string | null;
+}
+
+/**
+ * Whether the user has explicitly opted the APNS channel out of
+ * MEDICATION_INTAKE_SYNC. There is no second user-facing toggle — this is
+ * coherence plumbing — but an explicit per-channel `NotificationPreference`
+ * row (APNS / MEDICATION_INTAKE_SYNC / enabled=false) is still honoured so
+ * an operator can disable the fan-out. Returns false (suppressed) only on
+ * such an explicit opt-out.
+ */
+async function apnsSyncEnabled(userId: string): Promise {
+ const channel = await prisma.notificationChannel.findUnique({
+ where: { userId_type: { userId, type: "APNS" } },
+ select: {
+ enabled: true,
+ preferences: {
+ where: { eventType: EVENT_TYPE },
+ select: { enabled: true },
+ },
+ },
+ });
+ if (!channel || !channel.enabled) return false;
+ const pref = channel.preferences[0];
+ if (pref) return pref.enabled;
+ return EVENT_DEFAULT_ENABLED.MEDICATION_INTAKE_SYNC;
+}
+
+/**
+ * Dispatch the silent intake-sync push (+ Live Activity push) to the
+ * user's other devices. De-duplicated to ONE silent push per device
+ * regardless of how many intake rows the triggering mutation touched —
+ * the caller passes a single `(medicationId, scheduledFor)` pair per
+ * affected slot, but a bulk call should call this once with the affected
+ * slot, not once per row (see `dispatchIntakeSyncBulk`).
+ */
+export async function dispatchMedicationIntakeSync(
+ input: IntakeSyncInput,
+): Promise {
+ try {
+ const config = loadApnsConfig();
+ if (!config) {
+ // APNs not configured on this deployment — nothing to do. No ledger
+ // row: the silent-sync channel is a no-op, not a delivery attempt.
+ return;
+ }
+
+ if (!(await apnsSyncEnabled(input.userId))) {
+ recordPushAttempt({
+ userId: input.userId,
+ channel: "APNS",
+ eventType: EVENT_TYPE,
+ result: "skipped",
+ reason: "intake_sync_disabled",
+ });
+ return;
+ }
+
+ const devices = await prisma.device.findMany({
+ where: { userId: input.userId, apnsToken: { not: null } },
+ select: {
+ id: true,
+ token: true,
+ apnsToken: true,
+ apnsEnvironment: true,
+ liveActivityPushToken: true,
+ },
+ });
+
+ // Exclude the originating device — it already holds the new state.
+ const recipients = devices.filter(
+ (d) => !input.originDeviceToken || d.token !== input.originDeviceToken,
+ );
+
+ if (recipients.length === 0) {
+ recordPushAttempt({
+ userId: input.userId,
+ channel: "APNS",
+ eventType: EVENT_TYPE,
+ result: "skipped",
+ reason: "intake_sync_no_other_devices",
+ });
+ return;
+ }
+
+ const silentPayload = {
+ aps: { "content-available": 1 },
+ eventType: EVENT_TYPE,
+ medicationId: input.medicationId,
+ scheduledFor: input.scheduledFor,
+ };
+
+ let anySuccess = false;
+ const deadDeviceIds: string[] = [];
+ let lastFailureReason: string | undefined;
+
+ for (const device of recipients) {
+ if (!device.apnsToken) continue;
+ const environment: "sandbox" | "production" =
+ device.apnsEnvironment === "production" ? "production" : "sandbox";
+
+ // 1) Silent background reconcile push — one per device.
+ const silent = await sendApnsRawPush({
+ deviceToken: device.apnsToken,
+ environment,
+ pushType: "background",
+ payload: silentPayload,
+ priority: 5,
+ });
+ if (silent.ok) {
+ anySuccess = true;
+ } else {
+ lastFailureReason = silent.reason;
+ if (silent.shouldDisable) deadDeviceIds.push(device.id);
+ }
+
+ // 2) Live Activity end/update push — only when a token is stored.
+ if (device.liveActivityPushToken) {
+ await sendLiveActivityEnd({
+ token: device.liveActivityPushToken,
+ environment,
+ bundleId: config.bundleId,
+ medicationId: input.medicationId,
+ scheduledFor: input.scheduledFor,
+ });
+ }
+ }
+
+ // Reap devices APNs reported as permanently dead on the silent send.
+ if (deadDeviceIds.length > 0) {
+ await prisma.device
+ .deleteMany({ where: { id: { in: deadDeviceIds } } })
+ .catch(() => {
+ /* best-effort cleanup */
+ });
+ }
+
+ recordPushAttempt({
+ userId: input.userId,
+ channel: "APNS",
+ eventType: EVENT_TYPE,
+ result: anySuccess ? "ok" : "error",
+ reason: anySuccess ? null : (lastFailureReason ?? "intake_sync_failed"),
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ getEvent()?.addWarning(`medication_intake_sync_dispatch_failed: ${message}`);
+ }
+}
+
+/**
+ * Bulk-call entry point: de-duplicates the affected slots so the fan-out
+ * fires ONE silent push per device per distinct `(medicationId,
+ * scheduledFor)` slot the batch touched — not one per intake row. iOS
+ * coalesces multiple background wakes anyway, but de-duping here keeps the
+ * APNs quota proportional to slots, not to row count.
+ */
+export async function dispatchMedicationIntakeSyncBulk(args: {
+ userId: string;
+ slots: Array<{ medicationId: string; scheduledFor: string }>;
+ originDeviceToken?: string | null;
+}): Promise {
+ const seen = new Set();
+ const distinct: Array<{ medicationId: string; scheduledFor: string }> = [];
+ for (const slot of args.slots) {
+ const key = `${slot.medicationId}|${slot.scheduledFor}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ distinct.push(slot);
+ }
+ for (const slot of distinct) {
+ await dispatchMedicationIntakeSync({
+ userId: args.userId,
+ medicationId: slot.medicationId,
+ scheduledFor: slot.scheduledFor,
+ originDeviceToken: args.originDeviceToken,
+ });
+ }
+}
+
+/**
+ * Push an ActivityKit `end` event to a running Live Activity. The Activity
+ * push topic is the app bundle id suffixed with `.push-type.liveactivity`;
+ * the body carries the ActivityKit envelope (`event: "end"`, a timestamp,
+ * an empty content-state). Best-effort: a failure is annotated, never
+ * thrown.
+ */
+async function sendLiveActivityEnd(args: {
+ token: string;
+ environment: "sandbox" | "production";
+ bundleId: string;
+ medicationId: string;
+ scheduledFor: string;
+}): Promise {
+ try {
+ const result = await sendApnsRawPush({
+ deviceToken: args.token,
+ environment: args.environment,
+ pushType: "liveactivity",
+ topic: `${args.bundleId}.push-type.liveactivity`,
+ priority: 10,
+ payload: {
+ aps: {
+ timestamp: Math.floor(Date.now() / 1000),
+ event: "end",
+ "content-state": {},
+ // Dismiss the ended Activity from the lock screen promptly.
+ "dismissal-date": Math.floor(Date.now() / 1000),
+ },
+ medicationId: args.medicationId,
+ scheduledFor: args.scheduledFor,
+ },
+ });
+ if (!result.ok) {
+ getEvent()?.addMeta(
+ "medication_intake_sync_liveactivity_failed",
+ result.reason ?? "unknown",
+ );
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ getEvent()?.addWarning(
+ `medication_intake_sync_liveactivity_threw: ${message}`,
+ );
+ }
+}
diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts
index f38eeffc..51198986 100644
--- a/src/lib/notifications/senders/apns.ts
+++ b/src/lib/notifications/senders/apns.ts
@@ -744,6 +744,120 @@ export async function sendViaApns(
};
}
+export interface ApnsRawSendInput {
+ deviceToken: string;
+ environment: "sandbox" | "production";
+ /**
+ * APNs `apns-push-type` header. `background` for a silent
+ * content-available push (no alert); `liveactivity` for an
+ * ActivityKit update / end push.
+ */
+ pushType: "background" | "liveactivity";
+ /**
+ * Custom JSON payload merged into the notification body. For a
+ * `background` push this is the app's userInfo; for a `liveactivity`
+ * push it carries the `aps` content-state / event / timestamp the iOS
+ * Activity reads. Sent verbatim (no alert / sound / badge enrichment).
+ */
+ payload: Record;
+ /**
+ * APNs topic. The silent-sync push uses the app bundle id (default);
+ * the Live Activity push MUST use `.push-type.liveactivity`.
+ */
+ topic?: string;
+ /** `apns-priority`. `5` for background (power-conserving) is required. */
+ priority?: 5 | 10;
+}
+
+/**
+ * v1.17.1 (#22) — send one raw APNs push (silent background or Live
+ * Activity) to one device. Unlike `sendApnsPush`, this sets NO
+ * `aps.alert` / `aps.sound` / `aps.badge`: a `background` push must be
+ * content-only or APNs rejects it, and a `liveactivity` push carries its
+ * own `aps` content-state in the supplied `payload`. The `rawPayload`
+ * escape hatch bypasses node-apn's convenience setters so the caller
+ * controls the exact wire body.
+ */
+export async function sendApnsRawPush(
+ input: ApnsRawSendInput,
+): Promise {
+ const config = loadApnsConfig();
+ if (!config) {
+ return {
+ ok: false,
+ reason: "apns_not_configured",
+ message: "APNs env vars are not set",
+ };
+ }
+
+ const provider = getProvider(config, input.environment);
+
+ const note = new apn.Notification();
+ note.topic = input.topic ?? config.bundleId;
+ note.pushType = input.pushType;
+ if (input.pushType === "background") {
+ // A silent push declares `content-available: 1` and carries no
+ // alert/sound/badge. Apple rejects a content-available-only push at
+ // priority 10, so default to the power-conserving 5.
+ note.contentAvailable = true;
+ note.priority = input.priority ?? 5;
+ } else {
+ note.priority = input.priority ?? 10;
+ }
+ // Bypass the convenience setters entirely so the body is exactly what
+ // the silent-sync / Live-Activity contract specifies — no injected
+ // `aps.alert` shell.
+ note.rawPayload = input.payload;
+
+ const start = performance.now();
+ let result: Awaited>;
+ try {
+ result = await provider.send(note, input.deviceToken);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "send_failed";
+ getEvent()?.addExternalCall({
+ service: "apns",
+ method: `send_${input.pushType}`,
+ duration_ms: Math.round(performance.now() - start),
+ error: message,
+ });
+ return { ok: false, reason: "apns_network_error", message };
+ }
+
+ const duration = Math.round(performance.now() - start);
+
+ if (result.sent.length > 0) {
+ getEvent()?.addExternalCall({
+ service: "apns",
+ method: `send_${input.pushType}`,
+ duration_ms: duration,
+ status: 200,
+ });
+ return { ok: true, status: 200 };
+ }
+
+ const failure = result.failed[0];
+ const reason = failure?.response?.reason ?? failure?.error?.message;
+ const status = failure?.status ? Number(failure.status) : undefined;
+ const shouldDisable = reason ? PERMANENT_APNS_REASONS.has(reason) : false;
+
+ getEvent()?.addExternalCall({
+ service: "apns",
+ method: `send_${input.pushType}`,
+ duration_ms: duration,
+ status: status ?? undefined,
+ error: reason ?? "all_failed",
+ });
+
+ return {
+ ok: false,
+ status,
+ reason: reason ?? "apns_unknown_failure",
+ shouldDisable,
+ message: failure?.error?.message,
+ };
+}
+
function stripHtml(s: string): string {
return s.replace(/<[^>]*>/g, "");
}
diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts
index ca4eb675..658f6b03 100644
--- a/src/lib/notifications/types.ts
+++ b/src/lib/notifications/types.ts
@@ -66,6 +66,16 @@ export const EVENT_TYPES = [
// `notificationPrefs.measurementReminder.clientManaged` suppression.
// NOT time-sensitive — a Vorsorge nudge is not a Focus-bypass case.
"MEASUREMENT_REMINDER",
+ // v1.17.1 (#22) — silent medication-intake cross-device sync. Fired on a
+ // medication-intake mutation (single + bulk) to the user's OTHER iOS
+ // devices so a Live Activity / Home-Screen widget reconciles after a dose
+ // is logged on one device. APNs-ONLY by construction: it is a silent
+ // `apns-push-type: background` push (content-available, no alert) and has
+ // no Telegram / ntfy / Web Push semantics, so it never fans out to those
+ // channels (the dispatcher would have nothing meaningful to deliver and a
+ // user-visible Telegram message on every dose would be noise). Routed
+ // through the dedicated intake-sync sender, not the alert cascade.
+ "MEDICATION_INTAKE_SYNC",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -117,6 +127,12 @@ export const EVENT_DEFAULT_ENABLED: Record = {
// suppression the cron reads. An explicit per-channel
// `NotificationPreference` row still wins.
MEASUREMENT_REMINDER: true,
+ // v1.17.1 (#22) — ON at the channel layer; an explicit per-channel
+ // `NotificationPreference` row (APNS, eventType MEDICATION_INTAKE_SYNC,
+ // enabled=false) still suppresses it. There is no second user-facing
+ // toggle: this is plumbing that keeps the user's own devices coherent,
+ // not a notification the user chooses to receive.
+ MEDICATION_INTAKE_SYNC: true,
};
export const CHANNEL_TYPE_LABELS: Record = {
From 836fd7405ef5ac129e9ab3b6252ee6fbb6e21d43 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:58:21 +0200
Subject: [PATCH 30/79] feat(medications): dispatch the cross-device intake
sync on a dose mutation
Wire the silent intake-sync push into both intake writers. The single
route fires once for the affected slot after the row is persisted; the
bulk route collects the distinct (medicationId, scheduledFor) slots whose
state actually changed and fires one push per slot, so a 500-row backfill
wakes a device per affected slot rather than per row. Pending echoes,
duplicates, and no-downgrade no-ops change nothing visible and are
excluded.
Both pass the originating device through the X-Device-Id header so the
device that recorded the dose is skipped. The dispatch is best-effort and
fire-and-forget: the canonical rows are already written, so a push miss
never affects the response.
---
.../intake/__tests__/route.test.ts | 46 +++++++++++++++++++
src/app/api/medications/intake/bulk/route.ts | 34 ++++++++++++++
src/app/api/medications/intake/route.ts | 14 ++++++
3 files changed, 94 insertions(+)
diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts
index d1c15d0f..ae320ccb 100644
--- a/src/app/api/medications/intake/__tests__/route.test.ts
+++ b/src/app/api/medications/intake/__tests__/route.test.ts
@@ -53,6 +53,13 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
+// v1.17.1 (#22) — silent cross-device intake sync. Mocked so the route
+// test asserts the wiring (called with the right slot + origin token)
+// without reaching the APNs senders.
+vi.mock("@/lib/notifications/medication-intake-sync", () => ({
+ dispatchMedicationIntakeSync: vi.fn().mockResolvedValue(undefined),
+}));
+
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -75,6 +82,7 @@ import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
+import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -388,6 +396,44 @@ describe("POST /api/medications/intake", () => {
expect(consumeForIntake).not.toHaveBeenCalled();
});
+ // ── v1.17.1 (#22) — silent cross-device intake sync wiring ──────────
+
+ it("dispatches the silent intake sync with the affected slot + origin device", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const slot = new Date("2026-05-18T10:00:00.000Z");
+ vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
+ id: "e1",
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: slot,
+ } as never);
+ vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
+ id: "e1",
+ skipped: true,
+ takenAt: null,
+ scheduledFor: slot,
+ } as never);
+
+ const r = new NextRequest("http://localhost/api/medications/intake", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-device-id": "origin-device-token",
+ },
+ body: JSON.stringify({ intakeId: "e1", status: "skipped" }),
+ });
+ const res = await POST(r);
+ expect(res.status).toBe(200);
+
+ expect(dispatchMedicationIntakeSync).toHaveBeenCalledTimes(1);
+ expect(dispatchMedicationIntakeSync).toHaveBeenCalledWith({
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: "2026-05-18T10:00:00.000Z",
+ originDeviceToken: "origin-device-token",
+ });
+ });
+
// ── v1.16.10 — inventory consumption seams ──────────────────────────
it("taken (no slot move) consumes inventory exactly once on the toggled row", async () => {
diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts
index b923db78..2c05515e 100644
--- a/src/app/api/medications/intake/bulk/route.ts
+++ b/src/app/api/medications/intake/bulk/route.ts
@@ -68,6 +68,7 @@ import {
type InjectionSiteValue,
} from "@/lib/validations/medication";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
+import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -239,6 +240,14 @@ async function postBulk(request: NextRequest): Promise {
string,
{ medicationId: string; dayKey: string }
>();
+ // v1.17.1 (#22) — distinct `(medicationId, scheduledFor)` slots whose
+ // state actually changed in this batch (a row inserted / updated), so the
+ // silent cross-device sync fires once per affected slot — not once per
+ // row, and not for pending echoes / duplicates that changed nothing.
+ const syncSlots = new Map<
+ string,
+ { medicationId: string; scheduledFor: string }
+ >();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -583,6 +592,18 @@ async function postBulk(request: NextRequest): Promise {
medicationId: entry.medicationId,
dayKey,
});
+ // v1.17.1 (#22) — collect the slot for the silent cross-device sync
+ // only when the row's state actually changed (inserted / updated). A
+ // pending echo, duplicate, or no-downgrade no-op leaves the visible
+ // dose state untouched, so it must not wake the user's other devices.
+ const lastStatus = results[results.length - 1]?.status;
+ if (lastStatus === "inserted" || lastStatus === "updated") {
+ const scheduledForIso = effectiveScheduledFor.toISOString();
+ syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, {
+ medicationId: entry.medicationId,
+ scheduledFor: scheduledForIso,
+ });
+ }
} catch (err: unknown) {
// P2002 = unique-constraint violation. Two shapes reach here:
// 1. an idempotencyKey collision on the unscheduled/PRN insert →
@@ -679,6 +700,19 @@ async function postBulk(request: NextRequest): Promise {
}
}
+ // v1.17.1 (#22) — silent cross-device intake sync. One push per device
+ // per distinct affected slot (the map de-dupes), excluding the
+ // originating device (`X-Device-Id` = registered `Device.token`).
+ // APNs-only, best-effort, fire-and-forget: the canonical rows are already
+ // persisted, so a sync-push miss never affects the batch response.
+ if (syncSlots.size > 0) {
+ void dispatchMedicationIntakeSyncBulk({
+ userId: user.id,
+ slots: Array.from(syncSlots.values()),
+ originDeviceToken: request.headers.get("x-device-id"),
+ });
+ }
+
return apiSuccess({
processed: entries.length,
inserted,
diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts
index 002c9158..506fb660 100644
--- a/src/app/api/medications/intake/route.ts
+++ b/src/app/api/medications/intake/route.ts
@@ -50,6 +50,7 @@ import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
+import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -684,5 +685,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
+ // v1.17.1 (#22) — silent cross-device intake sync. Wake the user's OTHER
+ // iOS devices so a running Live Activity / Home-Screen widget reconciles
+ // the dose state changed here. APNs-only, best-effort, fire-and-forget:
+ // the canonical row is already persisted, so a sync-push miss never
+ // affects the response. The originating device (the one that POSTed) is
+ // excluded via its `X-Device-Id` (= registered `Device.token`).
+ void dispatchMedicationIntakeSync({
+ userId: user.id,
+ medicationId: existing.medicationId,
+ scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
+ originDeviceToken: request.headers.get("x-device-id"),
+ });
+
return apiSuccess(updated);
});
From 6d8dd75fffcd4b6b710cb77727ea85bf59b75241 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 14 Jun 2026 23:58:27 +0200
Subject: [PATCH 31/79] feat(devices): accept a Live Activity push token on
registration
Add an optional hex liveActivityPushToken to device registration. The iOS
client registers the current per-Activity ActivityKit push token so the
server can address a Live Activity update or end push on a
medication-intake mutation, and sends null to clear it when no Activity is
running. Following the medicationDelivery override convention, the field
is only touched when present, so an ordinary re-register keeps the prior
value. Regenerate the OpenAPI contract.
---
docs/api/openapi.yaml | 10 +++++
src/app/api/devices/__tests__/route.test.ts | 46 +++++++++++++++++++++
src/app/api/devices/route.ts | 19 +++++++++
src/lib/openapi/routes/devices.ts | 10 +++++
4 files changed, 85 insertions(+)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index f06755f3..abfd1c6c 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -5874,6 +5874,16 @@ components:
- server
- client
- type: "null"
+ liveActivityPushToken:
+ description: v1.17.1 (#22) hex-encoded ActivityKit Live Activity push token (distinct from the device APNs token). The
+ server addresses a Live Activity update / end push on a medication-intake mutation. Omitted = keep the prior
+ value; null = clear it when the Activity ends.
+ anyOf:
+ - type: string
+ minLength: 8
+ maxLength: 256
+ pattern: ^[A-Fa-f0-9]+$
+ - type: "null"
required:
- token
- bundleId
diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts
index 5b76b585..89ca90a1 100644
--- a/src/app/api/devices/__tests__/route.test.ts
+++ b/src/app/api/devices/__tests__/route.test.ts
@@ -250,6 +250,52 @@ describe("POST /api/devices", () => {
);
expect(res.status).toBe(201);
});
+
+ // v1.17.1 (#22) — Live Activity push-token registration.
+ it("persists liveActivityPushToken on create when supplied", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await POST(
+ req({
+ token: "abcd1234efgh5678",
+ bundleId: "io.healthlog.app",
+ liveActivityPushToken: "cafebabe".repeat(8),
+ }),
+ );
+ expect(res.status).toBe(201);
+ const data = vi.mocked(prisma.device.create).mock.calls[0][0].data;
+ expect(data.liveActivityPushToken).toBe("cafebabe".repeat(8));
+ });
+
+ it("clears liveActivityPushToken on re-register when null is sent", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.device.findUnique).mockResolvedValue({
+ id: "dev-1",
+ userId: "user-1",
+ token: "abcd1234efgh5678",
+ } as never);
+ const res = await POST(
+ req({
+ token: "abcd1234efgh5678",
+ bundleId: "io.healthlog.app",
+ liveActivityPushToken: null,
+ }),
+ );
+ expect(res.status).toBe(201);
+ const data = vi.mocked(prisma.device.update).mock.calls[0][0].data;
+ expect(data.liveActivityPushToken).toBeNull();
+ });
+
+ it("rejects a non-hex liveActivityPushToken with 422", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await POST(
+ req({
+ token: "abcd1234efgh5678",
+ bundleId: "io.healthlog.app",
+ liveActivityPushToken: "not-hex-!!!",
+ }),
+ );
+ expect(res.status).toBe(422);
+ });
});
describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => {
diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts
index 98dc17e3..caeca15f 100644
--- a/src/app/api/devices/route.ts
+++ b/src/app/api/devices/route.ts
@@ -63,6 +63,19 @@ const deviceSchema = z
// server APNs for this device; "client" forces local. Stored +
// echoed; cron suppression stays user-level.
medicationDelivery: z.enum(["server", "client"]).nullable().optional(),
+ // v1.17.1 (#22) — ActivityKit issues a per-Activity push token distinct
+ // from the device APNs token. The iOS client registers the current one
+ // here so the server can address a Live Activity update / end push on a
+ // medication-intake mutation; it sends `null` to clear the token when no
+ // Activity is running. Hex, like the APNs token. Only touched when the
+ // field is present so an ordinary re-register keeps the prior value.
+ liveActivityPushToken: z
+ .string()
+ .min(8)
+ .max(256)
+ .regex(/^[A-Fa-f0-9]+$/, "liveActivityPushToken must be hex")
+ .nullable()
+ .optional(),
})
.refine(
(v) =>
@@ -146,6 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
apnsToken,
apnsEnvironment,
medicationDelivery,
+ liveActivityPushToken,
} = parsed.data;
// APNs-token collision lookup. Two cases:
@@ -212,6 +226,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.7.0 — only touch the override when the client sends the
// field, so a re-register that omits it keeps the prior value.
...(medicationDelivery !== undefined && { medicationDelivery }),
+ // v1.17.1 (#22) — same omit-keeps-prior rule; `null` explicitly
+ // clears the token when the Activity ends.
+ ...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
lastSeen: new Date(),
},
select: { id: true },
@@ -231,6 +248,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — per-device delivery override (null = inherit default).
...(medicationDelivery !== undefined && { medicationDelivery }),
+ // v1.17.1 (#22) — Live Activity push token (null = no Activity).
+ ...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
},
select: { id: true },
});
diff --git a/src/lib/openapi/routes/devices.ts b/src/lib/openapi/routes/devices.ts
index f88f7f9e..361b31b5 100644
--- a/src/lib/openapi/routes/devices.ts
+++ b/src/lib/openapi/routes/devices.ts
@@ -43,6 +43,16 @@ const deviceRegisterRequest = z
.describe(
'v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays user-level.',
),
+ liveActivityPushToken: z
+ .string()
+ .min(8)
+ .max(256)
+ .regex(/^[A-Fa-f0-9]+$/)
+ .nullable()
+ .optional()
+ .describe(
+ "v1.17.1 (#22) hex-encoded ActivityKit Live Activity push token (distinct from the device APNs token). The server addresses a Live Activity update / end push on a medication-intake mutation. Omitted = keep the prior value; null = clear it when the Activity ends.",
+ ),
})
.meta({
id: "DeviceRegisterRequest",
From c90459a7bd3c48876afe9279c7eb3de4d985ba0b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:04:13 +0200
Subject: [PATCH 32/79] refactor(nav): drive desktop and mobile from one
destination model
The desktop sidebar and the mobile bottom-nav each hand-curated their
own destination list, and the two had drifted: the sidebar listed
Measurements and Mood inline but hid Workouts; the bottom bar buried
Measurements and Mood in More but promoted Workouts. A user who learned
the product on one platform re-learned it on the other.
Extract one ordered destination model both bars consume. The sidebar
renders every entry in order; the bottom bar keeps its 5-slot shape and
derives its More hub from the same list, so the two surfaces tell one
story. Workouts is now first-class on both. A shared active-resolver
prefers the most-specific sibling so a sub-route no longer lights up its
parent.
---
.../layout/__tests__/nav-model.test.ts | 75 ++++++++++
.../layout/__tests__/sidebar-nav.test.tsx | 22 +++
src/components/layout/bottom-nav.tsx | 98 ++++++-------
src/components/layout/nav-model.ts | 133 ++++++++++++++++++
src/components/layout/sidebar-nav.tsx | 88 +++---------
5 files changed, 290 insertions(+), 126 deletions(-)
create mode 100644 src/components/layout/__tests__/nav-model.test.ts
create mode 100644 src/components/layout/nav-model.ts
diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts
new file mode 100644
index 00000000..b522a43b
--- /dev/null
+++ b/src/components/layout/__tests__/nav-model.test.ts
@@ -0,0 +1,75 @@
+/**
+ * v1.17.1 (F-1 / F-3) — the one navigation information-model.
+ *
+ * Pins the coherence contract the desktop sidebar and the mobile
+ * bottom-nav both consume: one ordered destination list (so the two bars
+ * tell one story), Workouts AND Coach as first-class destinations on both
+ * surfaces, cycle gated by the account flag, and an active-resolver that
+ * prefers the most-specific sibling under `/insights`.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ NAV_DESTINATIONS,
+ isNavDestinationActive,
+ visibleNavDestinations,
+} from "../nav-model";
+
+describe("nav-model destination list", () => {
+ it("lists Workouts as a first-class destination on both surfaces", () => {
+ const hrefs = NAV_DESTINATIONS.map((d) => d.href);
+ expect(hrefs).toContain("/insights/workouts");
+ });
+
+ it("orders the core spine: dashboard → measurements → mood → medications", () => {
+ const hrefs = NAV_DESTINATIONS.map((d) => d.href);
+ expect(hrefs.indexOf("/")).toBeLessThan(hrefs.indexOf("/measurements"));
+ expect(hrefs.indexOf("/measurements")).toBeLessThan(
+ hrefs.indexOf("/mood"),
+ );
+ expect(hrefs.indexOf("/mood")).toBeLessThan(
+ hrefs.indexOf("/medications"),
+ );
+ });
+
+ it("every destination carries an i18n key under the nav namespace", () => {
+ for (const d of NAV_DESTINATIONS) {
+ expect(d.tKey.startsWith("nav.")).toBe(true);
+ }
+ });
+
+});
+
+describe("visibleNavDestinations cycle gate", () => {
+ it("includes Cycle (between Medications and Insights) only when enabled", () => {
+ const off = visibleNavDestinations(false).map((d) => d.href);
+ const on = visibleNavDestinations(true).map((d) => d.href);
+ expect(off).not.toContain("/cycle");
+ expect(on).toContain("/cycle");
+ expect(on.indexOf("/medications")).toBeLessThan(on.indexOf("/cycle"));
+ expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights"));
+ });
+});
+
+describe("isNavDestinationActive most-specific resolution", () => {
+ it("matches the dashboard only on an exact path", () => {
+ expect(isNavDestinationActive("/", "/")).toBe(true);
+ expect(isNavDestinationActive("/", "/measurements")).toBe(false);
+ });
+
+ it("does not light up Insights when on its Workouts sibling", () => {
+ expect(
+ isNavDestinationActive("/insights/workouts", "/insights/workouts"),
+ ).toBe(true);
+ expect(isNavDestinationActive("/insights", "/insights/workouts")).toBe(
+ false,
+ );
+ });
+
+ it("still lights up Insights on its own sub-routes", () => {
+ expect(isNavDestinationActive("/insights", "/insights")).toBe(true);
+ expect(isNavDestinationActive("/insights", "/insights/values/WEIGHT")).toBe(
+ true,
+ );
+ });
+});
diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx
index 5d070b66..a0db7aa8 100644
--- a/src/components/layout/__tests__/sidebar-nav.test.tsx
+++ b/src/components/layout/__tests__/sidebar-nav.test.tsx
@@ -131,6 +131,28 @@ describe(" targets deprecation (v1.8.6)", () => {
});
});
+describe(" unified destination model (v1.17.1 F-1)", () => {
+ it("surfaces Workouts as a first-class sidebar destination", () => {
+ // Pre-unify the sidebar hid Workouts entirely while the mobile bar
+ // promoted it. Both now render the one shared model, so both carry it.
+ const html = render();
+ expect(html).toContain('href="/insights/workouts"');
+ expect(html).toContain("Workouts");
+ });
+
+ it("marks Workouts active without also marking Insights active", () => {
+ const html = render({ pathname: "/insights/workouts" });
+ // The Workouts link carries aria-current="page"; the Insights link,
+ // its less-specific sibling, must not (most-specific resolution).
+ const workouts = html.match(
+ /]*href="\/insights\/workouts"[^>]*>/,
+ );
+ const insights = html.match(/]*href="\/insights"[^>]*>/);
+ expect(workouts?.[0]).toMatch(/aria-current="page"/);
+ expect(insights?.[0]).not.toMatch(/aria-current="page"/);
+ });
+});
+
describe(" admin entry mirrors Settings (no sub-item expansion)", () => {
// v1.4.16 A1: the maintainer reported the global sidebar expanding admin
// sub-items on `/admin/*` was unwanted UX — the in-shell ``
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx
index 3dd18a6e..cb0e510a 100644
--- a/src/components/layout/bottom-nav.tsx
+++ b/src/components/layout/bottom-nav.tsx
@@ -1,26 +1,25 @@
"use client";
import {
- Activity,
Bell,
Bug,
- Droplets,
- Dumbbell,
- FlaskConical,
Home,
Lightbulb,
MoreHorizontal,
Pill,
Plus,
Settings,
- Trophy,
- Waves,
+ type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
-import { useState } from "react";
+import {
+ isNavDestinationActive,
+ visibleNavDestinations,
+} from "@/components/layout/nav-model";
+import { useMemo, useState } from "react";
import { useAppSettings } from "@/components/app-settings-provider";
import { useAuth } from "@/hooks/use-auth";
import {
@@ -37,7 +36,7 @@ import { useTranslations } from "@/lib/i18n/context";
interface NavLink {
href: string;
tKey: string;
- icon: typeof Home;
+ icon: LucideIcon;
}
// v1.12.x — iOS-parity bottom bar (additive middle-path).
@@ -48,12 +47,14 @@ interface NavLink {
// the existing measurement / medication / mood quick-entry surfaces
// (see ``).
//
-// PRIMARY_LEFT / PRIMARY_RIGHT are the two anchor pairs that flank the
-// center action. The previous strip (Home · Measurements · Mood ·
-// Medications · Insights) carried Measurements and Mood inline; those
-// stay fully reachable — through the center capture picker AND the More
-// hub below — so nothing is orphaned. On desktop the sidebar still
-// lists every destination.
+// v1.17.1 (F-1) — the two always-visible flanking anchors and the "More"
+// hub are now BOTH derived from the one shared destination model
+// (`nav-model.ts`), the same ordered list the desktop sidebar renders.
+// The bar keeps its ergonomic 5-slot shape, but every destination that
+// isn't a primary slot falls into the hub in the model's order — so the
+// two surfaces tell one story instead of two hand-curated ones that drift.
+const PRIMARY_SLOT_HREFS = ["/", "/medications", "/insights"] as const;
+
const PRIMARY_LEFT: ReadonlyArray = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
@@ -63,33 +64,23 @@ const PRIMARY_RIGHT: ReadonlyArray = [
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
];
-// The "More" hub — a real hub of the remaining top-level destinations,
-// not just an overflow bucket. Measurements and Mood live here (they
-// left the always-visible strip when the center capture action took the
-// middle slot), alongside Workouts, Achievements, Notifications and
-// Settings. Every entry is an existing top-level route.
-const MORE_HUB: ReadonlyArray = [
- { href: "/measurements", tKey: "nav.measurements", icon: Activity },
- { href: "/mood", tKey: "nav.mood", icon: Waves },
- { href: "/labs", tKey: "nav.labs", icon: FlaskConical },
- { href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell },
- { href: "/achievements", tKey: "nav.achievements", icon: Trophy },
- { href: "/notifications", tKey: "nav.notifications", icon: Bell },
- { href: "/settings/account", tKey: "nav.settings", icon: Settings },
-];
-
-// v1.15.0 — the cycle entry, spliced into the More hub only when the
-// account's `cycleTrackingEnabled` gate is true. Hidden for everyone else.
-const CYCLE_HUB_ITEM: NavLink = {
- href: "/cycle",
- tKey: "nav.cycle",
- icon: Droplets,
+// Mobile-only hub conveniences that have no main-list home on desktop
+// (the sidebar reaches them through its footer + avatar menu). They sit
+// at the tail of the hub, after every shared destination.
+const NOTIFICATIONS_HUB_ITEM: NavLink = {
+ href: "/notifications",
+ tKey: "nav.notifications",
+ icon: Bell,
+};
+const SETTINGS_HUB_ITEM: NavLink = {
+ href: "/settings/account",
+ tKey: "nav.settings",
+ icon: Settings,
};
-// The bug-report entry, spliced into the More hub under the same
-// `bugReportEnabled` operator flag that gates the desktop sidebar
-// entry. Pre-splice the route was desktop-only — a phone user had no
-// path to `/bugreport` at all.
+// The bug-report entry, appended under the same `bugReportEnabled`
+// operator flag that gates the desktop sidebar entry. Pre-splice the
+// route was desktop-only — a phone user had no path to `/bugreport`.
const BUGREPORT_HUB_ITEM: NavLink = {
href: "/bugreport",
tKey: "nav.bugreport",
@@ -108,23 +99,22 @@ export function BottomNav() {
const [moreOpen, setMoreOpen] = useState(false);
const [captureOpen, setCaptureOpen] = useState(false);
- // v1.15.0 — splice the cycle entry into the More hub after Mood when the
- // account's gate is on, so it never appears for accounts without it.
- const cycleAwareHub: ReadonlyArray = user?.cycleTrackingEnabled
- ? [MORE_HUB[0], MORE_HUB[1], CYCLE_HUB_ITEM, ...MORE_HUB.slice(2)]
- : MORE_HUB;
- // Bug report sits directly before Settings (the hub's last entry)
- // when the operator flag is on — same gate as the desktop sidebar.
- const moreHub: ReadonlyArray = bugReportEnabled
- ? [
- ...cycleAwareHub.slice(0, -1),
- BUGREPORT_HUB_ITEM,
- cycleAwareHub[cycleAwareHub.length - 1],
- ]
- : cycleAwareHub;
+ // v1.17.1 (F-1) — the More hub is every shared destination that isn't an
+ // always-visible primary slot, in the model's order, plus the mobile-only
+ // Notifications / Settings conveniences (and Bug Report behind its flag).
+ // Cycle is filtered by the same account gate the sidebar uses, so the two
+ // surfaces gate it identically.
+ const moreHub = useMemo>(() => {
+ const shared = visibleNavDestinations(user?.cycleTrackingEnabled)
+ .filter((d) => !PRIMARY_SLOT_HREFS.includes(d.href as never))
+ .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
+ const tail: NavLink[] = [NOTIFICATIONS_HUB_ITEM, SETTINGS_HUB_ITEM];
+ if (bugReportEnabled) tail.unshift(BUGREPORT_HUB_ITEM);
+ return [...shared, ...tail];
+ }, [user?.cycleTrackingEnabled, bugReportEnabled]);
function isActiveLink(href: string) {
- return href === "/" ? pathname === "/" : pathname.startsWith(href);
+ return isNavDestinationActive(href, pathname);
}
// The "More" entry is treated as active when any of its children
diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts
new file mode 100644
index 00000000..da7109d5
--- /dev/null
+++ b/src/components/layout/nav-model.ts
@@ -0,0 +1,133 @@
+import {
+ Activity,
+ Droplets,
+ Dumbbell,
+ FlaskConical,
+ Home,
+ Lightbulb,
+ Pill,
+ Trophy,
+ Waves,
+ type LucideIcon,
+} from "lucide-react";
+
+/**
+ * v1.17.1 — the single navigation information-model.
+ *
+ * Before this module the desktop sidebar and the mobile bottom-nav each
+ * hand-curated their own destination list, and the two had drifted: the
+ * sidebar listed Measurements / Mood inline but hid Workouts; the bottom
+ * bar buried Measurements / Mood in "More" but promoted Workouts — and
+ * the Coach, the stated differentiator, had no nav home on either. A user
+ * who learned the product on one platform re-learned it on the other.
+ *
+ * This list is the ONE ordered destination model. Both bars render it:
+ * the sidebar shows every entry in order; the bottom bar keeps its 5-slot
+ * ergonomic shape (Home · Meds · capture · Insights · More) and derives
+ * its "More" hub from the SAME list (every destination not already a
+ * primary slot), so the two bars are re-skins of one story rather than
+ * two curated lists that drift.
+ */
+export interface NavDestination {
+ href: string;
+ /** i18n key under the `nav.*` namespace. */
+ tKey: string;
+ icon: LucideIcon;
+ /**
+ * Stable onboarding-tour anchor. Matches `data-tour-id` lookups in the
+ * spotlight tour — renaming silently breaks the cutout for that step.
+ */
+ tourId?: string;
+ /** Gate the entry on the account's `cycleTrackingEnabled` flag. */
+ requiresCycle?: boolean;
+}
+
+/**
+ * The canonical ordered destination list. Cycle sits where it always has
+ * (after Medications) and is filtered out when the account gate is off;
+ * the order is otherwise identical for both surfaces.
+ */
+export const NAV_DESTINATIONS: ReadonlyArray = [
+ { href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" },
+ {
+ href: "/measurements",
+ tKey: "nav.measurements",
+ icon: Activity,
+ tourId: "nav-measurements",
+ },
+ { href: "/mood", tKey: "nav.mood", icon: Waves, tourId: "nav-mood" },
+ {
+ href: "/medications",
+ tKey: "nav.medications",
+ icon: Pill,
+ tourId: "nav-medications",
+ },
+ {
+ href: "/cycle",
+ tKey: "nav.cycle",
+ icon: Droplets,
+ tourId: "nav-cycle",
+ requiresCycle: true,
+ },
+ {
+ href: "/labs",
+ tKey: "nav.labs",
+ icon: FlaskConical,
+ tourId: "nav-labs",
+ },
+ {
+ href: "/insights/workouts",
+ tKey: "nav.workouts",
+ icon: Dumbbell,
+ tourId: "nav-workouts",
+ },
+ {
+ href: "/insights",
+ tKey: "nav.insights",
+ icon: Lightbulb,
+ tourId: "nav-insights",
+ },
+ {
+ href: "/achievements",
+ tKey: "nav.achievements",
+ icon: Trophy,
+ tourId: "nav-achievements",
+ },
+];
+
+/**
+ * The ordered destinations visible to this account — drops cycle-gated
+ * entries when the flag is off. Both bars start from this.
+ */
+export function visibleNavDestinations(
+ cycleTrackingEnabled: boolean | undefined,
+): NavDestination[] {
+ return NAV_DESTINATIONS.filter(
+ (d) => !d.requiresCycle || cycleTrackingEnabled === true,
+ );
+}
+
+/**
+ * Whether `href` is the active nav destination for the current `pathname`,
+ * resolved against the full destination set so the most-specific entry
+ * wins. Without this, a plain `startsWith("/insights")` would light up
+ * Insights while the user is on its siblings `/insights/workouts` or
+ * `/insights/coach` — both of which are now their own top-level nav homes.
+ * The dashboard (`/`) only matches an exact path.
+ */
+export function isNavDestinationActive(
+ href: string,
+ pathname: string,
+ destinations: ReadonlyArray = NAV_DESTINATIONS,
+): boolean {
+ if (href === "/") return pathname === "/";
+ const matches = (candidate: string) =>
+ pathname === candidate || pathname.startsWith(`${candidate}/`);
+ if (!matches(href)) return false;
+ // A longer sibling that also matches is the more specific home — defer
+ // to it (e.g. on `/insights/coach`, `/insights` must NOT read active).
+ const moreSpecific = destinations.some(
+ (d) => d.href !== href && d.href.startsWith(`${href}/`) && matches(d.href),
+ );
+ return !moreSpecific;
+}
diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx
index 18ba6e77..c3f1d3bc 100644
--- a/src/components/layout/sidebar-nav.tsx
+++ b/src/components/layout/sidebar-nav.tsx
@@ -2,30 +2,26 @@
import { useMemo, useState } from "react";
import {
- Activity,
Bell,
Bug,
ChevronsLeft,
ChevronsRight,
- Droplets,
- FlaskConical,
- Home,
- Lightbulb,
LogOut,
Monitor,
Moon,
MoreVertical,
- Pill,
Settings,
Shield,
Sun,
- Trophy,
- Waves,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
+import {
+ isNavDestinationActive,
+ visibleNavDestinations,
+} from "@/components/layout/nav-model";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
@@ -52,51 +48,6 @@ import {
const STORAGE_KEY = "healthlog-sidebar-collapsed";
-const navItems = [
- { href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" },
- {
- href: "/measurements",
- tKey: "nav.measurements",
- icon: Activity,
- tourId: "nav-measurements",
- },
- { href: "/mood", tKey: "nav.mood", icon: Waves, tourId: "nav-mood" },
- {
- href: "/medications",
- tKey: "nav.medications",
- icon: Pill,
- tourId: "nav-medications",
- },
- // v1.17.1 — structured lab-result store. Pairs with the Vorsorge
- // annual-blood-panel reminder (which records its result here).
- { href: "/labs", tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs" },
- // v1.4.15 Phase B5: `tourId` values match `data-tour-id` lookups
- // performed by the onboarding tour. Keep these stable — renaming
- // them silently breaks the spotlight cutout for that step.
- {
- href: "/insights",
- tKey: "nav.insights",
- icon: Lightbulb,
- tourId: "nav-insights",
- },
- {
- href: "/achievements",
- tKey: "nav.achievements",
- icon: Trophy,
- tourId: "nav-achievements",
- },
-];
-
-// v1.15.0 — the cycle nav entry, appended to the main list only when the
-// account's `cycleTrackingEnabled` gate is true (resolved on `/api/auth/me`).
-// Hidden by construction for accounts without the feature.
-const cycleNavItem = {
- href: "/cycle",
- tKey: "nav.cycle",
- icon: Droplets,
- tourId: "nav-cycle",
-} as const;
-
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
@@ -287,21 +238,13 @@ export function SidebarNav() {
// with no sub-item expansion in the global sidebar — ``
// renders its own per-section nav inside the page itself.
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
- // v1.15.5 — surface the cycle entry directly after Medications (before
- // Insights) when the gate resolves true, instead of tacking it on at the
- // end. Splice by the Medications index so the position survives a reorder
- // of the static list; fall back to append if the anchor ever moves.
- const visibleNavItems = useMemo(() => {
- if (!user?.cycleTrackingEnabled) return navItems;
- const afterMedications =
- navItems.findIndex((item) => item.href === "/medications") + 1;
- if (afterMedications <= 0) return [...navItems, cycleNavItem];
- return [
- ...navItems.slice(0, afterMedications),
- cycleNavItem,
- ...navItems.slice(afterMedications),
- ];
- }, [user?.cycleTrackingEnabled]);
+ // v1.17.1 (F-1) — the sidebar renders the one shared destination model
+ // (`nav-model.ts`), the same ordered list the mobile bottom-nav derives
+ // its "More" hub from. Cycle is filtered in/out by the account gate.
+ const visibleNavItems = useMemo(
+ () => visibleNavDestinations(user?.cycleTrackingEnabled),
+ [user?.cycleTrackingEnabled],
+ );
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
try {
@@ -404,10 +347,11 @@ export function SidebarNav() {
)}
+
+ >
);
}
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx
index e7172f7c..60056910 100644
--- a/src/components/settings/notifications-section.tsx
+++ b/src/components/settings/notifications-section.tsx
@@ -14,6 +14,8 @@ import { NotificationStatusCard } from "@/components/settings/notification-statu
import { NtfyCard } from "@/components/settings/ntfy-card";
import { TelegramCard } from "@/components/settings/telegram-card";
import { WebPushCard } from "@/components/settings/web-push-card";
+import { WebhookCard } from "@/components/settings/webhook-card";
+import { EmailCard } from "@/components/settings/email-card";
import { apiGet } from "@/lib/api/api-fetch";
interface GlobalServiceAvailability {
@@ -105,6 +107,16 @@ export function NotificationsSection() {
)}
+ {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
+ Matrix / Home Assistant in one channel). */}
+
+
+
+ {/* v1.17.1 — SMTP / email. The card hides itself when the operator
+ hasn't configured SMTP_* env, so it never shows a dead toggle. */}
+
+
+
diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts
index 0403206e..5f604108 100644
--- a/src/lib/query-keys/admin.ts
+++ b/src/lib/query-keys/admin.ts
@@ -63,6 +63,9 @@ export const adminKeys = {
adminSettings: () => ["admin", "settings"] as const,
adminStatus: () => ["admin", "status"] as const,
+ /** v1.17.1 — operator-wide notification delivery-health panel. */
+ adminNotificationHealth: (hours: number) =>
+ ["admin", "notification-health", hours] as const,
adminUsers: () => ["admin", "users"] as const,
adminTokens: () => ["admin", "tokens"] as const,
adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const,
From c04bdc8f4e06a8012cf921b72436e4e8de150dbc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:46:28 +0200
Subject: [PATCH 51/79] feat(notifications): document SMTP env and translate
channel strings
Add the webhook + email Settings copy and the admin notification-health
panel labels across all six locales. Add the SMTP_* operator vars to the
env manifest (all-or-none: HOST + PORT + FROM enable the channel,
USER/PASS optional), to .env.production.example, and to the docker-compose
environment whitelist so an operator's .env values actually reach the
container.
---
.env.production.example | 18 ++++++++++++++++++
docker-compose.yml | 12 ++++++++++++
messages/de.json | 20 ++++++++++++++++++++
messages/en.json | 20 ++++++++++++++++++++
messages/es.json | 20 ++++++++++++++++++++
messages/fr.json | 20 ++++++++++++++++++++
messages/it.json | 20 ++++++++++++++++++++
messages/pl.json | 20 ++++++++++++++++++++
scripts/env-manifest.json | 20 ++++++++++++++++++++
9 files changed, 170 insertions(+)
diff --git a/.env.production.example b/.env.production.example
index d53e7acd..258db482 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -84,6 +84,24 @@ API_TOKEN_HMAC_KEY=""
# APNS_KEY_FILE=""
+# -----------------------------------------------------------------------------
+# SMTP -- email notification channel (optional, all-or-none)
+# -----------------------------------------------------------------------------
+# Operator SMTP transport for the email channel. SMTP_HOST + SMTP_PORT +
+# SMTP_FROM together enable it; SMTP_USER/SMTP_PASS are optional (omit for an
+# unauthenticated relay). SMTP_SECURE=true uses implicit TLS (port 465);
+# unset/false uses STARTTLS (port 587). Per-user recipient address is set in
+# Settings -> Notifications, not here. Missing the core trio disables the
+# channel silently. Listed in docker-compose.yml's environment whitelist so
+# these reach the container.
+# SMTP_HOST=""
+# SMTP_PORT="587"
+# SMTP_FROM="HealthLog "
+# SMTP_USER=""
+# SMTP_PASS=""
+# SMTP_SECURE="false"
+
+
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
diff --git a/docker-compose.yml b/docker-compose.yml
index 57e84f8b..1c6b5798 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -107,6 +107,18 @@ services:
# container (the `environment:` block is a whitelist). See
# docs/ops/tls-cert-pin.md.
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
+ # v1.17.1 — SMTP transport for the email notification channel (optional,
+ # all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
+ # toggles implicit TLS). Listed here so operator values in .env reach the
+ # container (the `environment:` block is a whitelist — vars not listed
+ # never propagate from .env, even with `${VAR}` substitution at
+ # compose-up). Per-user recipient is stored in the DB, not here.
+ SMTP_HOST: "${SMTP_HOST:-}"
+ SMTP_PORT: "${SMTP_PORT:-}"
+ SMTP_FROM: "${SMTP_FROM:-}"
+ SMTP_USER: "${SMTP_USER:-}"
+ SMTP_PASS: "${SMTP_PASS:-}"
+ SMTP_SECURE: "${SMTP_SECURE:-}"
depends_on:
db:
condition: service_healthy
diff --git a/messages/de.json b/messages/de.json
index 504fbedc..57f127fa 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4133,6 +4133,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Auth-Token (optional)",
"ntfyAuthTokenHint": "Nur nötig bei privaten Topics mit Zugangsschutz.",
+ "webhook": "Webhook",
+ "webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.",
+ "webhookEnable": "Webhook aktivieren",
+ "webhookUrl": "Webhook-URL",
+ "webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
+ "webhookHeaderName": "Header-Name (optional)",
+ "webhookHeaderValue": "Header-Wert (optional)",
+ "email": "E-Mail",
+ "emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.",
+ "emailEnable": "E-Mail aktivieren",
+ "emailRecipient": "Empfängeradresse",
"webPush": "Browser-Push",
"webPushDescription": "Erhalte Benachrichtigungen direkt im Browser, auch wenn HealthLog nicht geöffnet ist.",
"webPushNotSupported": "Dein Browser unterstützt keine Push-Benachrichtigungen.",
@@ -5011,6 +5022,15 @@
"authTokenRevoke": "Token widerrufen",
"medicationReminders": "Medikamentenerinnerungen",
"medicationRemindersDescription": "Schwellenwerte für die Farbübergänge der Erinnerungs-Badges auf den Medikamentenkarten.",
+ "notificationHealth": {
+ "title": "Zustellungsstatus",
+ "description": "Zustellungsergebnisse aller Nutzer der letzten 24 Stunden.",
+ "empty": "In diesem Zeitraum wurden keine Benachrichtigungen gesendet.",
+ "ok": "OK",
+ "error": "Fehlgeschlagen",
+ "skipped": "Übersprungen",
+ "autoDisabled": "Automatisch deaktivierte Kanäle"
+ },
"reminderLateMinutes": "Spät-Schwellenwert (Minuten)",
"reminderLateMinutesDescription": "Minuten nach Fenster-Ende bis der Badge von Gelb auf Orange wechselt.",
"reminderMissedMinutes": "Verpasst-Schwellenwert (Minuten)",
diff --git a/messages/en.json b/messages/en.json
index 8477957f..cc3e5d04 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4133,6 +4133,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Auth Token (optional)",
"ntfyAuthTokenHint": "Only needed for private topics with access control.",
+ "webhook": "Webhook",
+ "webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.",
+ "webhookEnable": "Enable webhook",
+ "webhookUrl": "Webhook URL",
+ "webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
+ "webhookHeaderName": "Header name (optional)",
+ "webhookHeaderValue": "Header value (optional)",
+ "email": "Email",
+ "emailDescription": "Receive alerts and reminders by email.",
+ "emailEnable": "Enable email",
+ "emailRecipient": "Recipient address",
"webPush": "Browser Push",
"webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.",
"webPushNotSupported": "Your browser does not support push notifications.",
@@ -5011,6 +5022,15 @@
"authTokenRevoke": "Token revoked",
"medicationReminders": "Medication Reminders",
"medicationRemindersDescription": "Thresholds for reminder badge color transitions on medication cards.",
+ "notificationHealth": {
+ "title": "Notification health",
+ "description": "Delivery results across all users over the last 24 hours.",
+ "empty": "No notifications sent in this window.",
+ "ok": "OK",
+ "error": "Failed",
+ "skipped": "Skipped",
+ "autoDisabled": "Auto-disabled channels"
+ },
"reminderLateMinutes": "Late threshold (minutes)",
"reminderLateMinutesDescription": "Minutes after window end until the badge changes from yellow to orange.",
"reminderMissedMinutes": "Missed threshold (minutes)",
diff --git a/messages/es.json b/messages/es.json
index 4ac12939..aa0316c0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4133,6 +4133,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Token de autenticación (opcional)",
"ntfyAuthTokenHint": "Solo necesario para temas privados con control de acceso.",
+ "webhook": "Webhook",
+ "webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.",
+ "webhookEnable": "Activar webhook",
+ "webhookUrl": "URL del webhook",
+ "webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
+ "webhookHeaderName": "Nombre de cabecera (opcional)",
+ "webhookHeaderValue": "Valor de cabecera (opcional)",
+ "email": "Correo electrónico",
+ "emailDescription": "Recibe alertas y recordatorios por correo electrónico.",
+ "emailEnable": "Activar correo electrónico",
+ "emailRecipient": "Dirección del destinatario",
"webPush": "Push del navegador",
"webPushDescription": "Recibe notificaciones directamente en tu navegador, incluso cuando HealthLog no está abierto.",
"webPushNotSupported": "Tu navegador no admite notificaciones push.",
@@ -5011,6 +5022,15 @@
"authTokenRevoke": "Token revocado",
"medicationReminders": "Recordatorios de medicación",
"medicationRemindersDescription": "Umbrales para los cambios de color de las insignias de recordatorio en las tarjetas de medicación.",
+ "notificationHealth": {
+ "title": "Estado de entrega",
+ "description": "Resultados de entrega de todos los usuarios en las últimas 24 horas.",
+ "empty": "No se enviaron notificaciones en este periodo.",
+ "ok": "OK",
+ "error": "Fallidas",
+ "skipped": "Omitidas",
+ "autoDisabled": "Canales desactivados automáticamente"
+ },
"reminderLateMinutes": "Umbral de retraso (minutos)",
"reminderLateMinutesDescription": "Minutos tras el fin de la ventana hasta que la insignia cambia de amarillo a naranja.",
"reminderMissedMinutes": "Umbral de omisión (minutos)",
diff --git a/messages/fr.json b/messages/fr.json
index a5d6fc97..9182df18 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4133,6 +4133,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Jeton d’authentification (facultatif)",
"ntfyAuthTokenHint": "Nécessaire uniquement pour les sujets privés avec contrôle d’accès.",
+ "webhook": "Webhook",
+ "webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.",
+ "webhookEnable": "Activer le webhook",
+ "webhookUrl": "URL du webhook",
+ "webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
+ "webhookHeaderName": "Nom d’en-tête (facultatif)",
+ "webhookHeaderValue": "Valeur d’en-tête (facultatif)",
+ "email": "E-mail",
+ "emailDescription": "Recevoir les alertes et les rappels par e-mail.",
+ "emailEnable": "Activer l’e-mail",
+ "emailRecipient": "Adresse du destinataire",
"webPush": "Push navigateur",
"webPushDescription": "Recevez des notifications directement dans votre navigateur, même lorsque HealthLog n’est pas ouvert.",
"webPushNotSupported": "Votre navigateur ne prend pas en charge les notifications push.",
@@ -5011,6 +5022,15 @@
"authTokenRevoke": "Jeton révoqué",
"medicationReminders": "Rappels de médicaments",
"medicationRemindersDescription": "Seuils des transitions de couleur des badges de rappel sur les fiches de médicaments.",
+ "notificationHealth": {
+ "title": "État de distribution",
+ "description": "Résultats de distribution de tous les utilisateurs sur les dernières 24 heures.",
+ "empty": "Aucune notification envoyée sur cette période.",
+ "ok": "OK",
+ "error": "Échecs",
+ "skipped": "Ignorées",
+ "autoDisabled": "Canaux désactivés automatiquement"
+ },
"reminderLateMinutes": "Seuil de retard (minutes)",
"reminderLateMinutesDescription": "Minutes après la fin de la fenêtre avant que le badge passe du jaune à l'orange.",
"reminderMissedMinutes": "Seuil d'oubli (minutes)",
diff --git a/messages/it.json b/messages/it.json
index 15cc4ed2..7e3d685c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4133,6 +4133,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Token di autenticazione (facoltativo)",
"ntfyAuthTokenHint": "Necessario solo per topic privati con controllo degli accessi.",
+ "webhook": "Webhook",
+ "webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.",
+ "webhookEnable": "Attiva webhook",
+ "webhookUrl": "URL del webhook",
+ "webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
+ "webhookHeaderName": "Nome intestazione (opzionale)",
+ "webhookHeaderValue": "Valore intestazione (opzionale)",
+ "email": "Email",
+ "emailDescription": "Ricevi avvisi e promemoria via email.",
+ "emailEnable": "Attiva email",
+ "emailRecipient": "Indirizzo del destinatario",
"webPush": "Push del browser",
"webPushDescription": "Ricevi notifiche direttamente nel browser, anche quando HealthLog non è aperto.",
"webPushNotSupported": "Il tuo browser non supporta le notifiche push.",
@@ -5011,6 +5022,15 @@
"authTokenRevoke": "Token revocato",
"medicationReminders": "Promemoria dei farmaci",
"medicationRemindersDescription": "Soglie per i cambi di colore dei badge di promemoria sulle schede dei farmaci.",
+ "notificationHealth": {
+ "title": "Stato di consegna",
+ "description": "Risultati di consegna di tutti gli utenti nelle ultime 24 ore.",
+ "empty": "Nessuna notifica inviata in questo periodo.",
+ "ok": "OK",
+ "error": "Non riuscite",
+ "skipped": "Saltate",
+ "autoDisabled": "Canali disattivati automaticamente"
+ },
"reminderLateMinutes": "Soglia di ritardo (minuti)",
"reminderLateMinutesDescription": "Minuti dopo la fine della finestra prima che il badge passi da giallo ad arancione.",
"reminderMissedMinutes": "Soglia di mancata assunzione (minuti)",
diff --git a/messages/pl.json b/messages/pl.json
index 5b865a42..72e76204 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4133,6 +4133,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Token uwierzytelniania (opcjonalny)",
"ntfyAuthTokenHint": "Wymagany tylko dla prywatnych tematów z kontrolą dostępu.",
+ "webhook": "Webhook",
+ "webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.",
+ "webhookEnable": "Włącz webhook",
+ "webhookUrl": "Adres URL webhooka",
+ "webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
+ "webhookHeaderName": "Nazwa nagłówka (opcjonalnie)",
+ "webhookHeaderValue": "Wartość nagłówka (opcjonalnie)",
+ "email": "E-mail",
+ "emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.",
+ "emailEnable": "Włącz e-mail",
+ "emailRecipient": "Adres odbiorcy",
"webPush": "Push przeglądarki",
"webPushDescription": "Otrzymuj powiadomienia bezpośrednio w przeglądarce, nawet gdy HealthLog nie jest otwarty.",
"webPushNotSupported": "Twoja przeglądarka nie obsługuje powiadomień push.",
@@ -5011,6 +5022,15 @@
"authTokenRevoke": "Token unieważniony",
"medicationReminders": "Przypomnienia o lekach",
"medicationRemindersDescription": "Progi zmian koloru oznaczeń przypomnień na kartach leków.",
+ "notificationHealth": {
+ "title": "Stan dostarczania",
+ "description": "Wyniki dostarczania wszystkich użytkowników z ostatnich 24 godzin.",
+ "empty": "W tym okresie nie wysłano żadnych powiadomień.",
+ "ok": "OK",
+ "error": "Nieudane",
+ "skipped": "Pominięte",
+ "autoDisabled": "Automatycznie wyłączone kanały"
+ },
"reminderLateMinutes": "Próg opóźnienia (minuty)",
"reminderLateMinutesDescription": "Minuty po końcu okna, zanim oznaczenie zmieni kolor z żółtego na pomarańczowy.",
"reminderMissedMinutes": "Próg pominięcia (minuty)",
diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json
index 6b3d72f9..51f68af5 100644
--- a/scripts/env-manifest.json
+++ b/scripts/env-manifest.json
@@ -137,6 +137,26 @@
}
]
},
+ {
+ "name": "SMTP (email channel)",
+ "description": "Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently — the channel is never offered and the Settings card hides itself.",
+ "required": false,
+ "allOrNone": true,
+ "variables": [
+ {
+ "name": "SMTP_HOST",
+ "purpose": "SMTP server hostname, e.g. smtp.example.com."
+ },
+ {
+ "name": "SMTP_PORT",
+ "purpose": "SMTP port. 587 for STARTTLS (SMTP_SECURE unset/false), 465 for implicit TLS (SMTP_SECURE=true)."
+ },
+ {
+ "name": "SMTP_FROM",
+ "purpose": "From address for outbound mail, e.g. \"HealthLog \"."
+ }
+ ]
+ },
{
"name": "Off-host backups",
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
From d0ba4b061bf5624668905120f2af3356c15ea50f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:58:30 +0200
Subject: [PATCH 52/79] feat(notifications): generate VAPID keys from the admin
panel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a server-side VAPID keypair generator so a self-hoster no longer has
to run a shell command to enable Web Push. A new admin-only route
(POST /api/admin/settings/web-push-vapid/generate) calls web-push's
generateVAPIDKeys(), stores the private key encrypted at rest, seeds a
placeholder mailto: subject the operator can edit, and returns only the
public key. The route is cookie-only via requireAdmin(); a Bearer token
cannot reach it. The private key is never returned and never logged — the
audit detail carries a marker only.
The admin Web Push VAPID card gains a Generate keys button that fills the
public key and subject fields on success. When a keypair already exists
the route refuses with 409 and the card confirms before retrying with
force, warning that regenerating invalidates existing browser
subscriptions.
---
messages/de.json | 5 +
messages/en.json | 5 +
messages/es.json | 5 +
messages/fr.json | 5 +
messages/it.json | 5 +
messages/pl.json | 5 +
.../generate/__tests__/route.test.ts | 192 ++++++++++++++++++
.../settings/web-push-vapid/generate/route.ts | 132 ++++++++++++
.../admin/web-push-vapid-section.tsx | 70 ++++++-
9 files changed, 423 insertions(+), 1 deletion(-)
create mode 100644 src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts
create mode 100644 src/app/api/admin/settings/web-push-vapid/generate/route.ts
diff --git a/messages/de.json b/messages/de.json
index bb1026aa..8142d82d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Optional: neuen Private Key eintragen",
"webPushVapidSubject": "VAPID Subject",
"webPushVapidSubjectPlaceholder": "mailto:deine-adresse@example.com",
+ "webPushVapidGenerate": "Schlüssel erzeugen",
+ "webPushVapidGenerating": "Wird erzeugt…",
+ "webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.",
+ "webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.",
+ "webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?",
"configured": "Konfiguriert",
"monitoringTestSuccess": "Testevent wurde gesendet",
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
diff --git a/messages/en.json b/messages/en.json
index 63210fdd..1e7058c0 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Optional: enter a new private key",
"webPushVapidSubject": "VAPID subject",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
+ "webPushVapidGenerate": "Generate keys",
+ "webPushVapidGenerating": "Generating…",
+ "webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.",
+ "webPushVapidGenerateFailed": "Could not generate VAPID keys.",
+ "webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?",
"configured": "Configured",
"monitoringTestSuccess": "Test event sent",
"monitoringTestFailed": "Monitoring test failed",
diff --git a/messages/es.json b/messages/es.json
index 2da94f8f..4988d8cd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Opcional: introduce una nueva clave privada",
"webPushVapidSubject": "Subject de VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
+ "webPushVapidGenerate": "Generar claves",
+ "webPushVapidGenerating": "Generando…",
+ "webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.",
+ "webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.",
+ "webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?",
"configured": "Configurado",
"monitoringTestSuccess": "Evento de prueba enviado",
"monitoringTestFailed": "La prueba de monitorización falló",
diff --git a/messages/fr.json b/messages/fr.json
index 21461fbc..c47bf151 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Facultatif : saisissez une nouvelle clé privée",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
+ "webPushVapidGenerate": "Générer les clés",
+ "webPushVapidGenerating": "Génération…",
+ "webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.",
+ "webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.",
+ "webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?",
"configured": "Configuré",
"monitoringTestSuccess": "Événement de test envoyé",
"monitoringTestFailed": "Échec du test de surveillance",
diff --git a/messages/it.json b/messages/it.json
index 26588525..cd103de5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Facoltativo: inserisci una nuova chiave privata",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
+ "webPushVapidGenerate": "Genera chiavi",
+ "webPushVapidGenerating": "Generazione…",
+ "webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.",
+ "webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.",
+ "webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?",
"configured": "Configurato",
"monitoringTestSuccess": "Evento di prova inviato",
"monitoringTestFailed": "Test di monitoraggio non riuscito",
diff --git a/messages/pl.json b/messages/pl.json
index a969c66f..19cfeb26 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4896,6 +4896,11 @@
"webPushVapidPrivateKeyPlaceholder": "Opcjonalnie: wprowadź nowy klucz prywatny",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
+ "webPushVapidGenerate": "Wygeneruj klucze",
+ "webPushVapidGenerating": "Generowanie…",
+ "webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.",
+ "webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.",
+ "webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?",
"configured": "Skonfigurowano",
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
"monitoringTestFailed": "Test monitorowania nie powiódł się",
diff --git a/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts b/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts
new file mode 100644
index 00000000..105287ed
--- /dev/null
+++ b/src/app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts
@@ -0,0 +1,192 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ appSettings: {
+ findUnique: vi.fn(),
+ upsert: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/lib/api-handler", async () => {
+ const actual =
+ await vi.importActual(
+ "@/lib/api-handler",
+ );
+ return {
+ ...actual,
+ apiHandler: Promise>(
+ h: T,
+ ): T => h,
+ requireAdmin: vi.fn(),
+ };
+});
+
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/logging/context", () => ({
+ annotate: vi.fn(),
+ getEvent: vi.fn(() => null),
+}));
+
+vi.mock("@/lib/crypto", () => ({
+ encrypt: vi.fn((value: string) => `enc(${value})`),
+}));
+
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateAppSettings: vi.fn(),
+}));
+
+vi.mock("web-push", () => ({
+ generateVAPIDKeys: vi.fn(() => ({
+ publicKey: "GENERATED_PUBLIC",
+ privateKey: "GENERATED_PRIVATE",
+ })),
+}));
+
+import { POST } from "../route";
+import { prisma } from "@/lib/db";
+import { requireAdmin, HttpError } from "@/lib/api-handler";
+import { encrypt } from "@/lib/crypto";
+import { auditLog } from "@/lib/auth/audit";
+
+const ADMIN_CTX = {
+ session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
+};
+
+function jsonReq(body: unknown): NextRequest {
+ return new NextRequest(
+ "http://localhost/api/admin/settings/web-push-vapid/generate",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
+ vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
+ id: "singleton",
+ } as never);
+});
+
+describe("POST /api/admin/settings/web-push-vapid/generate", () => {
+ it("rejects with 401 when no session (admin-gated)", async () => {
+ vi.mocked(requireAdmin).mockRejectedValue(
+ new HttpError(401, "Not authenticated"),
+ );
+ await expect(POST(jsonReq({}))).rejects.toThrow("Not authenticated");
+ });
+
+ it("rejects with 403 for a non-admin (cookie-only boundary)", async () => {
+ vi.mocked(requireAdmin).mockRejectedValue(
+ new HttpError(403, "Admin access required"),
+ );
+ await expect(POST(jsonReq({}))).rejects.toThrow("Admin access required");
+ });
+
+ it("generates a keypair and stores the private key encrypted", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
+
+ const res = await POST(jsonReq({}));
+ expect(res.status).toBe(200);
+
+ const body = (await res.json()) as {
+ data: {
+ webPushVapidPublicKey: string;
+ webPushVapidSubject: string;
+ webPushVapidConfigured: boolean;
+ };
+ };
+ expect(body.data.webPushVapidPublicKey).toBe("GENERATED_PUBLIC");
+ expect(body.data.webPushVapidConfigured).toBe(true);
+ // Default placeholder subject seeded so the keypair is immediately valid.
+ expect(body.data.webPushVapidSubject).toBe("mailto:admin@example.com");
+
+ // Private key encrypted before persistence; never returned in the body.
+ expect(encrypt).toHaveBeenCalledWith("GENERATED_PRIVATE");
+ expect(JSON.stringify(body.data)).not.toContain("GENERATED_PRIVATE");
+
+ const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
+ expect(upsert?.where).toEqual({ id: "singleton" });
+ expect(upsert?.update).toMatchObject({
+ webPushVapidPublicKey: "GENERATED_PUBLIC",
+ webPushVapidPrivateKeyEncrypted: "enc(GENERATED_PRIVATE)",
+ });
+ });
+
+ it("never logs the plaintext private key in the audit detail", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
+ await POST(jsonReq({}));
+ expect(auditLog).toHaveBeenCalled();
+ const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
+ string,
+ unknown
+ >;
+ expect(JSON.stringify(details)).not.toContain("GENERATED_PRIVATE");
+ expect(details.webPushVapidPrivateKeyUpdated).toBe(true);
+ expect(details.replacedExisting).toBe(false);
+ });
+
+ it("refuses with 409 when keys already exist and force is absent", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
+ webPushVapidPublicKey: "OLD_PUBLIC",
+ webPushVapidPrivateKeyEncrypted: "OLD_ENC",
+ webPushVapidSubject: "mailto:old@example.com",
+ } as never);
+
+ const res = await POST(jsonReq({}));
+ expect(res.status).toBe(409);
+ expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
+ });
+
+ it("overwrites existing keys when force is true and keeps the subject", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
+ webPushVapidPublicKey: "OLD_PUBLIC",
+ webPushVapidPrivateKeyEncrypted: "OLD_ENC",
+ webPushVapidSubject: "mailto:keep@example.com",
+ } as never);
+
+ const res = await POST(jsonReq({ force: true }));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { webPushVapidSubject: string };
+ };
+ // Existing subject preserved when no override is supplied.
+ expect(body.data.webPushVapidSubject).toBe("mailto:keep@example.com");
+
+ const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
+ string,
+ unknown
+ >;
+ expect(details.replacedExisting).toBe(true);
+ });
+
+ it("rejects an invalid subject override with 422", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
+ const res = await POST(jsonReq({ subject: "not-a-mailto" }));
+ expect(res.status).toBe(422);
+ expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
+ });
+
+ it("returns 415 when content-type is not JSON", async () => {
+ const r = new NextRequest(
+ "http://localhost/api/admin/settings/web-push-vapid/generate",
+ {
+ method: "POST",
+ headers: { "content-type": "text/plain" },
+ body: "{}",
+ },
+ );
+ const res = await POST(r);
+ expect(res.status).toBe(415);
+ });
+});
diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts
new file mode 100644
index 00000000..10cf0bd3
--- /dev/null
+++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts
@@ -0,0 +1,132 @@
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAdmin } from "@/lib/api-handler";
+import { auditLog } from "@/lib/auth/audit";
+import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { encrypt } from "@/lib/crypto";
+import { invalidateAppSettings } from "@/lib/cache/invalidate";
+import { z } from "zod/v4";
+import { NextRequest } from "next/server";
+
+export const dynamic = "force-dynamic";
+
+const generateSchema = z
+ .object({
+ // Overwrite guard — the client must opt in to replacing an existing
+ // keypair. Regenerating invalidates every browser subscription signed
+ // against the old public key, so the UI confirms before sending this.
+ force: z.boolean().optional(),
+ // Optional subject override. When omitted the existing subject is kept,
+ // or a neutral placeholder seeded so the keypair is immediately usable.
+ subject: z
+ .string()
+ .refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), {
+ message: "subject must be in mailto:address@example.com format",
+ })
+ .optional(),
+ })
+ .strict();
+
+/**
+ * POST /api/admin/settings/web-push-vapid/generate
+ *
+ * Server-side VAPID keypair generation — the self-hoster DX win. Calls
+ * `web-push`'s `generateVAPIDKeys()`, encrypts the private key at rest
+ * (`src/lib/crypto.ts`), and persists the pair onto the AppSettings
+ * singleton. Returns only the public key + subject; the private key never
+ * leaves the server and is never logged (the egress redactor covers the
+ * encrypted blob, and the audit detail carries only a "generated" marker).
+ *
+ * Cookie-only via `requireAdmin()` — a Bearer token can never reach it.
+ *
+ * Overwrite guard: if a keypair already exists, the call refuses with 409
+ * unless `force: true` is supplied. Regenerating invalidates existing
+ * browser subscriptions, so the operator must opt in.
+ */
+export const POST = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAdmin();
+ annotate({ action: { name: "admin.webPushVapid.generate" } });
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 4 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = generateSchema.safeParse(body ?? {});
+ if (!parsed.success) {
+ return apiError("Invalid request", 422);
+ }
+
+ const existing = await prisma.appSettings.findUnique({
+ where: { id: "singleton" },
+ select: {
+ webPushVapidPublicKey: true,
+ webPushVapidPrivateKeyEncrypted: true,
+ webPushVapidSubject: true,
+ },
+ });
+
+ const alreadyConfigured = Boolean(
+ existing?.webPushVapidPublicKey &&
+ existing?.webPushVapidPrivateKeyEncrypted,
+ );
+
+ if (alreadyConfigured && parsed.data.force !== true) {
+ // Overwrite guard — surface the existing public key so the UI can warn
+ // the operator that regenerating invalidates current subscriptions.
+ return apiError(
+ "VAPID keys already configured. Regenerating invalidates existing browser subscriptions. Resend with force to replace them.",
+ 409,
+ );
+ }
+
+ // Lazy import — mirrors the sender so a build without `web-push` still
+ // type-checks. `generateVAPIDKeys()` returns Base64URL public + private.
+ const webpush = await import("web-push");
+ const { publicKey, privateKey } = webpush.generateVAPIDKeys();
+
+ // Subject precedence: explicit override → existing subject → placeholder.
+ // The placeholder keeps the keypair immediately valid; the admin edits
+ // the real contact address inline in the card afterwards.
+ const subjectOverride = parsed.data.subject?.trim();
+ const subject =
+ (subjectOverride && subjectOverride.length > 0
+ ? subjectOverride
+ : existing?.webPushVapidSubject) || "mailto:admin@example.com";
+
+ await prisma.appSettings.upsert({
+ where: { id: "singleton" },
+ update: {
+ webPushVapidPublicKey: publicKey,
+ webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
+ webPushVapidSubject: subject,
+ },
+ create: {
+ id: "singleton",
+ webPushVapidPublicKey: publicKey,
+ webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
+ webPushVapidSubject: subject,
+ },
+ });
+
+ invalidateAppSettings();
+
+ await auditLog("admin.web_push_vapid.generate", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ // Never the private key — only a marker that a fresh pair was minted
+ // and whether it replaced an existing one.
+ details: {
+ webPushVapidPrivateKeyUpdated: true,
+ replacedExisting: alreadyConfigured,
+ webPushVapidSubject: subject,
+ },
+ });
+
+ // Public key + subject only; the private key stays server-side.
+ return apiSuccess({
+ webPushVapidPublicKey: publicKey,
+ webPushVapidSubject: subject,
+ webPushVapidConfigured: true,
+ });
+});
diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx
index f0829e8c..e409d96f 100644
--- a/src/components/admin/web-push-vapid-section.tsx
+++ b/src/components/admin/web-push-vapid-section.tsx
@@ -1,12 +1,16 @@
"use client";
import { useState } from "react";
-import { BellRing, Loader2 } from "lucide-react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { BellRing, KeyRound, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import {
ConfiguredBadge,
PasswordInput,
@@ -16,6 +20,7 @@ import {
export function WebPushVapidSection() {
const { t } = useTranslations();
+ const queryClient = useQueryClient();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState<
@@ -26,6 +31,7 @@ export function WebPushVapidSection() {
const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState<
string | null
>(null);
+ const [generating, setGenerating] = useState(false);
const webPushVapidPublicKeyValue =
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
@@ -52,6 +58,53 @@ export function WebPushVapidSection() {
});
}
+ async function generateVapidKeys(force: boolean) {
+ setGenerating(true);
+ try {
+ const res = await apiFetchRaw(
+ "/api/admin/settings/web-push-vapid/generate",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(force ? { force: true } : {}),
+ },
+ );
+
+ if (res.status === 409) {
+ // Overwrite guard — existing keys would be replaced. Confirm with
+ // the operator (regenerating invalidates current subscriptions),
+ // then retry with force.
+ if (window.confirm(t("admin.webPushVapidGenerateConfirm"))) {
+ await generateVapidKeys(true);
+ }
+ return;
+ }
+
+ if (!res.ok) {
+ toast.error(t("admin.webPushVapidGenerateFailed"));
+ return;
+ }
+
+ const json = (await res.json()) as {
+ data: { webPushVapidPublicKey: string; webPushVapidSubject: string };
+ };
+ // The private key was minted and encrypted server-side; only the
+ // public key + subject come back. Populate the visible fields and
+ // leave the private-key input empty (it stays "configured").
+ setWebPushVapidPublicKeyDraft(json.data.webPushVapidPublicKey);
+ setWebPushVapidSubjectDraft(json.data.webPushVapidSubject);
+ setWebPushVapidPrivateKeyDraft("");
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.adminSettings(),
+ });
+ toast.success(t("admin.webPushVapidGenerated"));
+ } catch {
+ toast.error(t("admin.webPushVapidGenerateFailed"));
+ } finally {
+ setGenerating(false);
+ }
+ }
+
return (
+ void generateVapidKeys(false)}
+ disabled={generating || updateSettings.isPending}
+ >
+ {generating ? (
+
+ ) : (
+
+ )}
+ {generating
+ ? t("admin.webPushVapidGenerating")
+ : t("admin.webPushVapidGenerate")}
+
Date: Mon, 15 Jun 2026 00:58:39 +0200
Subject: [PATCH 53/79] docs(self-hosting): VAPID generate button, env names,
backup and whitelist notes
Document the new Generate keys button in notifications.md as the easiest
Web Push setup path, demoting the CLI to an alternative. Note that
clientManaged medication-reminder suppression is an iOS-app-only contract
so a PWA-only operator does not go hunting for a web toggle.
Add the VAPID env-var names and APNS_KEY_B64 to .env.production.example
(the loader and APNs sender read them, but the example omitted them),
both as commented placeholders, with a note that Web Push needs no Apple
account and that these vars are not in the compose whitelist.
Add a Backup and restore section to getting-started.md: a local pg_dump
one-liner and the load-bearing warning that the ENCRYPTION_KEYS /
ENCRYPTION_KEY must be backed up alongside the dump or the encrypted data
is unrecoverable, cross-linking the off-host backup and key-rotation
runbooks. Add troubleshooting entries for the compose env-var whitelist
and pull_policy: always.
---
.env.production.example | 24 ++++++++++-
docs/self-hosting/getting-started.md | 48 ++++++++++++++++++++++
docs/self-hosting/notifications.md | 59 ++++++++++++++++++----------
3 files changed, 110 insertions(+), 21 deletions(-)
diff --git a/.env.production.example b/.env.production.example
index 258db482..3b94d396 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -74,13 +74,35 @@ API_TOKEN_HMAC_KEY=""
# OURA_REDIRECT_URI=""
+# -----------------------------------------------------------------------------
+# Web Push -- VAPID keys (optional; the admin panel is the easier path)
+# -----------------------------------------------------------------------------
+# Web Push needs NO Apple account and no paid service -- just a VAPID keypair.
+# The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate
+# keys" mints and stores the pair for you (private key encrypted at rest). Use
+# these env vars only if you prefer env-config over the DB. The loader reads
+# DB first, then env (src/lib/notifications/vapid-config.ts). NOTE: these are
+# NOT in docker-compose.yml's `environment:` whitelist -- if you go the env
+# route under compose, add them there too or the values never reach the
+# container (see docs/self-hosting/getting-started.md Troubleshooting).
+# VAPID_PUBLIC_KEY=""
+# VAPID_PRIVATE_KEY=""
+# VAPID_SUBJECT="mailto:you@example.com"
+
+
# -----------------------------------------------------------------------------
# APNs -- iOS push (optional, all-or-none)
# -----------------------------------------------------------------------------
+# Only the native iOS app uses APNs, and it needs a paid Apple Developer
+# Program membership. Web Push (above) needs NO Apple account -- it covers
+# every browser and the installed PWA on iPhone. Leave this whole block unset
+# unless you ship the native app. Key-source precedence when more than one is
+# set: APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE.
# APNS_KEY_ID=""
# APNS_TEAM_ID=""
# APNS_BUNDLE_ID=""
-# APNS_KEY="" # OR APNS_KEY_FILE -- either one satisfies the manifest
+# APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines)
+# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest
# APNS_KEY_FILE=""
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index ca8abe9a..5bee1db7 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -175,3 +175,51 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
`localhost`, and restart `app` after the change.
+- **An env var in `.env` seems ignored / a feature stays "off".** The
+ compose `environment:` block is a **whitelist** — a variable set in
+ `.env` only reaches the container if `docker-compose.yml` lists it
+ under `environment:`. Vars not listed never propagate, even with
+ `${VAR}` substitution at compose-up. If a setting looks configured but
+ is silently dead, check the var is in that block (add it if you run a
+ custom compose); the supported alternative for most secrets (VAPID,
+ GitHub token, GlitchTip DSN) is the **admin panel**, which stores them
+ in the database and sidesteps the whitelist entirely.
+- **A new image doesn't pick up / `:latest` looks stale.** `docker-compose.yml`
+ sets `pull_policy: always`, which is load-bearing: without it Docker
+ re-uses the cached `:latest` digest and silently skips the registry
+ round-trip on `compose up`. If you pin a custom compose, keep
+ `pull_policy: always` on the `app` service, and verify the running
+ build with `GET /api/version` (it returns `version` + `buildSha`).
+
+## Backup and restore
+
+Your database is the only stateful piece — back it up, and back up the
+key that decrypts it.
+
+**Local snapshot (the common single-box case).** Dump the `db` service
+with `pg_dump`:
+
+```bash
+# The bundled db service uses user `healthlog` and database `healthlog`.
+docker compose exec -T db pg_dump -U healthlog healthlog \
+ | gzip > healthlog-$(date +%F).sql.gz
+```
+
+Restore into a fresh database the same way:
+
+```bash
+gunzip -c healthlog-2026-01-01.sql.gz \
+ | docker compose exec -T db psql -U healthlog healthlog
+```
+
+> **Back up your `ENCRYPTION_KEYS` / `ENCRYPTION_KEY` alongside the dump.**
+> HealthLog encrypts sensitive columns at rest (AES-256-GCM). A database
+> dump **without the encryption key is unrecoverable** — the encrypted
+> data cannot be read back. Store the key (and any retired keys still in
+> the rotation map) somewhere separate from the dump but equally durable.
+> Losing the key means losing the encrypted data, full stop.
+
+For off-host encrypted backups to S3/R2/B2 see
+[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the
+encryption key safely (keep the old key until zero rows remain on it) see
+[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md).
diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md
index affe53ab..7abf16df 100644
--- a/docs/self-hosting/notifications.md
+++ b/docs/self-hosting/notifications.md
@@ -34,35 +34,46 @@ mobile — even when the HealthLog tab is closed, as long as the browser
or installed PWA is alive. It is the broadest-reach channel and the one
to set up first.
-### 1. Generate a VAPID keypair
+### 1. Generate and store a VAPID keypair
VAPID is the signing scheme that lets a push service trust your server.
-Generate a keypair once with the bundled `web-push` CLI:
+You also choose a **subject** — a `mailto:` address the push service can
+contact about your sender, e.g. `mailto:you@example.com`.
+
+The server loads VAPID config from the database first and falls back to
+environment variables (`src/lib/notifications/vapid-config.ts`). The admin
+panel is the easiest path because it survives without touching `.env`.
+
+**Admin panel — Generate keys (easiest).** Sign in as the admin user,
+open `/admin`, and find the **Web Push VAPID** card
+(`src/components/admin/web-push-vapid-section.tsx`). Click **Generate
+keys**: the server mints a fresh keypair, stores the private key encrypted
+at rest with your `ENCRYPTION_KEY`, seeds a placeholder subject, and fills
+in the public key. Edit the subject to your real `mailto:` address and
+save. The card shows a "configured" badge once all three fields are set —
+no shell, no copy-paste.
+
+> If a keypair already exists, **Generate keys** asks you to confirm before
+> replacing it. Regenerating invalidates every existing browser
+> subscription, so each device has to re-subscribe afterwards. Only
+> regenerate when you mean to.
+
+**Admin panel — paste an existing pair.** If you already have a keypair
+(e.g. copied from another deployment), paste the public key, the private
+key, and the subject into the same card and save instead of generating.
+
+**CLI — generate a pair yourself.** You can also mint one with the bundled
+`web-push` CLI and paste the result into the card or your `.env`:
```bash
npx web-push generate-vapid-keys
```
-That prints a **public key** and a **private key** (both Base64URL). You
-also choose a **subject** — a `mailto:` address the push service can
-contact about your sender, e.g. `mailto:you@example.com`.
+That prints a **public key** and a **private key** (both Base64URL).
-### 2. Store the keys
+### 2. Store the keys via environment variables (alternative)
-The server loads VAPID config from the database first and falls back to
-environment variables (`src/lib/notifications/vapid-config.ts`). Either
-path works; the admin panel is the easier one because it survives without
-touching `.env`.
-
-**Admin panel (preferred).** Sign in as the admin user, open
-`/admin`, and find the **Web Push VAPID** card
-(`src/components/admin/web-push-vapid-section.tsx`). Paste the public key,
-the private key, and the subject, then save. The private key is encrypted
-at rest with your `ENCRYPTION_KEY`, exactly like the other secrets in the
-admin panel. The card shows a "configured" badge once all three fields
-are set.
-
-**Environment variables (alternative).** If you would rather keep
+If you would rather keep
secrets out of the database, set them in `.env` / the compose
`environment:` block instead:
@@ -179,6 +190,14 @@ Two gotchas worth knowing before you blame a stale token:
`BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox &
Production**; a wrongly scoped key cannot be reconfigured — re-issue it.
+> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run
+> its own local medication reminders and ask the server to stop sending
+> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
+> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract:
+> a web/PWA-only self-hoster never needs it and there is no toggle for it
+> in the web UI. If you only run the PWA, ignore it entirely — your
+> medication reminders come from the server cron over the channels above.
+
## Which channel should I pick?
| Your setup | Recommended channel |
From 001c158203fb6b322096752cf58fa1d0948b390d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:59:33 +0200
Subject: [PATCH 54/79] feat(integrations): add a Setup-guide doc-link to every
integration card
Each integration card now carries the same discreet "Setup guide" link to
its runbook under docs.healthlog.dev/integrations/, so a user who
is mid-setup always knows where to go. The docs host lives in one shared
constant and the affordance renders identically across all six providers
(WHOOP, Withings, Fitbit, Polar, Oura, Nightscout).
---
messages/de.json | 33 ++++++++++-
messages/en.json | 33 ++++++++++-
messages/es.json | 33 ++++++++++-
messages/fr.json | 33 ++++++++++-
messages/it.json | 33 ++++++++++-
messages/pl.json | 33 ++++++++++-
.../__tests__/integrations-section.test.tsx | 39 +++++++++++++
.../settings/integrations/fitbit-card.tsx | 3 +
.../settings/integrations/nightscout-card.tsx | 3 +
.../integrations/oauth-provider-card.tsx | 3 +
.../integrations/setup-guide-link.tsx | 57 +++++++++++++++++++
.../settings/integrations/whoop-card.tsx | 3 +
.../settings/integrations/withings-card.tsx | 3 +
13 files changed, 303 insertions(+), 6 deletions(-)
create mode 100644 src/components/settings/integrations/setup-guide-link.tsx
diff --git a/messages/de.json b/messages/de.json
index bb1026aa..3acdc22e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
+ },
+ "reminders": {
+ "title": "Erinnerungen & Benachrichtigungen",
+ "description": "Ein Ort für jede Erinnerung — woran und wann erinnert wird — und die Kanäle, die sie zustellen.",
+ "categoriesGroup": "Erinnerungen",
+ "channelsGroup": "Zustellkanäle",
+ "medication": {
+ "title": "Medikamenten-Erinnerungen",
+ "description": "Einnahmezeiten und Zeitfenster, je Medikament eingestellt."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "description": "Wiederkehrende Untersuchungen und Messungen, inklusive Ort."
+ },
+ "mood": {
+ "title": "Stimmungs-Check-in",
+ "description": "Eine tägliche Erinnerung, dein Befinden zu erfassen."
+ },
+ "lowStock": {
+ "title": "Reichweite bei Restbestand",
+ "description": "Werde gewarnt, bevor ein Medikament zur Neige geht."
+ },
+ "coach": {
+ "title": "Coach-Hinweis",
+ "description": "Proaktive Impulse vom KI-Coach."
+ },
+ "channels": {
+ "title": "Benachrichtigungskanäle",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook und E-Mail."
+ }
}
},
"profile": "Profil",
@@ -4773,7 +4803,8 @@
"token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
- }
+ },
+ "integrationSetupGuide": "Einrichtungsanleitung"
},
"admin": {
"title": "Administration",
diff --git a/messages/en.json b/messages/en.json
index 63210fdd..8339967f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and order of the mood picker."
+ },
+ "reminders": {
+ "title": "Reminders & Notifications",
+ "description": "One home for every reminder — what gets reminded and when — plus the channels that deliver them.",
+ "categoriesGroup": "Reminders",
+ "channelsGroup": "Delivery channels",
+ "medication": {
+ "title": "Medication reminders",
+ "description": "Dose times and windows, configured on each medication."
+ },
+ "vorsorge": {
+ "title": "Preventive care",
+ "description": "Recurring check-ups and measurements, with where to go."
+ },
+ "mood": {
+ "title": "Mood check-in",
+ "description": "A daily nudge to log how you feel."
+ },
+ "lowStock": {
+ "title": "Low-stock runway",
+ "description": "Get warned before a medication runs out."
+ },
+ "coach": {
+ "title": "Coach nudge",
+ "description": "Proactive prompts from the AI coach."
+ },
+ "channels": {
+ "title": "Notification channels",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook, and Email."
+ }
}
},
"profile": "Profile",
@@ -4773,7 +4803,8 @@
"token": "The token exchange with Oura failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
- }
+ },
+ "integrationSetupGuide": "Setup guide"
},
"admin": {
"title": "Administration",
diff --git a/messages/es.json b/messages/es.json
index 2da94f8f..f5c8d5fc 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y orden del selector de ánimo."
+ },
+ "reminders": {
+ "title": "Recordatorios y notificaciones",
+ "description": "Un único lugar para cada recordatorio — qué se recuerda y cuándo — y los canales que lo entregan.",
+ "categoriesGroup": "Recordatorios",
+ "channelsGroup": "Canales de entrega",
+ "medication": {
+ "title": "Recordatorios de medicación",
+ "description": "Horas y ventanas de dosis, configuradas en cada medicamento."
+ },
+ "vorsorge": {
+ "title": "Cuidado preventivo",
+ "description": "Revisiones y mediciones periódicas, con el lugar al que acudir."
+ },
+ "mood": {
+ "title": "Registro de ánimo",
+ "description": "Un aviso diario para registrar cómo te sientes."
+ },
+ "lowStock": {
+ "title": "Margen por bajo stock",
+ "description": "Recibe un aviso antes de que se acabe un medicamento."
+ },
+ "coach": {
+ "title": "Sugerencia del coach",
+ "description": "Indicaciones proactivas del coach de IA."
+ },
+ "channels": {
+ "title": "Canales de notificación",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook y correo."
+ }
}
},
"profile": "Perfil",
@@ -4773,7 +4803,8 @@
"token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
- }
+ },
+ "integrationSetupGuide": "Guía de configuración"
},
"admin": {
"title": "Administration",
diff --git a/messages/fr.json b/messages/fr.json
index 21461fbc..7506b034 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
+ },
+ "reminders": {
+ "title": "Rappels et notifications",
+ "description": "Un seul endroit pour chaque rappel — quoi et quand — ainsi que les canaux qui les délivrent.",
+ "categoriesGroup": "Rappels",
+ "channelsGroup": "Canaux de diffusion",
+ "medication": {
+ "title": "Rappels de médication",
+ "description": "Heures et fenêtres de prise, configurées sur chaque médicament."
+ },
+ "vorsorge": {
+ "title": "Prévention",
+ "description": "Bilans et mesures récurrents, avec le lieu où se rendre."
+ },
+ "mood": {
+ "title": "Suivi de l'humeur",
+ "description": "Un rappel quotidien pour noter votre ressenti."
+ },
+ "lowStock": {
+ "title": "Marge de stock faible",
+ "description": "Soyez averti avant qu'un médicament ne s'épuise."
+ },
+ "coach": {
+ "title": "Suggestion du coach",
+ "description": "Invitations proactives du coach IA."
+ },
+ "channels": {
+ "title": "Canaux de notification",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook et e-mail."
+ }
}
},
"profile": "Profil",
@@ -4773,7 +4803,8 @@
"token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
- }
+ },
+ "integrationSetupGuide": "Guide de configuration"
},
"admin": {
"title": "Administration",
diff --git a/messages/it.json b/messages/it.json
index 26588525..0719bf88 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e ordine del selettore dell’umore."
+ },
+ "reminders": {
+ "title": "Promemoria e notifiche",
+ "description": "Un'unica sede per ogni promemoria — cosa ricordare e quando — e i canali che li recapitano.",
+ "categoriesGroup": "Promemoria",
+ "channelsGroup": "Canali di consegna",
+ "medication": {
+ "title": "Promemoria dei farmaci",
+ "description": "Orari e finestre delle dosi, impostati su ogni farmaco."
+ },
+ "vorsorge": {
+ "title": "Prevenzione",
+ "description": "Controlli e misurazioni ricorrenti, con il luogo dove recarsi."
+ },
+ "mood": {
+ "title": "Check-in dell'umore",
+ "description": "Un promemoria quotidiano per registrare come ti senti."
+ },
+ "lowStock": {
+ "title": "Autonomia scorte",
+ "description": "Ricevi un avviso prima che un farmaco finisca."
+ },
+ "coach": {
+ "title": "Suggerimento del coach",
+ "description": "Stimoli proattivi dal coach IA."
+ },
+ "channels": {
+ "title": "Canali di notifica",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook ed e-mail."
+ }
}
},
"profile": "Profilo",
@@ -4773,7 +4803,8 @@
"token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
- }
+ },
+ "integrationSetupGuide": "Guida alla configurazione"
},
"admin": {
"title": "Administration",
diff --git a/messages/pl.json b/messages/pl.json
index a969c66f..ae04caf9 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i kolejność w wyborze nastroju."
+ },
+ "reminders": {
+ "title": "Przypomnienia i powiadomienia",
+ "description": "Jedno miejsce dla każdego przypomnienia — o czym i kiedy — oraz kanały, które je dostarczają.",
+ "categoriesGroup": "Przypomnienia",
+ "channelsGroup": "Kanały dostarczania",
+ "medication": {
+ "title": "Przypomnienia o lekach",
+ "description": "Godziny i okna dawek, ustawiane przy każdym leku."
+ },
+ "vorsorge": {
+ "title": "Profilaktyka",
+ "description": "Cykliczne badania i pomiary wraz z miejscem, gdzie się udać."
+ },
+ "mood": {
+ "title": "Zapis nastroju",
+ "description": "Codzienne przypomnienie, by zapisać samopoczucie."
+ },
+ "lowStock": {
+ "title": "Zapas na wyczerpaniu",
+ "description": "Otrzymaj ostrzeżenie, zanim lek się skończy."
+ },
+ "coach": {
+ "title": "Podpowiedź trenera",
+ "description": "Proaktywne wskazówki od trenera AI."
+ },
+ "channels": {
+ "title": "Kanały powiadomień",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook i e-mail."
+ }
}
},
"profile": "Profil",
@@ -4773,7 +4803,8 @@
"token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
- }
+ },
+ "integrationSetupGuide": "Przewodnik konfiguracji"
},
"admin": {
"title": "Administracja",
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index 8381da09..69b49d1f 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -303,4 +303,43 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
// Fitbit / Google Health). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(3);
});
+
+ // v1.17.1 — every integration card carries the same discreet "Setup guide"
+ // doc-link pointing at its runbook under the single shared docs base. The
+ // affordance is one family across all six providers.
+ it("every integration card carries the shared Setup-guide doc-link", () => {
+ setIntegrationStatus({
+ threshold: 3,
+ integrations: [
+ {
+ integration: "withings",
+ state: "connected",
+ lastSuccessAt: "2026-05-09T18:00:00.000Z",
+ lastAttemptAt: "2026-05-09T18:00:00.000Z",
+ lastError: null,
+ connected: true,
+ configured: true,
+ legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
+ hasActivityScope: true,
+ },
+ ],
+ });
+
+ const html = render();
+ // One setup-guide link per card → six providers.
+ expect(count(html, 'data-slot="integration-setup-guide"')).toBe(6);
+ for (const provider of [
+ "withings",
+ "whoop",
+ "fitbit",
+ "polar",
+ "oura",
+ "nightscout",
+ ]) {
+ expect(html).toContain(`data-testid="${provider}-setup-guide"`);
+ expect(html).toContain(
+ `href="https://docs.healthlog.dev/integrations/${provider}"`,
+ );
+ }
+ });
});
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx
index 5b394c61..f2b1c3b0 100644
--- a/src/components/settings/integrations/fitbit-card.tsx
+++ b/src/components/settings/integrations/fitbit-card.tsx
@@ -51,6 +51,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export function FitbitCard({
viewModel,
@@ -484,6 +485,8 @@ export function FitbitCard({
{t("settings.fitbitNoCredentials")}
)}
+
+
);
diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx
index 97edbc3e..6dc028bb 100644
--- a/src/components/settings/integrations/nightscout-card.tsx
+++ b/src/components/settings/integrations/nightscout-card.tsx
@@ -39,6 +39,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys";
import { IntegrationErrorMessage } from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
interface NightscoutStatus {
connected: boolean;
@@ -366,6 +367,8 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
>
)}
+
+
);
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index 77b2e97f..d7642eca 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -37,6 +37,7 @@ import { TestConnectionButton } from "@/components/settings/test-connection-butt
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export interface OAuthProviderStatus {
connected: boolean;
@@ -398,6 +399,8 @@ export function OAuthProviderCard({
{msg}
)}
+
+
);
diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx
new file mode 100644
index 00000000..a6cf6a65
--- /dev/null
+++ b/src/components/settings/integrations/setup-guide-link.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+/**
+ * v1.17.1 — shared "Setup guide" doc-link for every Settings → Integrations
+ * card. Each card paints the same affordance: one discreet, external link to
+ * the provider's setup runbook ("was eingeben, wo klicken"). Single-sourced
+ * here so all six cards (WHOOP / Withings / Fitbit / Polar / Oura / Nightscout)
+ * read as one family and the docs host lives in exactly one place.
+ *
+ * The runbooks themselves are authored separately; this link wires the
+ * destination now so a user who is mid-setup always knows where to go.
+ */
+
+import { ExternalLink } from "lucide-react";
+
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * Base for every provider setup runbook. The provider key is appended as a
+ * path segment, e.g. `https://docs.healthlog.dev/integrations/whoop`. Kept as
+ * a single constant so the host never drifts across cards.
+ */
+export const INTEGRATION_DOCS_BASE =
+ "https://docs.healthlog.dev/integrations";
+
+export type IntegrationDocsProvider =
+ | "whoop"
+ | "withings"
+ | "fitbit"
+ | "polar"
+ | "oura"
+ | "nightscout";
+
+export function integrationDocsHref(provider: IntegrationDocsProvider): string {
+ return `${INTEGRATION_DOCS_BASE}/${provider}`;
+}
+
+export function IntegrationSetupGuideLink({
+ provider,
+}: {
+ provider: IntegrationDocsProvider;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ {t("settings.integrationSetupGuide")}
+
+
+ );
+}
diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx
index a3acc207..23b875f4 100644
--- a/src/components/settings/integrations/whoop-card.tsx
+++ b/src/components/settings/integrations/whoop-card.tsx
@@ -43,6 +43,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export function WhoopCard({
viewModel,
@@ -456,6 +457,8 @@ export function WhoopCard({
{t("settings.whoopNoCredentials")}
)}
+
+
);
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx
index d9613697..009566d7 100644
--- a/src/components/settings/integrations/withings-card.tsx
+++ b/src/components/settings/integrations/withings-card.tsx
@@ -36,6 +36,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export function WithingsCard({
viewModel,
@@ -491,6 +492,8 @@ export function WithingsCard({
{t("settings.withingsNoCredentials")}
)}
+
+
);
From 78c110a6c868c74a8865915bf0a6db8020e53d42 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:59:47 +0200
Subject: [PATCH 55/79] feat(settings): add a Reminders & Notifications home
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reminders used to be scattered across three places: medication reminders on
each med card, mood / low-stock / coach-nudge inside the Notifications channel
screen, and the Vorsorge reminders on their own page. The new Reminders
section is the single front door. It draws a clean line between the two
concepts — reminder categories (the what/when) and notification channels (the
how/where) — and links to each canonical editor, which keeps its own route and
deep link intact. Consolidation by linking, not a rewrite.
The section sits right after Notifications in the settings nav.
---
src/app/settings/[section]/page.tsx | 2 +
.../__tests__/reminders-section.test.tsx | 67 +++++++
.../__tests__/settings-shell.test.tsx | 3 +
src/components/settings/reminders-section.tsx | 176 ++++++++++++++++++
.../settings/section-placeholder.tsx | 2 +
src/components/settings/section-slugs.ts | 6 +
src/components/settings/settings-shell.tsx | 10 +
7 files changed, 266 insertions(+)
create mode 100644 src/components/settings/__tests__/reminders-section.test.tsx
create mode 100644 src/components/settings/reminders-section.tsx
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 20f1d2d2..87b97f90 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -14,6 +14,7 @@ import { LayoutSection } from "@/components/settings/layout-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
+import { RemindersSection } from "@/components/settings/reminders-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
// separate sections again. `ThresholdsSection` renders the target-range
@@ -52,6 +53,7 @@ const SECTION_COMPONENTS: Record<
ai: AiSection,
integrations: IntegrationsSection,
notifications: NotificationsSection,
+ reminders: RemindersSection,
layout: LayoutSection,
dashboard: DashboardSection,
insights: InsightsSection,
diff --git a/src/components/settings/__tests__/reminders-section.test.tsx b/src/components/settings/__tests__/reminders-section.test.tsx
new file mode 100644
index 00000000..3305f95d
--- /dev/null
+++ b/src/components/settings/__tests__/reminders-section.test.tsx
@@ -0,0 +1,67 @@
+/**
+ * v1.17.1 — the "Reminders & Notifications" hub.
+ *
+ * The hub is the single front door for every reminder concept. It groups the
+ * reminder CATEGORIES (medication / Vorsorge / mood / low-stock / coach) and
+ * links to the notification CHANNELS, with each link deep-linking into the
+ * canonical editor so nothing is rewritten — only gathered in one place.
+ */
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { RemindersSection } from "../reminders-section";
+
+function render(locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+describe("", () => {
+ it("deep-links to every canonical reminder editor", () => {
+ const html = render();
+ for (const href of [
+ "/medications",
+ "/vorsorge",
+ "/settings/notifications#mood-reminder",
+ "/settings/notifications#low-stock",
+ "/settings/notifications#coach-nudge",
+ ]) {
+ expect(html).toContain(`href="${href}"`);
+ }
+ });
+
+ it("links to the notification channels screen (the how/where)", () => {
+ const html = render();
+ expect(html).toContain('href="/settings/notifications"');
+ expect(html).toContain('data-testid="reminders-link-channels"');
+ });
+
+ it("renders a card for each reminder category", () => {
+ const html = render();
+ for (const testId of [
+ "reminders-link-medication",
+ "reminders-link-vorsorge",
+ "reminders-link-mood",
+ "reminders-link-low-stock",
+ "reminders-link-coach",
+ ]) {
+ expect(html).toContain(`data-testid="${testId}"`);
+ }
+ });
+
+ it("separates categories (what/when) from channels (how/where)", () => {
+ const html = render();
+ expect(html).toContain("Reminders");
+ expect(html).toContain("Delivery channels");
+ });
+
+ it("resolves its copy in German too", () => {
+ const html = render("de");
+ expect(html).toContain("Medikamenten-Erinnerungen");
+ expect(html).toContain("Zustellkanäle");
+ });
+});
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 361c4a52..b10b03c0 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -61,10 +61,13 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// slugs (`dashboard`, `insights`, `medications`, `mood`) keep their
// routes so deep links resolve, but they are reached through the
// Layout hub instead of four standalone nav entries.
+ // v1.17.1 — `reminders` is the one "Reminders & Notifications" home and
+ // sits right after `notifications`.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
+ "reminders",
"layout",
"dashboard",
"insights",
diff --git a/src/components/settings/reminders-section.tsx b/src/components/settings/reminders-section.tsx
new file mode 100644
index 00000000..69846575
--- /dev/null
+++ b/src/components/settings/reminders-section.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import Link from "next/link";
+import {
+ Bell,
+ ChevronRight,
+ HeartPulse,
+ MessageCircle,
+ Package,
+ Pill,
+ Smile,
+ Stethoscope,
+ type LucideIcon,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.17.1 — the one "Reminders & Notifications" home.
+ *
+ * Before this section, "what gets reminded and when" was scattered across
+ * three unrelated homes: medication reminders lived per-medication on the med
+ * cards, mood + low-stock + coach-nudge lived inside the Notifications channel
+ * screen, and the new Vorsorge (preventive-care) reminders had their own
+ * page. There was no single place that answered "was erinnert mich woran,
+ * wann, und worüber".
+ *
+ * This hub is the single front door. It draws a clean line between two
+ * genuinely different concepts (so the maintainer rule "never split the same
+ * concept across a page" still holds):
+ *
+ * - CHANNELS — the *how/where* a reminder is delivered (APNs / Telegram /
+ * ntfy / Web Push / Webhook / Email). These keep living on the
+ * Notifications channel screen; this hub links to them.
+ * - CATEGORIES — the *what/when*: medication, mood, Vorsorge, low-stock
+ * runway, and the proactive Coach nudge. Each keeps its own canonical
+ * editor and deep link; the hub just gathers them so the concept reads
+ * as one place.
+ *
+ * Consolidation by linking — every existing editor stays intact and
+ * deep-linkable; nothing here is a rewrite.
+ */
+interface ReminderLink {
+ href: string;
+ icon: LucideIcon;
+ titleKey: string;
+ descriptionKey: string;
+ /** External-to-settings link (opens a feature page, not a settings tab). */
+ testId: string;
+}
+
+const CHANNEL_LINKS: ReadonlyArray = [
+ {
+ href: "/settings/notifications",
+ icon: Bell,
+ titleKey: "settings.sections.reminders.channels.title",
+ descriptionKey: "settings.sections.reminders.channels.description",
+ testId: "reminders-link-channels",
+ },
+];
+
+const CATEGORY_LINKS: ReadonlyArray = [
+ {
+ href: "/medications",
+ icon: Pill,
+ titleKey: "settings.sections.reminders.medication.title",
+ descriptionKey: "settings.sections.reminders.medication.description",
+ testId: "reminders-link-medication",
+ },
+ {
+ href: "/vorsorge",
+ icon: Stethoscope,
+ titleKey: "settings.sections.reminders.vorsorge.title",
+ descriptionKey: "settings.sections.reminders.vorsorge.description",
+ testId: "reminders-link-vorsorge",
+ },
+ {
+ href: "/settings/notifications#mood-reminder",
+ icon: Smile,
+ titleKey: "settings.sections.reminders.mood.title",
+ descriptionKey: "settings.sections.reminders.mood.description",
+ testId: "reminders-link-mood",
+ },
+ {
+ href: "/settings/notifications#low-stock",
+ icon: Package,
+ titleKey: "settings.sections.reminders.lowStock.title",
+ descriptionKey: "settings.sections.reminders.lowStock.description",
+ testId: "reminders-link-low-stock",
+ },
+ {
+ href: "/settings/notifications#coach-nudge",
+ icon: MessageCircle,
+ titleKey: "settings.sections.reminders.coach.title",
+ descriptionKey: "settings.sections.reminders.coach.description",
+ testId: "reminders-link-coach",
+ },
+];
+
+function ReminderLinkCard({ link }: { link: ReminderLink }) {
+ const { t } = useTranslations();
+ const Icon = link.icon;
+ return (
+
+
+ );
+}
diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx
index 554c26ce..e437f3b9 100644
--- a/src/components/settings/section-placeholder.tsx
+++ b/src/components/settings/section-placeholder.tsx
@@ -12,6 +12,7 @@
*/
import {
+ AlarmClock,
Bell,
Download,
Info,
@@ -37,6 +38,7 @@ const SLUG_ICON: Record = {
account: User,
integrations: Link2,
notifications: Bell,
+ reminders: AlarmClock,
layout: LayoutDashboard,
dashboard: LayoutDashboard,
insights: TrendingUp,
diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts
index fd886ef7..87e9723b 100644
--- a/src/components/settings/section-slugs.ts
+++ b/src/components/settings/section-slugs.ts
@@ -27,10 +27,16 @@
// `layout` entry instead of four scattered "arrange" entries, so the
// concept reads as one place. The four editor slugs stay in this list so
// their routes still resolve; they are simply not listed in the shell nav.
+// v1.17.1 — `reminders` sits right after `notifications`: the one
+// "Reminders & Notifications" home that gathers the scattered reminder
+// categories (medication, mood, Vorsorge, low-stock, coach nudge) and links
+// to the notification channels. The canonical editors keep their own routes;
+// the hub is consolidation by linking.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
+ "reminders",
"layout",
"dashboard",
"insights",
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 530c4896..28d2c168 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -18,6 +18,7 @@ import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
+ AlarmClock,
Bell,
Download,
Info,
@@ -89,6 +90,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.notifications.title",
icon: Bell,
},
+ // v1.17.1 — the one "Reminders & Notifications" home. Gathers the
+ // scattered reminder categories (medication / mood / Vorsorge / low-stock
+ // / coach nudge) and links to the notification channels. The canonical
+ // editors keep their own routes; this nav entry is the single front door.
+ {
+ slug: "reminders",
+ titleKey: "settings.sections.reminders.title",
+ icon: AlarmClock,
+ },
// v1.17.1 (F-2) — one "Layout & Personalization" nav entry replaces the
// four scattered Dashboard / Insights / Medications / Mood "arrange"
// entries. Those routes still resolve (deep links, page-header cogs, and
From ac6910716989a3616fcdf68d635db7d1eb5adb68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:00:43 +0200
Subject: [PATCH 56/79] chore(compose): whitelist VAPID and APNs env vars so
the documented env path reaches the container
---
docker-compose.yml | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/docker-compose.yml b/docker-compose.yml
index 1c6b5798..ac73397f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -119,6 +119,23 @@ services:
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASS: "${SMTP_PASS:-}"
SMTP_SECURE: "${SMTP_SECURE:-}"
+ # v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores
+ # these in the DB (the supported path); these env vars are the fallback the
+ # loader reads when the DB pair is unset. Listed here so the documented env
+ # path reaches the container under the bundled compose. See
+ # docs/self-hosting/notifications.md.
+ VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
+ VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
+ VAPID_SUBJECT: "${VAPID_SUBJECT:-}"
+ # APNs for a self-hosted native iOS build (optional, env-only — no admin UI
+ # for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over
+ # APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production".
+ # PWA users need none of this (Web Push covers them). See
+ # docs/self-hosting/notifications.md.
+ APNS_KEY_B64: "${APNS_KEY_B64:-}"
+ APNS_KEY_ID: "${APNS_KEY_ID:-}"
+ APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
+ APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
depends_on:
db:
condition: service_healthy
From 3d6127a1385b89bf87a08b3197fc08419d9253c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:17:01 +0200
Subject: [PATCH 57/79] chore(labs): drop the unwired Vorsorge lab-lookup and
its dead trend key
The annual-blood-panel auto-resolve that findLatestLabResultForAnalytes was
meant to feed never got wired into the Vorsorge reminder, so the export sits
with zero callers and the labAnalyteTrend query-key has no consumer (the
sparkline is prop-fed and there is no labs trend endpoint). Remove both so the
knip gate is clean; the Vorsorge lab read-back can come back with the wiring
that needs it.
---
src/lib/labs/store.ts | 52 +++-----------------------------------
src/lib/query-keys/labs.ts | 4 ---
2 files changed, 4 insertions(+), 52 deletions(-)
diff --git a/src/lib/labs/store.ts b/src/lib/labs/store.ts
index b50ab1e0..c8e9625d 100644
--- a/src/lib/labs/store.ts
+++ b/src/lib/labs/store.ts
@@ -1,23 +1,14 @@
/**
* v1.17.1 — server-side helpers for the structured lab-result store.
*
- * Two concerns live here:
- *
- * 1. The AES-256-GCM ↔ `Bytes` codec for `LabResult.noteEncrypted`. The
- * free-text note is the only sensitive column on the model; it shares
- * the `encrypt()` string format (`"."`) every other
- * `*Encrypted` column uses, encoded as UTF-8 bytes.
- *
- * 2. `findLatestLabResultForAnalytes` — the read point the Vorsorge
- * annual-blood-panel reminder consults to decide whether a panel was
- * recorded inside its lead-time window. Exposed here, NOT coupled to
- * the reminder's code, so the reminder can mark itself satisfied
- * without this module taking a dependency on it.
+ * The AES-256-GCM ↔ `Bytes` codec for `LabResult.noteEncrypted`. The
+ * free-text note is the only sensitive column on the model; it shares the
+ * `encrypt()` string format (`"."`) every other `*Encrypted`
+ * column uses, encoded as UTF-8 bytes.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
-import { prisma } from "@/lib/db";
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
export function encryptNoteToBytes(plaintext: string): Uint8Array {
@@ -34,38 +25,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array {
export function decryptNoteFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
-
-/**
- * Latest live (non-tombstoned) lab result for any of the given analytes,
- * recorded at or after `since`. Returns the single most-recent matching
- * row by `takenAt`, or `null` when no matching result exists in the window.
- *
- * Case-insensitive analyte match so "HbA1c" / "hba1c" resolve the same — a
- * lab report's casing is not the user's concern. The Vorsorge reminder
- * passes the analytes that satisfy an annual blood panel (e.g. an HbA1c or
- * a lipid marker) plus the reminder's window start; a non-null return means
- * the panel was recorded and the reminder can mark itself satisfied.
- */
-export async function findLatestLabResultForAnalytes(
- userId: string,
- analytes: string[],
- since: Date,
-): Promise<{ id: string; analyte: string; takenAt: Date } | null> {
- if (analytes.length === 0) return null;
- const candidates = await prisma.labResult.findMany({
- where: {
- userId,
- deletedAt: null,
- takenAt: { gte: since },
- // Prisma has no case-insensitive `in`; match each analyte
- // case-insensitively and let the DB OR them together.
- OR: analytes.map((a) => ({
- analyte: { equals: a, mode: "insensitive" as const },
- })),
- },
- orderBy: { takenAt: "desc" },
- take: 1,
- select: { id: true, analyte: true, takenAt: true },
- });
- return candidates[0] ?? null;
-}
diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts
index 374b5975..8f47ac82 100644
--- a/src/lib/query-keys/labs.ts
+++ b/src/lib/query-keys/labs.ts
@@ -26,8 +26,4 @@ export const labKeys = {
params.page,
params.sortDir,
] as const,
-
- /** Per-analyte trend series (≥2 values) shown inline on a grouped card. */
- labAnalyteTrend: (analyte: string) =>
- ["lab-results", "trend", analyte] as const,
};
From 290f46fcda321bc2a446adfd26251eb8f81a6b45 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:19:25 +0200
Subject: [PATCH 58/79] fix(sleep): label Polar nights reconstructed and emit
their awake time
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Polar exposes only per-stage duration totals, so its mapper already
reconstructs a contiguous CORE-DEEP-REM order exactly like WHOOP and flags
each segment reconstructed. The night reader's honesty allow-list only held
WHOOP, so a Polar-won night was served with reconstructed:false — presenting
a synthetic stage order to web and iOS as if it were the device's measured
timing. Add POLAR to the allow-list so the layout is labelled approximate.
Polar's total_interruption_duration carried the night's awake time but was
dropped, so every Polar night read as fully consolidated (awakeMinutes:null,
awakenings:0) with overstated asleep-vs-in-bed efficiency. Lay it as a
leading AWAKE segment so the asleep stages partition the remaining window and
the reader surfaces real awake time.
WHOOP and Polar independently implemented the same contiguous-timeline walk.
Extract a shared reconstructContiguousSleepTimeline helper under src/lib/sleep
and call it from both; each mapper keeps only its field normalisation. Oura's
measured 5-min hypnogram is a different algorithm and is left untouched.
---
.../analytics/__tests__/sleep-night.test.ts | 16 +++
src/lib/analytics/sleep-night.ts | 10 +-
src/lib/polar/__tests__/client.test.ts | 47 +++++++
src/lib/polar/client.ts | 69 +++++-----
.../__tests__/reconstruct-timeline.test.ts | 90 ++++++++++++
src/lib/sleep/reconstruct-timeline.ts | 129 ++++++++++++++++++
src/lib/whoop/client.ts | 76 ++++-------
7 files changed, 351 insertions(+), 86 deletions(-)
create mode 100644 src/lib/sleep/__tests__/reconstruct-timeline.test.ts
create mode 100644 src/lib/sleep/reconstruct-timeline.ts
diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts
index 00221cea..b10ce131 100644
--- a/src/lib/analytics/__tests__/sleep-night.test.ts
+++ b/src/lib/analytics/__tests__/sleep-night.test.ts
@@ -399,6 +399,22 @@ describe("reconstructSleepSessions", () => {
expect(new Set(ends).size).toBe(ends.length);
});
+ it("flags a Polar-won night as reconstructed (synthetic stage order)", () => {
+ // Polar exposes only per-stage duration totals, so its mapper reconstructs
+ // a contiguous CORE→DEEP→REM order exactly like WHOOP. The reader must label
+ // such a night reconstructed so the UI never presents the synthetic order as
+ // measured timing.
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "POLAR"),
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "POLAR"),
+ srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "POLAR"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "UTC");
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].source).toBe("POLAR");
+ expect(sessions[0].reconstructed).toBe(true);
+ });
+
it("does NOT flag an Apple-Health night as reconstructed (measured timeline)", () => {
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts
index 88024e6c..138d94e2 100644
--- a/src/lib/analytics/sleep-night.ts
+++ b/src/lib/analytics/sleep-night.ts
@@ -285,13 +285,15 @@ const NO_SOURCE = "__none__";
* Sources whose per-segment stage ORDER is synthesised, not measured. WHOOP's
* v2 API exposes only per-stage duration totals (no onset timestamps), so the
* ingest mapper reconstructs a contiguous timeline in a fixed physiological
- * order (`whoop/client.ts` `mapSleep`). A session won by such a source carries
- * `reconstructed: true` so the UI labels it as an approximate layout. Apple
- * Health / Withings / Fitbit all store a real per-segment series and stay off
- * this set.
+ * order (`whoop/client.ts` + `polar/client.ts` `mapSleep`, both via the shared
+ * `reconstructContiguousSleepTimeline` builder). A session won by such a source
+ * carries `reconstructed: true` so the UI labels it as an approximate layout.
+ * Apple Health / Oura / Withings / Fitbit all store a real per-segment series
+ * and stay off this set.
*/
const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet = new Set([
"WHOOP",
+ "POLAR",
]);
/**
diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts
index d68ae79f..81ad2ff9 100644
--- a/src/lib/polar/__tests__/client.test.ts
+++ b/src/lib/polar/__tests__/client.test.ts
@@ -265,6 +265,53 @@ describe("mapSleep", () => {
).toBe(endMs);
});
+ it("emits an AWAKE segment from total_interruption_duration", () => {
+ const start = "2026-06-09T23:00:00+02:00"; // 21:00 UTC
+ const s: PolarSleep = {
+ date: "2026-06-10",
+ sleep_start_time: start,
+ sleep_end_time: "2026-06-10T07:00:00+02:00",
+ total_interruption_duration: 900, // 15 min awake
+ light_sleep: 3600, // 60 min
+ deep_sleep: 1800, // 30 min
+ rem_sleep: 5400, // 90 min
+ sleep_score: 82,
+ };
+ const mapped = mapSleep(s);
+ const onset = Date.parse(start);
+
+ // AWAKE is laid first (leading settling-in block), so the asleep stages
+ // partition the rest of the window and the night reader can surface real
+ // awake time + efficiency rather than a fully consolidated night.
+ const awake = mapped.find((m) => m.sleepStage === "AWAKE")!;
+ expect(awake).toBeDefined();
+ expect(awake.value).toBe(15);
+ expect(awake.reconstructed).toBe(true);
+ expect(awake.externalId).toBe("sleep:2026-06-10:seg:sleep_awake:0");
+ // AWAKE ends at +15m; CORE then starts there and is index 1.
+ expect(awake.measuredAt.getTime()).toBe(onset + 15 * 60_000);
+
+ const core = mapped.find((m) => m.sleepStage === "CORE")!;
+ expect(core.externalId).toBe("sleep:2026-06-10:seg:sleep_core:1");
+ expect(core.measuredAt.getTime()).toBe(onset + (15 + 60) * 60_000);
+ });
+
+ it("omits AWAKE when no interruption time is reported", () => {
+ const mapped = mapSleep({
+ date: "2026-06-10",
+ sleep_start_time: "2026-06-09T23:00:00+02:00",
+ sleep_end_time: "2026-06-10T07:00:00+02:00",
+ light_sleep: 3600,
+ deep_sleep: 1800,
+ rem_sleep: 5400,
+ });
+ expect(mapped.find((m) => m.sleepStage === "AWAKE")).toBeUndefined();
+ // CORE stays index 0 when AWAKE is absent.
+ expect(mapped.find((m) => m.sleepStage === "CORE")!.externalId).toBe(
+ "sleep:2026-06-10:seg:sleep_core:0",
+ );
+ });
+
it("falls back to a midnight-UTC anchor when the window is missing", () => {
const mapped = mapSleep({
date: "2026-06-10",
diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts
index 1de24b07..7a8395d2 100644
--- a/src/lib/polar/client.ts
+++ b/src/lib/polar/client.ts
@@ -22,6 +22,7 @@
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
+import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
const POLAR_API_BASE = "https://www.polaraccesslink.com";
@@ -481,51 +482,47 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs;
const stages: Array<
- [number | null | undefined, NonNullable, string]
+ [number | null | undefined, "CORE" | "DEEP" | "REM" | "AWAKE", string]
> = [
+ // AWAKE first — Polar reports the night's total interruption time, but no
+ // per-stage onset. Lay it as a leading settling-in/awake block so the asleep
+ // stages partition the remaining window and the night reader can surface a
+ // real awakeMinutes / efficiency rather than reading the night as fully
+ // consolidated. Matches WHOOP's leading-AWAKE ordering.
+ [s.total_interruption_duration, "AWAKE", "sleep_awake"],
[s.light_sleep, "CORE", "sleep_core"],
[s.deep_sleep, "DEEP", "sleep_deep"],
[s.rem_sleep, "REM", "sleep_rem"],
];
if (haveWindow) {
- // Reconstructed timeline: lay each asleep stage contiguously from onset, one
- // timed row per segment ending at the running cursor. The order is
- // synthetic (Polar gives no per-stage onset), so flag every segment.
- let cursor = startMs;
- let segIndex = 0;
- for (const [sec, stage, fieldTag] of stages) {
- if (typeof sec !== "number" || sec <= 0) continue;
- const segEnd = cursor + sec * 1000;
- out.push({
- type: "SLEEP_DURATION",
- value: round2(sec * SEC_TO_MIN),
- unit: "minutes",
- measuredAt: new Date(segEnd),
- fieldTag,
+ // Reconstructed timeline: lay each stage contiguously from onset, one timed
+ // row per segment ending at the running cursor. The order is synthetic
+ // (Polar gives no per-stage onset), so the shared builder flags every
+ // segment reconstructed; the same algorithm backs WHOOP's `mapSleep`.
+ out.push(
+ ...reconstructContiguousSleepTimeline({
+ startMs,
+ stages: stages.map(([sec, stage, fieldTag]) => ({
+ durationMs:
+ typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec,
+ stage,
+ fieldTag,
+ })),
+ // IN_BED — single envelope row over the whole sleep window, stamped at
+ // the sleep END so the in-bed reader resolves the span back to
+ // [start, end].
+ inBed: {
+ durationMs: endMs - startMs,
+ measuredAt: new Date(endMs),
+ fieldTag: "sleep_in_bed",
+ },
// Indexed externalId keeps the several segment rows of one night
// distinct under userId_type_source_externalId.
- externalId: `sleep:${s.date}:seg:${fieldTag}:${segIndex}`,
- sleepStage: stage,
- reconstructed: true,
- });
- cursor = segEnd;
- segIndex += 1;
- }
-
- // IN_BED — single envelope row over the whole sleep window, stamped at the
- // sleep END so the in-bed reader resolves the span back to [start, end].
- const inBedSec = (endMs - startMs) / 1000;
- if (inBedSec > 0) {
- out.push({
- type: "SLEEP_DURATION",
- value: round2(inBedSec * SEC_TO_MIN),
- unit: "minutes",
- measuredAt: new Date(endMs),
- fieldTag: "sleep_in_bed",
- sleepStage: "IN_BED",
- });
- }
+ externalIdFor: (fieldTag, index) =>
+ `sleep:${s.date}:seg:${fieldTag}:${index}`,
+ }),
+ );
if (typeof s.sleep_score === "number") {
out.push({
diff --git a/src/lib/sleep/__tests__/reconstruct-timeline.test.ts b/src/lib/sleep/__tests__/reconstruct-timeline.test.ts
new file mode 100644
index 00000000..121eb22a
--- /dev/null
+++ b/src/lib/sleep/__tests__/reconstruct-timeline.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest";
+
+import { reconstructContiguousSleepTimeline } from "../reconstruct-timeline";
+
+describe("reconstructContiguousSleepTimeline", () => {
+ const onset = Date.parse("2026-06-09T21:00:00.000Z");
+ const end = Date.parse("2026-06-10T05:00:00.000Z");
+
+ it("lays stages contiguously from onset, each ending at its own instant", () => {
+ const rows = reconstructContiguousSleepTimeline({
+ startMs: onset,
+ stages: [
+ { durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
+ { durationMs: 30 * 60_000, stage: "DEEP", fieldTag: "sleep_deep" },
+ { durationMs: 90 * 60_000, stage: "REM", fieldTag: "sleep_rem" },
+ ],
+ externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
+ });
+ const core = rows.find((r) => r.sleepStage === "CORE")!;
+ const deep = rows.find((r) => r.sleepStage === "DEEP")!;
+ const rem = rows.find((r) => r.sleepStage === "REM")!;
+ expect(core.value).toBe(60);
+ expect(core.measuredAt.getTime()).toBe(onset + 60 * 60_000);
+ expect(deep.measuredAt.getTime()).toBe(onset + 90 * 60_000);
+ expect(rem.measuredAt.getTime()).toBe(onset + 180 * 60_000);
+ // Distinct instants → ordered hypnogram, not a shared right edge.
+ expect(new Set(rows.map((r) => r.measuredAt.getTime())).size).toBe(3);
+ });
+
+ it("flags every laid segment reconstructed and keys an indexed externalId", () => {
+ const rows = reconstructContiguousSleepTimeline({
+ startMs: onset,
+ stages: [
+ { durationMs: 15 * 60_000, stage: "AWAKE", fieldTag: "sleep_awake" },
+ { durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
+ ],
+ externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
+ });
+ expect(rows.every((r) => r.reconstructed === true)).toBe(true);
+ expect(rows[0].externalId).toBe("night:seg:sleep_awake:0");
+ expect(rows[1].externalId).toBe("night:seg:sleep_core:1");
+ });
+
+ it("skips non-positive, null, undefined and non-finite durations", () => {
+ const rows = reconstructContiguousSleepTimeline({
+ startMs: onset,
+ stages: [
+ { durationMs: 0, stage: "AWAKE", fieldTag: "sleep_awake" },
+ { durationMs: null, stage: "CORE", fieldTag: "sleep_core" },
+ { durationMs: undefined, stage: "DEEP", fieldTag: "sleep_deep" },
+ { durationMs: Number.NaN, stage: "REM", fieldTag: "sleep_rem" },
+ { durationMs: 30 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
+ ],
+ externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
+ });
+ expect(rows).toHaveLength(1);
+ // The only laid stage keeps index 0 (skipped stages do not consume indices).
+ expect(rows[0].externalId).toBe("night:seg:sleep_core:0");
+ expect(rows[0].measuredAt.getTime()).toBe(onset + 30 * 60_000);
+ });
+
+ it("appends a single IN_BED envelope at the given END instant", () => {
+ const rows = reconstructContiguousSleepTimeline({
+ startMs: onset,
+ stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }],
+ inBed: {
+ durationMs: end - onset,
+ measuredAt: new Date(end),
+ fieldTag: "sleep_in_bed",
+ },
+ externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
+ });
+ const inBed = rows.find((r) => r.sleepStage === "IN_BED")!;
+ expect(inBed.measuredAt.getTime()).toBe(end);
+ expect(inBed.value).toBe(480); // 8 h
+ // IN_BED is an envelope, not a placed segment — no reconstructed flag / id.
+ expect(inBed.reconstructed).toBeUndefined();
+ expect(inBed.externalId).toBeUndefined();
+ });
+
+ it("omits IN_BED when its duration is missing or non-positive", () => {
+ const rows = reconstructContiguousSleepTimeline({
+ startMs: onset,
+ stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }],
+ inBed: { durationMs: 0, measuredAt: new Date(end), fieldTag: "sleep_in_bed" },
+ externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
+ });
+ expect(rows.find((r) => r.sleepStage === "IN_BED")).toBeUndefined();
+ });
+});
diff --git a/src/lib/sleep/reconstruct-timeline.ts b/src/lib/sleep/reconstruct-timeline.ts
new file mode 100644
index 00000000..cc0bfa33
--- /dev/null
+++ b/src/lib/sleep/reconstruct-timeline.ts
@@ -0,0 +1,129 @@
+/**
+ * Shared reconstructed-sleep-timeline builder.
+ *
+ * Some wearables (WHOOP v2, Polar) expose only per-stage DURATION totals for a
+ * night — no per-stage onset timestamps and no hypnogram endpoint. Stamping
+ * every stage total on the one sleep-END instant collapses the hypnogram into
+ * overlapping spans that all touch the night's right edge. Since the API never
+ * carries an order, both vendors RECONSTRUCT an ordered, contiguous timeline:
+ * lay the stages back-to-back from sleep ONSET in a fixed physiological order,
+ * emitting one timed row per segment with `measuredAt = that segment's END`.
+ *
+ * The ORDER is synthetic, so every laid segment is flagged `reconstructed:
+ * true`; the night DTO advertises it and the UI labels such a night as an
+ * approximate layout, never as measured stage timing. The reader's honesty
+ * flag (`analytics/sleep-night.ts` `RECONSTRUCTED_TIMELINE_SOURCES`) carries
+ * the same contract on the read side.
+ *
+ * This helper is the single source of the algorithm; each vendor `mapSleep`
+ * keeps only its field-name → tuple normalisation (ms vs sec, source field
+ * names) so the segment shape and externalId scheme can never drift between the
+ * two synthetic-timeline vendors. Oura's measured 5-min hypnogram is a
+ * different algorithm and does NOT use this helper.
+ */
+
+/** The sleep-row shape both vendor mappers emit. Kept structurally identical to
+ * each vendor's local `MappedMeasurement`; the helper returns rows assignable to
+ * either. */
+export interface ReconstructedSleepRow {
+ type: "SLEEP_DURATION";
+ value: number;
+ unit: "minutes";
+ measuredAt: Date;
+ fieldTag: string;
+ externalId?: string;
+ sleepStage: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
+ reconstructed?: boolean;
+}
+
+/** One asleep/awake stage to lay onto the reconstructed timeline. `durationMs`
+ * is the stage total in milliseconds; non-positive or non-finite durations are
+ * skipped. */
+export interface ReconstructedStage {
+ durationMs: number | null | undefined;
+ stage: "CORE" | "DEEP" | "REM" | "AWAKE";
+ fieldTag: string;
+}
+
+/** Optional single IN_BED envelope row over the whole sleep window. The in-bed
+ * reader consumes the union envelope, so this stays one row stamped at the
+ * sleep END (`measuredAt`) — NOT a placed segment. */
+export interface ReconstructedInBed {
+ durationMs: number | null | undefined;
+ /** Sleep END instant; the in-bed reader resolves the span back to its window. */
+ measuredAt: Date;
+ fieldTag: string;
+}
+
+export interface ReconstructTimelineOptions {
+ /** Sleep ONSET instant; the contiguous walk starts here. */
+ startMs: number;
+ /** Stages in physiological lay-out order (e.g. AWAKE → CORE → DEEP → REM). */
+ stages: ReadonlyArray;
+ /** Optional IN_BED envelope row. */
+ inBed?: ReconstructedInBed;
+ /**
+ * Builds the indexed externalId for a laid segment so the several rows of one
+ * night stay distinct under `userId_type_source_externalId`. Called with the
+ * segment's fieldTag and its running index.
+ */
+ externalIdFor: (fieldTag: string, index: number) => string;
+}
+
+const MS_TO_MIN = 1 / 60_000;
+
+function round2(n: number): number {
+ return Math.round(n * 100) / 100;
+}
+
+/**
+ * Lay the asleep/awake stages contiguously from `startMs` in the given order,
+ * emitting one timed `SLEEP_DURATION` row per stage (`measuredAt` = that
+ * segment's END), each flagged `reconstructed: true` and keyed by an indexed
+ * externalId. Appends a single IN_BED envelope row when `inBed` is provided.
+ */
+export function reconstructContiguousSleepTimeline(
+ opts: ReconstructTimelineOptions,
+): ReconstructedSleepRow[] {
+ const { startMs, stages, inBed, externalIdFor } = opts;
+ const out: ReconstructedSleepRow[] = [];
+
+ let cursor = startMs;
+ let segIndex = 0;
+ for (const { durationMs, stage, fieldTag } of stages) {
+ if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) {
+ continue;
+ }
+ const segEnd = cursor + durationMs;
+ out.push({
+ type: "SLEEP_DURATION",
+ value: round2(durationMs * MS_TO_MIN),
+ unit: "minutes",
+ measuredAt: new Date(segEnd),
+ fieldTag,
+ externalId: externalIdFor(fieldTag, segIndex),
+ sleepStage: stage,
+ reconstructed: true,
+ });
+ cursor = segEnd;
+ segIndex += 1;
+ }
+
+ if (
+ inBed &&
+ typeof inBed.durationMs === "number" &&
+ Number.isFinite(inBed.durationMs) &&
+ inBed.durationMs > 0
+ ) {
+ out.push({
+ type: "SLEEP_DURATION",
+ value: round2(inBed.durationMs * MS_TO_MIN),
+ unit: "minutes",
+ measuredAt: inBed.measuredAt,
+ fieldTag: inBed.fieldTag,
+ sleepStage: "IN_BED",
+ });
+ }
+
+ return out;
+}
diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts
index 19c30ddc..84d0a01a 100644
--- a/src/lib/whoop/client.ts
+++ b/src/lib/whoop/client.ts
@@ -17,6 +17,7 @@
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
+import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
@@ -611,55 +612,38 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
// the remaining window.
const onset = new Date(s.start).getTime();
// Order the laid-out stages: a brief AWAKE settling-in block, then the asleep
- // stages CORE → DEEP → REM. Each carries its own duration; the cursor walks
- // forward so the spans are contiguous and non-overlapping.
- const TIMELINE_ORDER: ReadonlyArray<{
- key: keyof typeof stages;
- stage: NonNullable;
- tag: string;
- }> = [
- { key: "total_awake_time_milli", stage: "AWAKE", tag: "sleep_awake" },
- { key: "total_light_sleep_time_milli", stage: "CORE", tag: "sleep_core" },
- { key: "total_slow_wave_sleep_time_milli", stage: "DEEP", tag: "sleep_deep" },
- { key: "total_rem_sleep_time_milli", stage: "REM", tag: "sleep_rem" },
- ];
- let cursor = onset;
- let segIndex = 0;
- for (const { key, stage, tag } of TIMELINE_ORDER) {
- const ms = stages[key];
- if (typeof ms !== "number" || ms <= 0) continue;
- const segEnd = cursor + ms;
- out.push({
- type: "SLEEP_DURATION",
- value: round2(ms * MS_TO_MIN),
- unit: "minutes",
- measuredAt: new Date(segEnd),
+ // stages CORE → DEEP → REM. Each carries its own duration; the shared builder
+ // walks the cursor forward so the spans are contiguous and non-overlapping,
+ // and lays the IN_BED envelope over the whole window. The order is synthetic
+ // (WHOOP gives no per-stage onset), so every laid segment is flagged
+ // reconstructed; the same algorithm backs Polar's `mapSleep`.
+ out.push(
+ ...reconstructContiguousSleepTimeline({
+ startMs: onset,
+ stages: [
+ { durationMs: stages.total_awake_time_milli, stage: "AWAKE", fieldTag: "sleep_awake" },
+ { durationMs: stages.total_light_sleep_time_milli, stage: "CORE", fieldTag: "sleep_core" },
+ {
+ durationMs: stages.total_slow_wave_sleep_time_milli,
+ stage: "DEEP",
+ fieldTag: "sleep_deep",
+ },
+ { durationMs: stages.total_rem_sleep_time_milli, stage: "REM", fieldTag: "sleep_rem" },
+ ],
+ // IN_BED — single envelope row over the whole sleep window. `measuredAt`
+ // is the sleep END so `segmentOf` resolves the span back to [start, end];
+ // the in-bed union-envelope reader consumes the full window.
+ inBed: {
+ durationMs: stages.total_in_bed_time_milli,
+ measuredAt,
+ fieldTag: "sleep_in_bed",
+ },
// The sync layer keys reconstructed rows by this externalId verbatim; the
// index keeps the several rows of one night distinct under
// userId_type_source_externalId.
- fieldTag: tag,
- externalId: `${s.id}:seg:${tag}:${segIndex}`,
- sleepStage: stage,
- reconstructed: true,
- });
- cursor = segEnd;
- segIndex += 1;
- }
-
- // IN_BED — single envelope row over the whole sleep window. `measuredAt` is
- // the sleep END so `segmentOf` resolves the span back to [start, end]; the
- // in-bed union-envelope reader consumes the full window.
- const inBedMs = stages.total_in_bed_time_milli;
- if (typeof inBedMs === "number" && inBedMs > 0) {
- out.push({
- type: "SLEEP_DURATION",
- value: round2(inBedMs * MS_TO_MIN),
- unit: "minutes",
- measuredAt,
- fieldTag: "sleep_in_bed",
- sleepStage: "IN_BED",
- });
- }
+ externalIdFor: (tag, index) => `${s.id}:seg:${tag}:${index}`,
+ }),
+ );
const need = s.score.sleep_needed;
const totalNeedMilli =
From 7a96a934bf099ea6981909da3ad9b0ccd9a59f85 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:20:49 +0200
Subject: [PATCH 59/79] refactor(import): derive the CSV example header from
the shared column list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The downloadable CSV example pinned its header as an inline string literal
while csv-measurements.ts exported CSV_EXAMPLE_COLUMNS as the documented
single source of truth for that order — so the two could silently drift.
Derive the example header from the constant; the column order now has one
owner and the previously unused export is wired in.
---
src/components/settings/import-panel.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx
index 2c0f8a1b..efd05b62 100644
--- a/src/components/settings/import-panel.tsx
+++ b/src/components/settings/import-panel.tsx
@@ -45,6 +45,7 @@ import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
+import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements";
// ─────────────────────────── Section wrapper ───────────────────────────
@@ -641,7 +642,7 @@ function JsonImportCard() {
* drift. Exported so a test can assert it stays a valid header.
*/
export const EXAMPLE_CSV = [
- "type,value,unit,measuredAt,glucoseContext,notes,externalId",
+ CSV_EXAMPLE_COLUMNS.join(","),
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,",
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
"BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,",
From 5a62d04651abb250a23c2d962bf5a0397bda4b3f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:21:10 +0200
Subject: [PATCH 60/79] fix(import): use the success token for import success
ticks
The three import success check-icons hardcoded text-emerald-500, bypassing
the semantic text-success token every other surface uses. The token is
AA-tuned per theme (a darker green in light mode) where emerald-500 is a
single fixed value, so the import ticks sat off-tone in light mode and could
fall short of the AA bar the light tokens are tuned for. Swap to text-success
to match the integration-card family.
---
src/components/settings/import-panel.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx
index efd05b62..f30fad3f 100644
--- a/src/components/settings/import-panel.tsx
+++ b/src/components/settings/import-panel.tsx
@@ -322,7 +322,7 @@ function AppleHealthImportCard() {
className="text-foreground flex items-start gap-2 text-xs"
>
@@ -568,7 +568,7 @@ function JsonImportCard() {
className="text-foreground flex items-start gap-2 text-xs"
>
@@ -791,7 +791,7 @@ function CsvImportCard() {
From c6beba22290134b899407b125d0a170e20725df5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:21:35 +0200
Subject: [PATCH 61/79] fix(import): keep the import cards on the SPA and
balance the trio
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The JSON and CSV cards linked to the in-app docs with a raw anchor, so
tapping the schema link triggered a full-page reload that discarded an
in-progress paste or upload in the sibling card; route them through the
Next Link instead. The three import cards also dropped to a two-column grid
at md, orphaning the CSV card alone on a second row — add a three-column
track at xl so the set reads as one family.
---
src/components/settings/import-panel.tsx | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx
index f30fad3f..c87efaee 100644
--- a/src/components/settings/import-panel.tsx
+++ b/src/components/settings/import-panel.tsx
@@ -26,6 +26,7 @@
*/
import { useCallback, useId, useRef, useState } from "react";
+import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
@@ -67,7 +68,7 @@ export function ImportPanel() {
{t("settings.sections.export.import.description")}
From 9f960a59d0da4d11d8e6b7bba610ac97ee95417a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:21:45 +0200
Subject: [PATCH 62/79] feat(import): show character counts and a Preview busy
state
The JSON and CSV paste boxes now cap input at the 16 MB body ceiling the
routes enforce and show a live character count beneath each, so an over-long
paste is bounded with feedback instead of silently rejected at submit. The
CSV Preview button also renders the same spinner the Import button does while
a dry-run is in flight, with motion-reduce honoured, so the tap no longer
looks inert on a slow preview.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
src/components/settings/import-panel.tsx | 24 ++++++++++++++++++++++++
7 files changed, 30 insertions(+)
diff --git a/messages/de.json b/messages/de.json
index 1d1ba962..025a470e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4006,6 +4006,7 @@
"import": {
"heading": "Import",
"description": "Importiere Daten aus einem Apple-Health-Export oder einer JSON-Datei. Bereits vorhandene Einträge werden übersprungen, ein erneuter Import ist also unbedenklich.",
+ "charCount": "{used} / {max} Zeichen",
"appleHealth": {
"title": "Apple Health",
"description": "Lade die export.zip aus der Health-App auf dem iPhone hoch (Profil → Alle Gesundheitsdaten exportieren).",
diff --git a/messages/en.json b/messages/en.json
index 3a8b08b1..b66c2383 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4006,6 +4006,7 @@
"import": {
"heading": "Import",
"description": "Bring data into HealthLog from an Apple Health export, a JSON file, or a CSV. Imports skip rows that already exist, so re-running one is safe.",
+ "charCount": "{used} / {max} characters",
"appleHealth": {
"title": "Apple Health",
"description": "Upload the export.zip from the Health app on iPhone (Profile → Export All Health Data).",
diff --git a/messages/es.json b/messages/es.json
index 210c603a..2c51a68b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4006,6 +4006,7 @@
"import": {
"heading": "Importar",
"description": "Importa datos a HealthLog desde una exportación de Apple Health o un archivo JSON. La importación omite las filas que ya existen, así que repetirla es seguro.",
+ "charCount": "{used} / {max} caracteres",
"appleHealth": {
"title": "Apple Health",
"description": "Sube el archivo export.zip de la app Salud del iPhone (Perfil → Exportar todos los datos de salud).",
diff --git a/messages/fr.json b/messages/fr.json
index e73f56d8..72f2d8a5 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4006,6 +4006,7 @@
"import": {
"heading": "Import",
"description": "Importez des données dans HealthLog depuis un export Apple Santé ou un fichier JSON. L'import ignore les lignes déjà présentes : le relancer est sans risque.",
+ "charCount": "{used} / {max} caractères",
"appleHealth": {
"title": "Apple Santé",
"description": "Importez le fichier export.zip de l'app Santé sur iPhone (Profil → Exporter toutes les données de santé).",
diff --git a/messages/it.json b/messages/it.json
index 5e20abdb..73ca3843 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4006,6 +4006,7 @@
"import": {
"heading": "Importa",
"description": "Importa dati in HealthLog da un'esportazione di Apple Salute o da un file JSON. L'importazione salta le righe già presenti, quindi ripeterla è sicuro.",
+ "charCount": "{used} / {max} caratteri",
"appleHealth": {
"title": "Apple Salute",
"description": "Carica il file export.zip dall'app Salute su iPhone (Profilo → Esporta tutti i dati sanitari).",
diff --git a/messages/pl.json b/messages/pl.json
index f62178eb..8270e74a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4006,6 +4006,7 @@
"import": {
"heading": "Import",
"description": "Zaimportuj dane do HealthLog z eksportu Apple Health lub pliku JSON. Import pomija wiersze, które już istnieją, więc ponowne uruchomienie jest bezpieczne.",
+ "charCount": "{used} / {max} znaków",
"appleHealth": {
"title": "Apple Health",
"description": "Prześlij plik export.zip z aplikacji Zdrowie na iPhonie (Profil → Eksportuj wszystkie dane zdrowotne).",
diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx
index c87efaee..879237ce 100644
--- a/src/components/settings/import-panel.tsx
+++ b/src/components/settings/import-panel.tsx
@@ -48,6 +48,13 @@ import { cn } from "@/lib/utils";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements";
+/**
+ * Upper bound for the paste textareas, mirroring the 16 MB server-side body
+ * ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste
+ * before it ever reaches the route and feeds the live character counter.
+ */
+const MAX_PASTE_CHARS = 16 * 1024 * 1024;
+
// ─────────────────────────── Section wrapper ───────────────────────────
export function ImportPanel() {
@@ -537,10 +544,17 @@ function JsonImportCard() {
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
+ maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder='{"measurements":[…],"moodEntries":[…]}'
className="font-mono text-xs"
/>
+
void send(true)}
data-testid="import-csv-preview"
>
+ {busy && (
+
+ )}
{t("settings.sections.export.import.csv.preview")}
Date: Mon, 15 Jun 2026 01:22:43 +0200
Subject: [PATCH 63/79] fix(insights): unify device-score tiles onto the shared
card and skeleton primitives
Route the recovery and sleep-quality device-score surfaces through the
canonical card and loading primitives so they read as one family:
- DeviceScoreTile titles itself with CardHeader/CardTitle + CardAction
and drops its bespoke header row and padding override, so it matches
the sibling sleep-debt and chronotype cards on the unified
p-4 md:p-6 token.
- Add DeviceScoreTileSkeleton / DeviceScoreGridSkeleton, a shared
tile-shaped placeholder that mirrors the loaded layout. Recovery and
sleep-quality paint it while their analytics slice loads instead of
popping in.
- Recovery's no-data branch now renders the shared EmptyState with an
icon rather than a hand-rolled div; the sleep-quality sub-section
keeps its calm collapse-to-null once data lands.
- New insights.recovery.emptyTitle key across all six locales.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
.../__tests__/device-score-surfaces.test.tsx | 34 +++++++++++
.../insights/device-score-tile-skeleton.tsx | 49 ++++++++++++++++
src/components/insights/device-score-tile.tsx | 56 ++++++++++---------
.../insights/recovery/recovery-section.tsx | 26 +++++++--
.../insights/sleep/sleep-quality-section.tsx | 19 ++++++-
11 files changed, 156 insertions(+), 34 deletions(-)
create mode 100644 src/components/insights/device-score-tile-skeleton.tsx
diff --git a/messages/de.json b/messages/de.json
index 1d1ba962..ff332a5f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Erholung",
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
+ "emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
"recharge": {
"title": "Recharge",
diff --git a/messages/en.json b/messages/en.json
index 3a8b08b1..2f0bc951 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Recovery",
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
+ "emptyTitle": "No recovery data yet",
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
"recharge": {
"title": "Recharge",
diff --git a/messages/es.json b/messages/es.json
index 210c603a..92e2f870 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Recuperación",
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
+ "emptyTitle": "Aún no hay datos de recuperación",
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
"recharge": {
"title": "Recarga",
diff --git a/messages/fr.json b/messages/fr.json
index e73f56d8..73dbb370 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Récupération",
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
+ "emptyTitle": "Pas encore de données de récupération",
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
"recharge": {
"title": "Recharge",
diff --git a/messages/it.json b/messages/it.json
index 5e20abdb..1c3001bf 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Recupero",
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
+ "emptyTitle": "Ancora nessun dato di recupero",
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
"recharge": {
"title": "Ricarica",
diff --git a/messages/pl.json b/messages/pl.json
index f62178eb..f4bbf165 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Regeneracja",
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
+ "emptyTitle": "Brak danych o regeneracji",
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
"recharge": {
"title": "Recharge",
diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx
index e861f6bc..537537f3 100644
--- a/src/components/insights/__tests__/device-score-surfaces.test.tsx
+++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx
@@ -80,6 +80,16 @@ function analyticsWith(summaries: Record) {
};
}
+function analyticsLoading() {
+ return {
+ data: undefined,
+ isLoading: true,
+ isEmpty: false,
+ error: null,
+ refetch: () => {},
+ };
+}
+
beforeEach(() => {
analyticsMock.mockReset();
});
@@ -133,6 +143,27 @@ describe(" gating", () => {
});
});
+describe("device-score loading skeletons", () => {
+ it("paints the shared Skeleton grid for sleep-quality while loading", () => {
+ analyticsMock.mockReturnValue(analyticsLoading());
+ const html = render();
+ expect(html).toContain('data-slot="sleep-quality-loading"');
+ expect(html).toContain('data-slot="device-score-grid-skeleton"');
+ expect(html).toContain('data-slot="skeleton"');
+ // No real tiles while the slice is still loading.
+ expect(html).not.toContain('data-slot="device-score-tile"');
+ });
+
+ it("paints the shared Skeleton grid for recovery while loading", () => {
+ analyticsMock.mockReturnValue(analyticsLoading());
+ const html = render();
+ expect(html).toContain('data-slot="recovery-loading"');
+ expect(html).toContain('data-slot="device-score-grid-skeleton"');
+ expect(html).toContain('data-slot="skeleton"');
+ expect(html).not.toContain('data-slot="recovery-empty"');
+ });
+});
+
describe(" data-gating", () => {
it("collapses to nothing when no sleep-quality metric has data", () => {
analyticsMock.mockReturnValue(
@@ -171,6 +202,9 @@ describe(" data-gating", () => {
);
const html = render();
expect(html).toContain('data-slot="recovery-empty"');
+ // Routed through the shared (dashed bordered card), not the
+ // old hand-rolled div — the empty title comes from the unified primitive.
+ expect(html).toContain("border-dashed");
expect(html).not.toContain('data-slot="recovery-group-strain"');
});
diff --git a/src/components/insights/device-score-tile-skeleton.tsx b/src/components/insights/device-score-tile-skeleton.tsx
new file mode 100644
index 00000000..6ad95042
--- /dev/null
+++ b/src/components/insights/device-score-tile-skeleton.tsx
@@ -0,0 +1,49 @@
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardHeader,
+} from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * v1.17.1 — loading placeholder that mirrors {@link DeviceScoreTile}'s
+ * layout so the device-score grids (recovery + sleep-quality) and the lab
+ * list paint the same tile shape while their analytics slice loads, instead
+ * of popping in or showing a bespoke centred spinner. One header row (title +
+ * latest readout) over a sparkline block, all via the shared `Skeleton`
+ * primitive (which carries `motion-reduce:animate-none` for free).
+ */
+export function DeviceScoreTileSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * A `count`-wide grid of {@link DeviceScoreTileSkeleton}s, matching the
+ * `grid gap-4 sm:grid-cols-2` the device-score sections render their tiles in.
+ */
+export function DeviceScoreGridSkeleton({ count = 2 }: { count?: number }) {
+ return (
+
From dba107de51a1c3c78f59d89cf6f006d75b728bfa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:22:57 +0200
Subject: [PATCH 65/79] fix(vorsorge): add a loading state and align the cards
on the shared primitives
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The reminder list read isLoading but rendered nothing, so the page
showed a header over blank space while fetching.
- Loading now paints a card-shaped Skeleton stack via the shared
primitive.
- The no-data case routes through the shared EmptyState with an icon
and an add action, matching the lab empty.
- Reminder cards title themselves with CardHeader/CardTitle + CardAction
and the form card drops its pt-6 override, so the card token owns the
vertical rhythm and the double-pad goes away. The card stays neutral —
due state still reads only through the discreet badge.
---
.../__tests__/vorsorge-section.test.tsx | 74 +++++++++++++++++
.../vorsorge-section.tsx | 81 +++++++++++++------
2 files changed, 129 insertions(+), 26 deletions(-)
create mode 100644 src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
new file mode 100644
index 00000000..66c83a99
--- /dev/null
+++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
@@ -0,0 +1,74 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
+
+/**
+ * v1.17.1 — the Vorsorge section's loading + empty affordances.
+ *
+ * Audit 02-H1/H2 + 11-H1: the list read `isLoading` but rendered nothing while
+ * fetching (a header over blank space). The fix paints a tile-shaped `Skeleton`
+ * stack while loading and routes the no-data case through the shared
+ * `` with an add action. These tests pin both.
+ */
+
+const remindersMock = vi.fn();
+vi.mock("@/hooks/use-measurement-reminders", () => ({
+ useMeasurementReminders: () => remindersMock(),
+ useMeasurementReminderMutations: () => ({
+ create: { mutate: vi.fn(), isPending: false },
+ remove: { mutate: vi.fn(), isPending: false },
+ satisfy: { mutate: vi.fn(), isPending: false },
+ }),
+}));
+
+import { VorsorgeSection } from "../vorsorge-section";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+beforeEach(() => {
+ remindersMock.mockReset();
+});
+
+describe(" loading + empty", () => {
+ it("paints the shared Skeleton stack while loading", () => {
+ remindersMock.mockReturnValue({ data: undefined, isLoading: true });
+ const html = render();
+ expect(html).toContain('data-slot="vorsorge-loading"');
+ expect(html).toContain('data-slot="skeleton"');
+ // No empty-state while still loading.
+ expect(html).not.toContain('data-slot="empty-state"');
+ });
+
+ it("routes the no-data case through the shared EmptyState with an action", () => {
+ remindersMock.mockReturnValue({ data: [], isLoading: false });
+ const html = render();
+ expect(html).toContain('data-slot="empty-state"');
+ expect(html).toContain("border-dashed");
+ expect(html).not.toContain('data-slot="vorsorge-loading"');
+ });
+
+ it("renders reminder cards once data lands", () => {
+ const reminder: MeasurementReminder = {
+ id: "r1",
+ label: "Annual blood panel",
+ measurementType: null,
+ intervalDays: 365,
+ rrule: null,
+ nextDueAt: null,
+ notifyHour: 9,
+ location: null,
+ enabled: true,
+ } as MeasurementReminder;
+ remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
+ const html = render();
+ expect(html).toContain("Annual blood panel");
+ expect(html).not.toContain('data-slot="empty-state"');
+ expect(html).not.toContain('data-slot="vorsorge-loading"');
+ });
+});
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index b8690097..162de30b 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -18,11 +18,18 @@ import { CheckCircle2, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
+import { Skeleton } from "@/components/ui/skeleton";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -137,7 +144,7 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
{showForm && (
-
+
- {/* Bottom utility links */}
+ {/* Bottom utility links — the shared utility tail (minus
+ Notifications, which lives in the avatar menu) with the
+ role-gated Admin entry inserted before Settings. */}
From 3fc195e1c09eb9c86d034a2a52660c2bc0be80b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:24:03 +0200
Subject: [PATCH 67/79] feat(settings): add a back-to-hub link on the Layout
child editors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The four Layout personalization editors (dashboard, insights, medications,
mood) are reached through the Layout & Personalization hub or a page-header
cog, and the left rail highlights the hub while the body shows the child.
The only return path was tapping the highlighted rail entry — no explicit
up-affordance, sharper on mobile where the rail collapses to a chip strip.
Add a shared back-to-hub link at the top of each child so the hub → child
→ hub loop reads clearly.
Also fix the stale doc-comment on the settings section route: the slug set
is 16 (12 nav-visible plus the four routable-but-hidden Layout children),
not eight.
---
src/app/settings/[section]/page.tsx | 6 ++-
src/components/settings/dashboard-section.tsx | 7 +++-
src/components/settings/insights-section.tsx | 7 +++-
.../settings/medications-section.tsx | 7 +++-
src/components/settings/mood-section.tsx | 7 +++-
.../settings/settings-hub-back-link.tsx | 39 +++++++++++++++++++
6 files changed, 67 insertions(+), 6 deletions(-)
create mode 100644 src/components/settings/settings-hub-back-link.tsx
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 87b97f90..ceee2abe 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -30,8 +30,10 @@ import {
import { SettingsShell } from "@/components/settings/settings-shell";
/**
- * Dynamic settings section route. Each of the eight `SETTINGS_SECTION_SLUGS`
- * is pre-rendered at build via `generateStaticParams()` so the URLs are
+ * Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
+ * (16 today — 12 nav-visible sections plus the four routable-but-hidden
+ * Layout child editors) is pre-rendered at build via
+ * `generateStaticParams()` so the URLs are
* statically known to Next.js, while the `dynamicParams = false` flag below
* tells the router to 404 (instead of attempting on-demand rendering) for any
* slug not in the list — which is exactly what `notFound()` would do at
diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx
index 59f4e4dc..346493ba 100644
--- a/src/components/settings/dashboard-section.tsx
+++ b/src/components/settings/dashboard-section.tsx
@@ -1,6 +1,7 @@
"use client";
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
+import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { useTranslations } from "@/lib/i18n/context";
export function DashboardSection() {
@@ -11,7 +12,11 @@ export function DashboardSection() {
aria-labelledby="settings-section-dashboard-title"
className="space-y-6"
>
-
+
+
{t("settings.sections.dashboard.title")}
diff --git a/src/components/settings/insights-section.tsx b/src/components/settings/insights-section.tsx
index aee780b3..73da8d53 100644
--- a/src/components/settings/insights-section.tsx
+++ b/src/components/settings/insights-section.tsx
@@ -2,6 +2,7 @@
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
+import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -23,7 +24,11 @@ export function InsightsSection() {
aria-labelledby="settings-section-insights-title"
className="space-y-6"
>
-
+
+
{t("settings.sections.insights.title")}
diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx
index 7e4830cf..224d0817 100644
--- a/src/components/settings/medications-section.tsx
+++ b/src/components/settings/medications-section.tsx
@@ -8,6 +8,7 @@ import {
type ReorderMedication,
} from "@/components/medications/medication-order-editor";
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
+import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
@@ -68,7 +69,11 @@ export function MedicationsSection() {
aria-labelledby="settings-section-medications-title"
className="space-y-6"
>
-
+
+
{t("settings.sections.medications.title")}
diff --git a/src/components/settings/mood-section.tsx b/src/components/settings/mood-section.tsx
index d8a82248..675a7554 100644
--- a/src/components/settings/mood-section.tsx
+++ b/src/components/settings/mood-section.tsx
@@ -6,6 +6,7 @@ import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card";
import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card";
import { TagManagerCard } from "@/components/mood/manage/tag-manager-card";
import { useMoodTagManage } from "@/components/mood/manage/use-mood-tag-manage";
+import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -44,7 +45,11 @@ export function MoodSection() {
aria-labelledby="settings-section-mood-title"
className="space-y-6"
>
-
+
+
{t("settings.sections.mood.title")}
diff --git a/src/components/settings/settings-hub-back-link.tsx b/src/components/settings/settings-hub-back-link.tsx
new file mode 100644
index 00000000..2f1933ca
--- /dev/null
+++ b/src/components/settings/settings-hub-back-link.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import Link from "next/link";
+import { ChevronLeft } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.17.1 (N-5) — the up-affordance for a hub child editor.
+ *
+ * The four Layout personalization editors (dashboard / insights /
+ * medications / mood) are reached through the "Layout & Personalization"
+ * hub (or a page-header cog), and the left rail highlights the hub while
+ * the body shows the child. The return path existed only implicitly (tap
+ * the highlighted rail entry); this is the explicit "← back to hub" link
+ * at the top of the child, so the hub → child → hub loop reads clearly,
+ * especially on mobile where the rail collapses to a chip strip.
+ */
+export function SettingsHubBackLink({
+ href,
+ labelKey,
+}: {
+ href: string;
+ labelKey: string;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ {t(labelKey)}
+
+ );
+}
From b87ded090cd8022aab4566e0561ce4326c38db0d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:27:49 +0200
Subject: [PATCH 68/79] fix(notifications): bound the SMTP transport with
explicit timeouts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The email sender built its nodemailer transport without connection,
greeting, or socket timeouts, so nodemailer's defaults applied — up to a
10-minute socket timeout. A mail server that accepts the TCP connection
then stalls (firewalled relay, overloaded Postfix, blackholed smarthost)
could hold a dispatch worker for minutes per notification, while every
other egress in the cascade is bounded. Pin connection/greeting at 10s
and socket at 20s so a stall maps to the transient send outcome the SMTP
classifier already retries, matching the webhook sender's ethos.
---
.../senders/__tests__/email.test.ts | 26 ++++++++++++++++---
src/lib/notifications/senders/email.ts | 8 ++++++
2 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts
index 53066c67..e7840330 100644
--- a/src/lib/notifications/senders/__tests__/email.test.ts
+++ b/src/lib/notifications/senders/__tests__/email.test.ts
@@ -8,15 +8,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sendMailMock = vi.fn();
-const createTransportMock = vi.fn((..._args: unknown[]) => ({
- sendMail: sendMailMock,
-}));
+const createTransportMock = vi.fn((options: unknown) => {
+ void options;
+ return { sendMail: sendMailMock };
+});
const loadEmailConfigMock = vi.fn();
const recordPushAttemptMock = vi.fn();
vi.mock("nodemailer", () => ({
default: {
- createTransport: (...args: unknown[]) => createTransportMock(...args),
+ createTransport: (options: unknown) => createTransportMock(options),
},
}));
@@ -86,6 +87,23 @@ describe("sendViaEmail", () => {
);
});
+ it("builds the transport with explicit SMTP timeouts (no unbounded hang)", async () => {
+ loadEmailConfigMock.mockReturnValue(transport);
+ sendMailMock.mockResolvedValue({ messageId: "1" });
+
+ await sendViaEmail({ recipient: "you@example.com" }, payload());
+
+ expect(createTransportMock).toHaveBeenCalledTimes(1);
+ const opts = createTransportMock.mock.calls[0][0] as {
+ connectionTimeout?: number;
+ greetingTimeout?: number;
+ socketTimeout?: number;
+ };
+ expect(opts.connectionTimeout).toBe(10_000);
+ expect(opts.greetingTimeout).toBe(10_000);
+ expect(opts.socketTimeout).toBe(20_000);
+ });
+
it("soft-skips when SMTP is unconfigured (no channel burn)", async () => {
loadEmailConfigMock.mockReturnValue(null);
diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts
index 289c7490..b072cb90 100644
--- a/src/lib/notifications/senders/email.ts
+++ b/src/lib/notifications/senders/email.ts
@@ -55,6 +55,14 @@ function getTransporter(): { transporter: Transporter; from: string } | null {
port: config.port,
secure: config.secure,
...(config.auth ? { auth: config.auth } : {}),
+ // Bound every phase of the SMTP exchange. nodemailer's defaults (2 min
+ // connect, 30 s greeting, 10 min socket) would let a hung/blackholed relay
+ // pin a dispatch worker for minutes per send. These caps map a stall to the
+ // transient `SendOutcome` `classifySmtpError` already retries, matching the
+ // webhook sender's 5 s ethos.
+ connectionTimeout: 10_000,
+ greetingTimeout: 10_000,
+ socketTimeout: 20_000,
});
cachedTransportKey = key;
return { transporter: cachedTransporter, from: config.from };
From 33e7f2073652236159158ddc09532889f8b51e0c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:27:57 +0200
Subject: [PATCH 69/79] perf(reminders): bound the Vorsorge cron scan to due
reminders
The measurement-reminder tick loaded every enabled reminder across all
tenants every 15 minutes and gated due-ness in Node, so the shipped
measurement_reminders_user_id_next_due_at_idx went unused. Add a
nextDueAt bound (not null, lte now plus one tick of slack) to the query
so Postgres scans only rows that could plausibly fire. nextDueAt is
stamped at the notify-hour boundary, so anything due is already past;
non-recurring reminders carry a null nextDueAt and can never fire, which
the in-Node predicate already short-circuits. A reminder satisfied early
re-anchors once it crosses due, before any nudge, so dropping future rows
is safe. The in-Node hour gate stays the authoritative fire decision.
---
.../__tests__/measurement-reminder.test.ts | 27 +++++++++++++++++++
src/lib/jobs/measurement-reminder.ts | 24 ++++++++++++++++-
2 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts
index 5c6ad36d..d64cd34a 100644
--- a/src/lib/jobs/__tests__/measurement-reminder.test.ts
+++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts
@@ -268,6 +268,33 @@ describe("runMeasurementReminderTick", () => {
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
});
+ it("bounds the scan with a nextDueAt filter so the index is used", async () => {
+ const { prisma } = makePrisma({
+ reminders: [reminder({})],
+ measurementMatch: null,
+ });
+ const dispatch = vi.fn(async () => OK);
+
+ await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
+
+ const calls = prisma.measurementReminder.findMany.mock
+ .calls as unknown as unknown[][];
+ const args = calls[0][0] as {
+ where: {
+ deletedAt: null;
+ enabled: boolean;
+ nextDueAt: { not: null; lte: Date };
+ };
+ };
+ expect(args.where.enabled).toBe(true);
+ expect(args.where.deletedAt).toBeNull();
+ expect(args.where.nextDueAt.not).toBeNull();
+ // Floor is now + one tick of slack; anything due (<= floor) is included.
+ expect(args.where.nextDueAt.lte.getTime()).toBe(
+ NINE_LOCAL.getTime() + 15 * 60_000,
+ );
+ });
+
it("skips a reminder outside its notify-hour window", async () => {
const { prisma } = makePrisma({
reminders: [reminder({ measurementType: null })],
diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts
index 7223d76e..2afa3168 100644
--- a/src/lib/jobs/measurement-reminder.ts
+++ b/src/lib/jobs/measurement-reminder.ts
@@ -41,6 +41,14 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
+/**
+ * Slack added to `now` when scanning for due reminders, so a `nextDueAt`
+ * stamped just ahead of a tick still falls inside the same hour window.
+ * One tick interval (15 min) — small enough that the in-Node hour gate stays
+ * the authoritative fire decision.
+ */
+const DUE_QUERY_SLACK_MS = 15 * 60_000;
+
export interface MeasurementReminderSummary {
candidatesScanned: number;
inWindow: number;
@@ -141,8 +149,22 @@ export async function runMeasurementReminderTick(
failed: 0,
};
+ // Bound the scan to reminders that could plausibly fire this tick so Postgres
+ // uses `measurement_reminders_user_id_next_due_at_idx` instead of loading the
+ // whole cross-tenant enabled set 4×/hour. `nextDueAt` is stamped at the
+ // notify-hour boundary, so anything due is already <= now; the small slack
+ // (one tick interval) covers a stamp landing just ahead of a :00 tick. A
+ // null `nextDueAt` is a non-recurring reminder that can never fire — the
+ // in-Node `evaluateMeasurementReminderDue` short-circuits it anyway, so
+ // excluding it here is parity. Reminders satisfied early re-anchor once they
+ // cross due, before any nudge fires, so dropping future rows is safe.
+ const dueFloor = new Date(now.getTime() + DUE_QUERY_SLACK_MS);
const reminders = await prisma.measurementReminder.findMany({
- where: { deletedAt: null, enabled: true },
+ where: {
+ deletedAt: null,
+ enabled: true,
+ nextDueAt: { not: null, lte: dueFloor },
+ },
include: {
user: {
select: {
From 790fecddb843a352168219c816a8a2db9fdf3de4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:28:04 +0200
Subject: [PATCH 70/79] fix(doctor-report): aggregate sleep per night, not per
stage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The doctor-report PDF computed SLEEP_DURATION min/avg/max over the raw
per-stage rows (a 40-minute DEEP block, a 12-minute AWAKE block), so its
sleep figure was per-stage-row minutes while every other surface — the
dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed —
shows the reconstructed per-night asleep total. The sleep-stamping work
rewrites exactly those raw rows, so the report could drift independently.
Route the report sleep value through the night reconstruction engine, the
same way the recovery score routes through the canonical recovery resolver
— one number, every surface. The user's source priority feeds the
reconstruction so the multi-source de-dup matches the dashboard.
---
src/lib/__tests__/doctor-report-data.test.ts | Bin 10248 -> 12832 bytes
src/lib/doctor-report-data.ts | 39 ++++++++++++++++++-
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts
index a1fae17148cb5765f054afeb688a737d98baa530..9c3bfc1f14138b9310fca171e47f9f09f575f1e3 100644
GIT binary patch
delta 2556
zcmaJ@O>f&q5Jj%ZrI)rqPXnDuSfXjwZDSb@px8p{Hnt2~X@D9=ye3!TBIGW+Tw1aN
zgE#M
z3!}@(oTZ%S$6}P2qKQw6GRv42w|wQ?+~geW*_mM@eyYYFHJo-|L(X-pKwuTe8Zeet;{rSe?d`CY2?_iC(;i%h9j)dg?ES@MJ7m~m+Gi@D{
z*bwTI!0JX^MjVD(W%f*^F;tq3=k20U!%{{@C<)Xa(v2qy)xlp{$PqG5r}LjRvmK<8
znDjf#nzK?N`I=J8`UgP98B)vRuIW@D`4|pRDwM`2s_A9?*3?@P7_}2k{E1nXb6`#V9rd^S)6FsyP^rPRyo{qty)eL
zO)%^&Cn-#$<-F2vw=U_1v`aI%vJ0(N!OMi!N;`|IQlv3X(PFEubPDY4`i0<;ZV}3D
z6Gt$uQa>1q)Nt+9Lhz(r*)njTkLpeM3_>>@zSC(fE4#*jq9LM;E_INx+*=<8ou{?$
z+i9rg{RremCEkqss${Y9clG6V19u2!-7Z}&
zKJ|C28J^kH!NafjcBn1ic$?rx6b5ivQewJ?)=j!p2H3Q=>1BnxQ;cwNIgosk^T>Ga
z>T{g=RbW)_my)I4IZo_FO>S-JD!2(ERZ8Qd<#XfEHW}Ii0U}ygRv!@rrU+om;GEfeVvOV5~~K5U!RKjeEgf$8J$f?_ON5mb|um
zXa~$atLzmkxj=3;am<{E7Tb!jW(u<{xBAjnraG`)S}ynqM-oLi@q!?jIs4PvaUnPf
z6U!Ag#F-1Z*1SEnMxIXGzZ+H*T+@x$u|?H?Wu$^s$q`oJ4oEJlSlr%xVl>ZqEm_y&
Z-8y>>c43?lFKWs)eSJY6ufM$i>_7XzE7Je~
delta 17
ZcmZ3G(h;y>D&uAzrk|plmnl{W002XU2KE2|
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index 62ddc5ee..f08da5c0 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -27,6 +27,10 @@ import type {
MeasurementSource,
} from "@/generated/prisma/client";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
+import {
+ reconstructSleepNights,
+ type SleepStageRow,
+} from "@/lib/analytics/sleep-night";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
@@ -685,6 +689,10 @@ export async function collectDoctorReportData(
// v1.17 W1a — the user timezone anchors the ledger compliance
// band minter (matches the detail page's dose-day attribution).
timezone: true,
+ // v1.17.1 — source-priority feeds the per-night sleep reconstruction
+ // so the report's SLEEP_DURATION resolves the same canonical night
+ // (multi-source de-dup) the dashboard + iOS feed show.
+ sourcePriorityJson: true,
// v1.7.0 — patient-identity fields for the export cover + FHIR
// Patient. KVNR is encrypted (and not selected here) — the route
// decrypts it and hands it to the builders.
@@ -708,6 +716,36 @@ export async function collectDoctorReportData(
});
}
+ // v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a
+ // 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface
+ // (dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed)
+ // shows the per-night reconstructed asleep total via `summarizeSleepNights`,
+ // and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route
+ // the report's sleep value through the same engine — one number, one surface
+ // — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`.
+ const reportTz = userProfile?.timezone ?? "Europe/Berlin";
+ const sleepRows = measurements.filter(
+ (m) => m.type === "SLEEP_DURATION",
+ ) as unknown as SleepStageRow[];
+ if (sleepRows.length > 0) {
+ const nights = reconstructSleepNights(
+ sleepRows,
+ reportTz,
+ userProfile?.sourcePriorityJson ?? null,
+ ).filter((n) => n.asleepMinutes > 0);
+ // Per-night asleep totals, ascending by night, replace the raw per-stage
+ // rows so the clinical vitals table reads time-asleep hours, not stage-row
+ // minutes. Empty (no scorable night) drops SLEEP_DURATION entirely.
+ if (nights.length > 0) {
+ byType.SLEEP_DURATION = nights.map((n) => ({
+ value: n.asleepMinutes,
+ measuredAt: n.measuredAt.toISOString(),
+ }));
+ } else {
+ delete byType.SLEEP_DURATION;
+ }
+ }
+
// Per-type stats.
const stats: Record = {};
for (const [type, entries] of Object.entries(byType)) {
@@ -728,7 +766,6 @@ export async function collectDoctorReportData(
// detail page (slot dedup, band timing, cadence honoured). As-needed / PRN
// medications are excluded — no schedule, no expected dose, no fabricated
// 100 % on a clinical report. The medication itself stays on the list.
- const reportTz = userProfile?.timezone ?? "Europe/Berlin";
const compliance = buildLedgerCompliance(
medications.map((m) => ({
id: m.id,
From b336ed5b732d56e7c6196f688578045822fea1bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:28:12 +0200
Subject: [PATCH 71/79] fix(settings): confirm VAPID regenerate in-app; let
labs subtitle wrap
The VAPID regenerate overwrite guard used a native window.confirm for a
subscription-invalidating destructive action, where the rest of the
release uses the project's AlertDialog. Drive the overwrite confirm
through an AlertDialog (calm in-app dialog, destructive action variant)
and retry with force from its action.
The /labs header subtitle used truncate, clipping mid-word in longer
locales on a 375px viewport. Drop truncate so it wraps; the add button
stays shrink-0 and the row aligns to the top.
---
messages/de.json | 2 +
messages/en.json | 2 +
messages/es.json | 2 +
messages/fr.json | 2 +
messages/it.json | 2 +
messages/pl.json | 2 +
src/app/labs/page.tsx | 4 +-
.../admin/web-push-vapid-section.tsx | 50 ++++++++++++++++---
8 files changed, 58 insertions(+), 8 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 1d1ba962..b847bcce 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4932,6 +4932,8 @@
"webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.",
"webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.",
"webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?",
+ "webPushVapidGenerateConfirmTitle": "VAPID-Schlüssel neu erzeugen?",
+ "webPushVapidRegenerate": "Neu erzeugen",
"configured": "Konfiguriert",
"monitoringTestSuccess": "Testevent wurde gesendet",
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
diff --git a/messages/en.json b/messages/en.json
index 3a8b08b1..3613256a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4932,6 +4932,8 @@
"webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.",
"webPushVapidGenerateFailed": "Could not generate VAPID keys.",
"webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?",
+ "webPushVapidGenerateConfirmTitle": "Regenerate VAPID keys?",
+ "webPushVapidRegenerate": "Regenerate",
"configured": "Configured",
"monitoringTestSuccess": "Test event sent",
"monitoringTestFailed": "Monitoring test failed",
diff --git a/messages/es.json b/messages/es.json
index 210c603a..fde506b9 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4932,6 +4932,8 @@
"webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.",
"webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.",
"webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?",
+ "webPushVapidGenerateConfirmTitle": "¿Regenerar las claves VAPID?",
+ "webPushVapidRegenerate": "Regenerar",
"configured": "Configurado",
"monitoringTestSuccess": "Evento de prueba enviado",
"monitoringTestFailed": "La prueba de monitorización falló",
diff --git a/messages/fr.json b/messages/fr.json
index e73f56d8..e34333cd 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4932,6 +4932,8 @@
"webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.",
"webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.",
"webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?",
+ "webPushVapidGenerateConfirmTitle": "Régénérer les clés VAPID ?",
+ "webPushVapidRegenerate": "Régénérer",
"configured": "Configuré",
"monitoringTestSuccess": "Événement de test envoyé",
"monitoringTestFailed": "Échec du test de surveillance",
diff --git a/messages/it.json b/messages/it.json
index 5e20abdb..e21d8c41 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4932,6 +4932,8 @@
"webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.",
"webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.",
"webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?",
+ "webPushVapidGenerateConfirmTitle": "Rigenerare le chiavi VAPID?",
+ "webPushVapidRegenerate": "Rigenera",
"configured": "Configurato",
"monitoringTestSuccess": "Evento di prova inviato",
"monitoringTestFailed": "Test di monitoraggio non riuscito",
diff --git a/messages/pl.json b/messages/pl.json
index f62178eb..58998f83 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4932,6 +4932,8 @@
"webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.",
"webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.",
"webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?",
+ "webPushVapidGenerateConfirmTitle": "Wygenerować nowe klucze VAPID?",
+ "webPushVapidRegenerate": "Wygeneruj ponownie",
"configured": "Skonfigurowano",
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
"monitoringTestFailed": "Test monitorowania nie powiódł się",
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 524e903e..2030d051 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -50,12 +50,12 @@ export default function LabsPage() {
return (
-
+
{t("labs.title")}
-
+
{t("labs.subtitle")}
diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx
index e409d96f..d67d49bc 100644
--- a/src/components/admin/web-push-vapid-section.tsx
+++ b/src/components/admin/web-push-vapid-section.tsx
@@ -6,6 +6,16 @@ import { toast } from "sonner";
import { BellRing, KeyRound, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -32,6 +42,11 @@ export function WebPushVapidSection() {
string | null
>(null);
const [generating, setGenerating] = useState(false);
+ // Overwrite guard: the first generate returns 409 when keys already exist;
+ // we surface the project's AlertDialog (not a native window.confirm) before
+ // retrying with force, since regenerating invalidates every live Web-Push
+ // subscription.
+ const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const webPushVapidPublicKeyValue =
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
@@ -71,12 +86,10 @@ export function WebPushVapidSection() {
);
if (res.status === 409) {
- // Overwrite guard — existing keys would be replaced. Confirm with
- // the operator (regenerating invalidates current subscriptions),
- // then retry with force.
- if (window.confirm(t("admin.webPushVapidGenerateConfirm"))) {
- await generateVapidKeys(true);
- }
+ // Overwrite guard — existing keys would be replaced. Surface the
+ // in-app confirm dialog (regenerating invalidates current
+ // subscriptions); the dialog action retries with force.
+ setOverwriteConfirmOpen(true);
return;
}
@@ -201,6 +214,31 @@ export function WebPushVapidSection() {
{t("common.save")}
);
}
From aeb30464ac738ec232bd3ce22e0da18ae2aac9a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:28:19 +0200
Subject: [PATCH 72/79] chore(observability): add headerValue to the redact
denylist
The webhook shared secret lives only inside the AES-GCM-encrypted channel
config blob and the GET masks it, so no current path excerpts it into a
wide event. Add headervalue to the sensitive-key denylist as defence in
depth: if a future change ever routes a webhook body through the payload
diagnostic, the secret redacts instead of landing verbatim. The Live
Activity push token is already covered by the token pattern.
---
.../observability/__tests__/redact-payload.test.ts | 14 ++++++++++++++
src/lib/observability/redact-payload.ts | 7 +++++++
2 files changed, 21 insertions(+)
diff --git a/src/lib/observability/__tests__/redact-payload.test.ts b/src/lib/observability/__tests__/redact-payload.test.ts
index 713f4789..0f9491ef 100644
--- a/src/lib/observability/__tests__/redact-payload.test.ts
+++ b/src/lib/observability/__tests__/redact-payload.test.ts
@@ -163,4 +163,18 @@ describe("redactSensitiveFields", () => {
benignField: "kept",
});
});
+
+ it("redacts the webhook shared secret (headerValue) and the Live Activity token", () => {
+ const body = {
+ headerValue: "super-secret-webhook-key",
+ liveActivityPushToken: "abc123",
+ webhookUrl: "https://example.com/hook",
+ };
+ expect(redactSensitiveFields(body)).toEqual({
+ headerValue: "[redacted]",
+ // Covered by the existing /token/i pattern.
+ liveActivityPushToken: "[redacted]",
+ webhookUrl: "https://example.com/hook",
+ });
+ });
});
diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts
index 1ee5992e..6b7a1f4a 100644
--- a/src/lib/observability/redact-payload.ts
+++ b/src/lib/observability/redact-payload.ts
@@ -65,6 +65,13 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/progesteronetest/i,
/contraceptive/i,
/cervicalmucus/i,
+ // v1.17.1 — the webhook shared secret. `headerValue` lives only inside the
+ // AES-GCM-encrypted `NotificationChannel.config` blob and the GET masks it
+ // to `hasHeaderValue`, so no current path excerpts it. Defence-in-depth: if
+ // a future change routes a webhook body through `buildPayloadDiagnostic`,
+ // the secret redacts instead of landing verbatim. The Live Activity push
+ // token is already covered by `/token/i`.
+ /headervalue/i,
];
const REDACTED = "[redacted]";
From 1ed2e5d2a2d5269f5ac0ac98a06aa99888dfefeb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:28:26 +0200
Subject: [PATCH 73/79] docs(recovery): note the date-anchor bucketing gap for
negative-UTC users
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Oura and Polar recovery rows are bucketed on the local day of their raw
measuredAt, on the assumption that both stamp the wake morning. Polar
always anchors at UTC midnight of the wake date and Oura falls back to the
same anchor when no real wake instant is present, so for a user in a far
negative UTC zone that anchor reads as the previous local evening and a
single night can split across two recovery days near the date line. A
correct fix needs an anchor-kind signal threaded through the recovery
read so the date-anchored sources read their wake day in UTC while a real
wake instant stays local — out of scope here. Document the assumption at
the bucketing site; positive-UTC users are unaffected.
---
src/lib/insights/derived/recovery-resolve.ts | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/src/lib/insights/derived/recovery-resolve.ts b/src/lib/insights/derived/recovery-resolve.ts
index b8729830..ce8cb09e 100644
--- a/src/lib/insights/derived/recovery-resolve.ts
+++ b/src/lib/insights/derived/recovery-resolve.ts
@@ -76,6 +76,20 @@ function rankOf(source: MeasurementSource): number {
* shifted forward by one before the local-day read — aligning it onto the same
* wake day the WHOOP row for that night carries. Reading the key in the user's
* timezone keeps a near-midnight or late re-score on the same night.
+ *
+ * KNOWN LIMITATION (05-M2, deferred to v1.17.2). OURA and POLAR are bucketed by
+ * the local day of their raw `measuredAt` on the assumption that both stamp the
+ * wake morning. Polar always anchors `measuredAt = ${date}T00:00:00.000Z`
+ * (UTC-midnight of the wake date); Oura prefers a real `bedtimeEnd` wake instant
+ * but FALLS BACK to the same UTC-midnight anchor. For a user in a far-negative
+ * UTC zone, a UTC-midnight anchor reads as the PREVIOUS local evening, so a
+ * Polar (or Oura-fallback) night can land one day earlier than the WHOOP/Oura
+ * night it should align with — splitting one physiological night across two
+ * recovery days near the date line. A correct fix needs an "anchor kind"
+ * (date-midnight vs real-instant) threaded through the recovery read so the
+ * date-anchored sources read their wake-day in UTC while WHOOP / Oura-bedtimeEnd
+ * stay local; that DTO change is out of scope for v1.17.1. Europe / positive-UTC
+ * users (the common case) are unaffected.
*/
function wakeDayKeyOf(d: Date, source: MeasurementSource, tz: string): string {
const anchor =
From 38a2c7127e2a19af9b98ee6171f1618305ddff67 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:34:12 +0200
Subject: [PATCH 74/79] fix(integrations): audit WHOOP and Fitbit credential
teardown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Polar and Oura credential-DELETE handlers emit an auditLog entry and park
the integration ledger at disconnected; the WHOOP and Fitbit handlers cleared
the token row and BYO credentials but logged nothing. Credential teardown is a
sensitive op — add auditLog plus markDisconnected to both so the audit trail
and the ledger state stay uniform across every BYO-key integration.
---
.../credentials/__tests__/route.test.ts | 79 +++++++++++++++++++
src/app/api/fitbit/credentials/route.ts | 8 ++
.../whoop/credentials/__tests__/route.test.ts | 16 ++++
src/app/api/whoop/credentials/route.ts | 8 ++
4 files changed, 111 insertions(+)
create mode 100644 src/app/api/fitbit/credentials/__tests__/route.test.ts
diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts
new file mode 100644
index 00000000..44160d2e
--- /dev/null
+++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/api-handler", () => ({
+ apiHandler: unknown>(fn: T) => fn,
+ requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ user: { findUnique: vi.fn(), update: vi.fn() },
+ fitbitConnection: { delete: vi.fn() },
+ },
+}));
+
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
+vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
+vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
+
+vi.mock("@/lib/api-response", () => ({
+ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
+ apiError: (error: string, status: number) => ({ data: null, error, status }),
+ safeJson: async (req: NextRequest) => {
+ try {
+ return { data: await req.json(), error: null };
+ } catch {
+ return { data: null, error: { status: 400 } };
+ }
+ },
+}));
+
+import { PUT, DELETE } from "../route";
+import { prisma } from "@/lib/db";
+import { auditLog } from "@/lib/auth/audit";
+import { markDisconnected } from "@/lib/integrations/status";
+
+const userUpdate = prisma.user.update as ReturnType;
+
+function req(body: unknown): NextRequest {
+ return new NextRequest("http://localhost/api/fitbit/credentials", {
+ method: "PUT",
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("/api/fitbit/credentials", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("PUT 422s on missing fields", async () => {
+ const res = (await (
+ PUT as unknown as (r: NextRequest) => Promise<{ status: number }>
+ )(req({ clientId: "" }))) as { status: number };
+ expect(res.status).toBe(422);
+ expect(userUpdate).not.toHaveBeenCalled();
+ });
+
+ it("DELETE clears credentials, audits the teardown, and parks the ledger (04-L1 parity)", async () => {
+ userUpdate.mockResolvedValue({});
+ (
+ prisma.fitbitConnection.delete as ReturnType
+ ).mockResolvedValue({});
+ const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)())
+ .data as { deleted: boolean };
+ expect(res.deleted).toBe(true);
+ expect(userUpdate).toHaveBeenCalledWith({
+ where: { id: "u1" },
+ data: {
+ fitbitClientIdEncrypted: null,
+ fitbitClientSecretEncrypted: null,
+ },
+ });
+ expect(auditLog).toHaveBeenCalledWith("fitbit.credentials.delete", {
+ userId: "u1",
+ });
+ expect(markDisconnected).toHaveBeenCalledWith("u1", "fitbit");
+ });
+});
diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts
index 1a57ae5f..9ecb39a6 100644
--- a/src/app/api/fitbit/credentials/route.ts
+++ b/src/app/api/fitbit/credentials/route.ts
@@ -1,8 +1,10 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
+import { markDisconnected } from "@/lib/integrations/status";
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -60,6 +62,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
/**
* Delete Fitbit credentials and the active connection.
+ *
+ * Audits the teardown and parks the integration ledger at `disconnected` for
+ * parity with the Polar / Oura credential-DELETE so a sensitive op leaves a
+ * uniform audit trail and the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
@@ -76,6 +82,8 @@ export const DELETE = apiHandler(async () => {
fitbitClientSecretEncrypted: null,
},
});
+ await auditLog("fitbit.credentials.delete", { userId: user.id });
+ await markDisconnected(user.id, "fitbit");
return apiSuccess({ deleted: true });
});
diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts
index 0816df18..ec79e0cd 100644
--- a/src/app/api/whoop/credentials/__tests__/route.test.ts
+++ b/src/app/api/whoop/credentials/__tests__/route.test.ts
@@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
+vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
+vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
@@ -30,6 +32,8 @@ vi.mock("@/lib/api-response", () => ({
import { GET, PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
+import { auditLog } from "@/lib/auth/audit";
+import { markDisconnected } from "@/lib/integrations/status";
const userFind = prisma.user.findUnique as ReturnType;
const userUpdate = prisma.user.update as ReturnType;
@@ -91,4 +95,16 @@ describe("/api/whoop/credentials", () => {
},
});
});
+
+ it("DELETE audits the teardown and parks the ledger (04-L1 parity)", async () => {
+ userUpdate.mockResolvedValue({});
+ (prisma.whoopConnection.delete as ReturnType).mockResolvedValue(
+ {},
+ );
+ await (DELETE as unknown as () => Promise)();
+ expect(auditLog).toHaveBeenCalledWith("whoop.credentials.delete", {
+ userId: "u1",
+ });
+ expect(markDisconnected).toHaveBeenCalledWith("u1", "whoop");
+ });
});
diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts
index 618d8740..6675fa11 100644
--- a/src/app/api/whoop/credentials/route.ts
+++ b/src/app/api/whoop/credentials/route.ts
@@ -1,8 +1,10 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
+import { markDisconnected } from "@/lib/integrations/status";
import { whoopCredentialsSchema } from "@/lib/validations/whoop";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -58,6 +60,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
/**
* Delete WHOOP credentials and the active connection.
+ *
+ * Audits the teardown and parks the integration ledger at `disconnected` for
+ * parity with the Polar / Oura credential-DELETE so a sensitive op leaves a
+ * uniform audit trail and the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
@@ -74,6 +80,8 @@ export const DELETE = apiHandler(async () => {
whoopClientSecretEncrypted: null,
},
});
+ await auditLog("whoop.credentials.delete", { userId: user.id });
+ await markDisconnected(user.id, "whoop");
return apiSuccess({ deleted: true });
});
From 9430e89889555d8ab66e9f9ddbd6d2b70e48b0c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:34:30 +0200
Subject: [PATCH 75/79] feat(integrations): unify OAuth returns and the status
envelope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The WHOOP and Fitbit OAuth callbacks redirect back with
`?whoop=connected|error` / `?fitbit=connected|error`, but the settings page
only read that param for Withings, Polar and Oura — a user returning from a
WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast.
Extend the generic return handler to all four OAuth providers, add the
matching connected/failed/error i18n keys across every locale, and invalidate
both the per-card key and the consolidated envelope on a successful return.
Polar and Oura already write the shared IntegrationKey ledger but each fired
its own `/api//status` round-trip from the card, so the page issued
one consolidated request plus three per-card ones. Fold Polar and Oura into
`/api/integrations/status` and let the cards read a passed view-model instead
of fetching, retiring the extra round-trips. The per-provider status routes
stay for the iOS and test callers.
---
messages/de.json | 24 +++
messages/en.json | 24 +++
messages/es.json | 24 +++
messages/fr.json | 24 +++
messages/it.json | 24 +++
messages/pl.json | 24 +++
.../status/__tests__/route.test.ts | 115 ++++++++++++++
src/app/api/integrations/status/route.ts | 51 ++++++
.../__tests__/oauth-return-outcome.test.ts | 67 ++++++++
.../settings/integrations-section.tsx | 148 ++++++++++++++----
.../__tests__/oauth-provider-card.test.tsx | 28 +++-
.../integrations/oauth-provider-card.tsx | 31 +++-
.../settings/integrations/oura-card.tsx | 14 +-
.../settings/integrations/polar-card.tsx | 14 +-
.../settings/integrations/shared.tsx | 13 +-
15 files changed, 583 insertions(+), 42 deletions(-)
create mode 100644 src/app/api/integrations/status/__tests__/route.test.ts
create mode 100644 src/components/settings/__tests__/oauth-return-outcome.test.ts
diff --git a/messages/de.json b/messages/de.json
index 1d1ba962..1f9493ce 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4463,6 +4463,18 @@
"whoopConnect": "Mit WHOOP verbinden",
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
+ "whoopOauthConnected": "WHOOP verbunden — die erste Synchronisierung startet im Hintergrund.",
+ "whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen",
+ "whoopOauthError": {
+ "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
+ "state": "Die Antwort von WHOOP passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
+ "expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
+ "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
+ "nocode": "WHOOP hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im WHOOP-Dialog.",
+ "nocreds": "WHOOP ist nicht konfiguriert. Hinterlege oben deine WHOOP-OAuth-Zugangsdaten und versuche es erneut.",
+ "token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
+ "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
+ },
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.",
@@ -4490,6 +4502,18 @@
"fitbitConnect": "Mit Google Health verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
+ "fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.",
+ "fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen",
+ "fitbitOauthError": {
+ "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
+ "state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
+ "expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
+ "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
+ "nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
+ "nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
+ "token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
+ "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
+ },
"integrations": {
"withings": {
"reconnect": {
diff --git a/messages/en.json b/messages/en.json
index 3a8b08b1..21fc5391 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4463,6 +4463,18 @@
"whoopConnect": "Connect with WHOOP",
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
+ "whoopOauthConnected": "WHOOP connected — the first sync starts in the background.",
+ "whoopOauthFailed": "WHOOP connection failed",
+ "whoopOauthError": {
+ "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
+ "state": "The response from WHOOP did not match any open connection request. Please start the connection again.",
+ "expired": "The connection request expired before it completed. Please start the connection again.",
+ "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
+ "nocode": "WHOOP did not return an authorization code. Try again and allow access in the WHOOP dialog.",
+ "nocreds": "WHOOP is not configured. Add your WHOOP OAuth credentials above and try again.",
+ "token": "The token exchange with WHOOP failed. Please start the connection again.",
+ "generic": "Unknown error while connecting. Please start the connection again."
+ },
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.",
@@ -4490,6 +4502,18 @@
"fitbitConnect": "Connect with Google Health",
"fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.",
"fitbitBackfillInProgress": "Importing your Google Health history in the background…",
+ "fitbitOauthConnected": "Google Health connected — the first sync starts in the background.",
+ "fitbitOauthFailed": "Google Health connection failed",
+ "fitbitOauthError": {
+ "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
+ "state": "The response from Google did not match any open connection request. Please start the connection again.",
+ "expired": "The connection request expired before it completed. Please start the connection again.",
+ "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
+ "nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
+ "nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
+ "token": "The token exchange with Google failed. Please start the connection again.",
+ "generic": "Unknown error while connecting. Please start the connection again."
+ },
"integrations": {
"withings": {
"reconnect": {
diff --git a/messages/es.json b/messages/es.json
index 210c603a..61ad7b3d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4463,6 +4463,18 @@
"whoopConnect": "Conectar con WHOOP",
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
+ "whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.",
+ "whoopOauthFailed": "Error al conectar con WHOOP",
+ "whoopOauthError": {
+ "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
+ "state": "La respuesta de WHOOP no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
+ "expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
+ "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
+ "nocode": "WHOOP no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de WHOOP.",
+ "nocreds": "WHOOP no está configurado. Añade tus credenciales OAuth de WHOOP arriba e inténtalo de nuevo.",
+ "token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.",
+ "generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
+ },
"fitbit": "Google Health",
"fitbitTag": "Fitbit y Pixel",
"fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.",
@@ -4490,6 +4502,18 @@
"fitbitConnect": "Conectar con Google Health",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
+ "fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
+ "fitbitOauthFailed": "Error al conectar con Google Health",
+ "fitbitOauthError": {
+ "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
+ "state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
+ "expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
+ "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
+ "nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.",
+ "nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.",
+ "token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.",
+ "generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
+ },
"integrations": {
"withings": {
"reconnect": {
diff --git a/messages/fr.json b/messages/fr.json
index e73f56d8..fc462699 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4463,6 +4463,18 @@
"whoopConnect": "Se connecter avec WHOOP",
"whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.",
"whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…",
+ "whoopOauthConnected": "WHOOP connecté — la première synchronisation démarre en arrière-plan.",
+ "whoopOauthFailed": "Échec de la connexion à WHOOP",
+ "whoopOauthError": {
+ "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
+ "state": "La réponse de WHOOP ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
+ "expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
+ "cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
+ "nocode": "WHOOP n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre WHOOP.",
+ "nocreds": "WHOOP n'est pas configuré. Ajoutez vos identifiants OAuth WHOOP ci-dessus et réessayez.",
+ "token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.",
+ "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
+ },
"fitbit": "Google Health",
"fitbitTag": "Fitbit et Pixel",
"fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.",
@@ -4490,6 +4502,18 @@
"fitbitConnect": "Se connecter à Google Health",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.",
"fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…",
+ "fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.",
+ "fitbitOauthFailed": "Échec de la connexion à Google Health",
+ "fitbitOauthError": {
+ "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
+ "state": "La réponse de Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
+ "expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
+ "cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
+ "nocode": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.",
+ "nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.",
+ "token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.",
+ "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
+ },
"integrations": {
"withings": {
"reconnect": {
diff --git a/messages/it.json b/messages/it.json
index 5e20abdb..f9bda221 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4463,6 +4463,18 @@
"whoopConnect": "Connetti con WHOOP",
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
+ "whoopOauthConnected": "WHOOP collegato — la prima sincronizzazione parte in background.",
+ "whoopOauthFailed": "Connessione a WHOOP non riuscita",
+ "whoopOauthError": {
+ "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
+ "state": "La risposta di WHOOP non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
+ "expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
+ "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
+ "nocode": "WHOOP non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di WHOOP.",
+ "nocreds": "WHOOP non è configurato. Aggiungi le tue credenziali OAuth di WHOOP qui sopra e riprova.",
+ "token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.",
+ "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
+ },
"fitbit": "Google Health",
"fitbitTag": "Fitbit e Pixel",
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.",
@@ -4490,6 +4502,18 @@
"fitbitConnect": "Connetti con Google Health",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"fitbitBackfillInProgress": "Importazione dello storico Google Health in background…",
+ "fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.",
+ "fitbitOauthFailed": "Connessione a Google Health non riuscita",
+ "fitbitOauthError": {
+ "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
+ "state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
+ "expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
+ "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
+ "nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.",
+ "nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.",
+ "token": "Lo scambio del token con Google è fallito. Riavvia la connessione.",
+ "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
+ },
"integrations": {
"withings": {
"reconnect": {
diff --git a/messages/pl.json b/messages/pl.json
index f62178eb..e2a43a61 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4463,6 +4463,18 @@
"whoopConnect": "Połącz z WHOOP",
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
+ "whoopOauthConnected": "WHOOP połączony — pierwsza synchronizacja startuje w tle.",
+ "whoopOauthFailed": "Połączenie z WHOOP nie powiodło się",
+ "whoopOauthError": {
+ "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
+ "state": "Odpowiedź z WHOOP nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
+ "expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
+ "cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
+ "nocode": "WHOOP nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie WHOOP.",
+ "nocreds": "WHOOP nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth WHOOP i spróbuj ponownie.",
+ "token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.",
+ "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
+ },
"fitbit": "Google Health",
"fitbitTag": "Fitbit i Pixel",
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.",
@@ -4490,6 +4502,18 @@
"fitbitConnect": "Połącz z Google Health",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.",
"fitbitBackfillInProgress": "Importowanie historii Google Health w tle…",
+ "fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.",
+ "fitbitOauthFailed": "Połączenie z Google Health nie powiodło się",
+ "fitbitOauthError": {
+ "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
+ "state": "Odpowiedź z Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
+ "expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
+ "cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
+ "nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
+ "nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
+ "token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.",
+ "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
+ },
"integrations": {
"withings": {
"reconnect": {
diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts
new file mode 100644
index 00000000..2f378f5d
--- /dev/null
+++ b/src/app/api/integrations/status/__tests__/route.test.ts
@@ -0,0 +1,115 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/api-handler", () => ({
+ apiHandler: unknown>(fn: T) => fn,
+ requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ user: { findUnique: vi.fn() },
+ withingsConnection: { findUnique: vi.fn(async () => null) },
+ whoopConnection: { findUnique: vi.fn(async () => null) },
+ fitbitConnection: { findUnique: vi.fn(async () => null) },
+ moodEntry: { count: vi.fn(async () => 0) },
+ },
+}));
+
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+
+vi.mock("@/lib/api-response", () => ({
+ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
+}));
+
+const ledger: Record = {
+ state: "connected",
+ lastSuccessAt: null,
+ lastAttemptAt: null,
+ lastError: null,
+};
+vi.mock("@/lib/integrations/status", () => ({
+ getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
+ integration,
+ ...ledger,
+ })),
+ getPersistentFailureThreshold: () => 5,
+}));
+
+vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false }));
+vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null }));
+
+const polarAvailable = vi.fn(async () => true);
+const ouraAvailable = vi.fn(async () => false);
+vi.mock("@/lib/polar/credentials", () => ({
+ getPolarClientCredentials: () => polarAvailable(),
+}));
+vi.mock("@/lib/oura/credentials", () => ({
+ getOuraClientCredentials: () => ouraAvailable(),
+}));
+
+import { GET } from "../route";
+import { prisma } from "@/lib/db";
+
+const userFind = prisma.user.findUnique as ReturnType;
+
+type Entry = {
+ integration: string;
+ connected?: boolean;
+ configured?: boolean;
+ available?: boolean;
+ hasOwnCredentials?: boolean;
+};
+
+async function fetchEntries(): Promise {
+ const res = (await (GET as unknown as () => Promise<{ data: unknown }>)())
+ .data as { integrations: Entry[] };
+ return res.integrations;
+}
+
+describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ polarAvailable.mockResolvedValue(true);
+ ouraAvailable.mockResolvedValue(false);
+ });
+
+ it("includes polar + oura entries on the consolidated envelope", async () => {
+ userFind.mockResolvedValue({
+ polarAccessTokenEncrypted: "tok",
+ polarClientIdEncrypted: "id",
+ polarClientSecretEncrypted: "sec",
+ ouraAccessTokenEncrypted: null,
+ ouraClientIdEncrypted: null,
+ ouraClientSecretEncrypted: null,
+ });
+
+ const entries = await fetchEntries();
+ const keys = entries.map((e) => e.integration);
+ expect(keys).toEqual(
+ expect.arrayContaining(["polar", "oura"]),
+ );
+
+ const polar = entries.find((e) => e.integration === "polar")!;
+ expect(polar.connected).toBe(true);
+ expect(polar.configured).toBe(true);
+ expect(polar.available).toBe(true);
+ expect(polar.hasOwnCredentials).toBe(true);
+
+ const oura = entries.find((e) => e.integration === "oura")!;
+ expect(oura.connected).toBe(false);
+ expect(oura.available).toBe(false);
+ expect(oura.hasOwnCredentials).toBe(false);
+ });
+
+ it("reports polar disconnected when no access token is stored", async () => {
+ userFind.mockResolvedValue({
+ polarAccessTokenEncrypted: null,
+ polarClientIdEncrypted: null,
+ polarClientSecretEncrypted: null,
+ });
+ const entries = await fetchEntries();
+ const polar = entries.find((e) => e.integration === "polar")!;
+ expect(polar.connected).toBe(false);
+ expect(polar.configured).toBe(false);
+ });
+});
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
index 9b3d768f..8c3b5eec 100644
--- a/src/app/api/integrations/status/route.ts
+++ b/src/app/api/integrations/status/route.ts
@@ -24,6 +24,8 @@ import {
getPersistentFailureThreshold,
type IntegrationKey,
} from "@/lib/integrations/status";
+import { getOuraClientCredentials } from "@/lib/oura/credentials";
+import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
@@ -38,16 +40,25 @@ export const GET = apiHandler(async () => {
moodLogStatus,
whoopStatus,
fitbitStatus,
+ polarStatus,
+ ouraStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
moodLogEntryCount,
+ // v1.17.1 — `available` reports whether usable OAuth credentials resolve
+ // (per-user BYO first, then the shared env app), mirroring the per-card
+ // /api//status the consolidated envelope now replaces.
+ polarAvailable,
+ ouraAvailable,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
+ getIntegrationStatus(user.id, "polar"),
+ getIntegrationStatus(user.id, "oura"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -57,6 +68,12 @@ export const GET = apiHandler(async () => {
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
+ polarAccessTokenEncrypted: true,
+ polarClientIdEncrypted: true,
+ polarClientSecretEncrypted: true,
+ ouraAccessTokenEncrypted: true,
+ ouraClientIdEncrypted: true,
+ ouraClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
@@ -95,6 +112,8 @@ export const GET = apiHandler(async () => {
prisma.moodEntry.count({
where: { userId: user.id, deletedAt: null },
}),
+ getPolarClientCredentials(user.id).then((c) => !!c),
+ getOuraClientCredentials(user.id).then((c) => !!c),
]);
const now = Date.now();
@@ -164,6 +183,28 @@ export const GET = apiHandler(async () => {
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
} satisfies IntegrationViewModel & FitbitExtras,
+ {
+ ...polarStatus,
+ // `connected` = a stored access token; `configured` mirrors it (the
+ // OAuth card has no separate "credentials saved but disconnected" view
+ // beyond `hasOwnCredentials`). The card greys out the connect button
+ // when no usable credentials resolve (`available`).
+ connected: !!dbUser?.polarAccessTokenEncrypted,
+ configured: !!dbUser?.polarAccessTokenEncrypted,
+ available: polarAvailable,
+ hasOwnCredentials:
+ !!dbUser?.polarClientIdEncrypted &&
+ !!dbUser?.polarClientSecretEncrypted,
+ } satisfies IntegrationViewModel & OAuthProviderExtras,
+ {
+ ...ouraStatus,
+ connected: !!dbUser?.ouraAccessTokenEncrypted,
+ configured: !!dbUser?.ouraAccessTokenEncrypted,
+ available: ouraAvailable,
+ hasOwnCredentials:
+ !!dbUser?.ouraClientIdEncrypted &&
+ !!dbUser?.ouraClientSecretEncrypted,
+ } satisfies IntegrationViewModel & OAuthProviderExtras,
],
});
});
@@ -214,3 +255,13 @@ interface FitbitExtras {
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
}
+
+// v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the
+// per-user BYO-key flags the shared OAuth card reads instead of the dedicated
+// /api//status round-trip the page used to fire per card.
+interface OAuthProviderExtras {
+ connected: boolean;
+ configured: boolean;
+ available: boolean;
+ hasOwnCredentials: boolean;
+}
diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts
new file mode 100644
index 00000000..a4c2f25a
--- /dev/null
+++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts
@@ -0,0 +1,67 @@
+/**
+ * v1.17.1 (04-M1) — the WHOOP / Fitbit OAuth callbacks redirect back with
+ * `?whoop=connected|error` / `?fitbit=connected|error&reason=`, but the
+ * settings page only read the param for Withings/Polar/Oura — so a user
+ * returning from a WHOOP or Fitbit round-trip landed on a silently unchanged
+ * page. These pin the four-provider outcome parsing + reason-key resolution.
+ */
+import { describe, it, expect } from "vitest";
+
+import {
+ parseOAuthOutcome,
+ oauthReasonKey,
+} from "../integrations-section";
+
+describe("parseOAuthOutcome", () => {
+ it("reads a connected outcome for every OAuth provider", () => {
+ for (const p of ["polar", "oura", "whoop", "fitbit"] as const) {
+ expect(parseOAuthOutcome(`?${p}=connected`)).toEqual({
+ provider: p,
+ kind: "connected",
+ });
+ }
+ });
+
+ it("reads WHOOP + Fitbit error outcomes with their reason tag (the gap fix)", () => {
+ expect(parseOAuthOutcome("?whoop=error&reason=token")).toEqual({
+ provider: "whoop",
+ kind: "error",
+ reason: "token",
+ });
+ expect(parseOAuthOutcome("?fitbit=error&reason=expired")).toEqual({
+ provider: "fitbit",
+ kind: "error",
+ reason: "expired",
+ });
+ });
+
+ it("defaults a missing reason to 'unknown'", () => {
+ expect(parseOAuthOutcome("?whoop=error")).toEqual({
+ provider: "whoop",
+ kind: "error",
+ reason: "unknown",
+ });
+ });
+
+ it("returns null when no provider param is present", () => {
+ expect(parseOAuthOutcome("?foo=bar")).toBeNull();
+ expect(parseOAuthOutcome("")).toBeNull();
+ });
+});
+
+describe("oauthReasonKey", () => {
+ it("maps a known tag to the provider-specific key", () => {
+ expect(oauthReasonKey("whoop", "token")).toBe(
+ "settings.whoopOauthError.token",
+ );
+ expect(oauthReasonKey("fitbit", "expired")).toBe(
+ "settings.fitbitOauthError.expired",
+ );
+ });
+
+ it("falls back to generic for an unknown tag", () => {
+ expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
+ "settings.whoopOauthError.generic",
+ );
+ });
+});
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index 148a806c..7484da40 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -8,11 +8,13 @@ import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
+import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import {
pickStatus,
useIntegrationStatuses,
+ type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
import { WithingsCard } from "@/components/settings/integrations/withings-card";
@@ -43,6 +45,97 @@ type WithingsOauthOutcome =
| { kind: "connected" }
| { kind: "error"; reason: string };
+/**
+ * The OAuth providers whose callbacks redirect back to the settings page with a
+ * `?=connected|error&reason=` outcome param. Polar/Oura own a
+ * per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
+ * are invalidated on a successful return so the card repaints either way.
+ */
+const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
+type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
+
+const OAUTH_OUTCOME_KEYS: Record<
+ OAuthOutcomeProvider,
+ () => readonly unknown[]
+> = {
+ polar: queryKeys.polar,
+ oura: queryKeys.oura,
+ whoop: queryKeys.whoop,
+ fitbit: queryKeys.fitbit,
+};
+
+/**
+ * Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
+ * message; anything else falls back to the provider's `generic` copy. Union of
+ * the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
+ */
+const OAUTH_OUTCOME_REASONS = new Set([
+ "csrf1",
+ "state",
+ "cross_user",
+ "nocode",
+ "nocreds",
+ "token",
+ "rate_limited",
+ "expired",
+]);
+
+type OAuthOutcome =
+ | { provider: OAuthOutcomeProvider; kind: "connected" }
+ | { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
+
+/**
+ * Parse the OAuth-return outcome from a URL query string. Reads the first
+ * provider whose `?=connected|error` param is present, in
+ * `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
+ * coverage is unit-testable without a browser.
+ */
+export function parseOAuthOutcome(search: string): OAuthOutcome | null {
+ const params = new URLSearchParams(search);
+ for (const provider of OAUTH_OUTCOME_PROVIDERS) {
+ const v = params.get(provider);
+ if (v === "connected") return { provider, kind: "connected" };
+ if (v === "error") {
+ return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
+ }
+ }
+ return null;
+}
+
+/**
+ * Resolve the i18n key for an error reason tag. Known tags map to the
+ * provider-specific message; anything else falls back to `generic`.
+ */
+export function oauthReasonKey(
+ provider: OAuthOutcomeProvider,
+ reason: string,
+): string {
+ return OAUTH_OUTCOME_REASONS.has(reason)
+ ? `settings.${provider}OauthError.${reason}`
+ : `settings.${provider}OauthError.generic`;
+}
+
+/**
+ * Adapt the consolidated-envelope view-model into the OAuth card's status
+ * shape. Returns `undefined` when the envelope hasn't loaded so the card
+ * renders its loading/disconnected default rather than a half-populated state.
+ */
+function toOAuthStatus(
+ vm: IntegrationStatusViewModel | undefined,
+): OAuthProviderStatus | undefined {
+ if (!vm) return undefined;
+ return {
+ connected: vm.connected ?? false,
+ configured: vm.configured ?? false,
+ available: vm.available ?? false,
+ hasOwnCredentials: vm.hasOwnCredentials,
+ state: vm.state,
+ lastSuccessAt: vm.lastSuccessAt,
+ lastAttemptAt: vm.lastAttemptAt,
+ lastError: vm.lastError,
+ };
+}
+
export function IntegrationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -92,23 +185,16 @@ export function IntegrationsSection() {
}
}, [withingsOauthOutcome, router, queryClient, t]);
- // v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers.
- // The Polar / Oura callbacks redirect back with `?=connected` or
- // `?=error&reason=`; surface the outcome as a toast and scrub
- // the one-shot params so a reload doesn't replay it.
- const [oauthOutcome] = useState<
- { provider: "polar" | "oura"; kind: "connected" } | { provider: "polar" | "oura"; kind: "error"; reason: string } | null
- >(() => {
+ // v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
+ // The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
+ // `?=connected` or `?=error&reason=`; surface the
+ // outcome as a toast and scrub the one-shot params so a reload doesn't replay
+ // it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
+ // WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
+ // the same gap the Withings handler was written to close.
+ const [oauthOutcome] = useState(() => {
if (typeof window === "undefined") return null;
- const params = new URLSearchParams(window.location.search);
- for (const provider of ["polar", "oura"] as const) {
- const v = params.get(provider);
- if (v === "connected") return { provider, kind: "connected" };
- if (v === "error") {
- return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
- }
- }
- return null;
+ return parseOAuthOutcome(window.location.search);
});
useEffect(() => {
@@ -118,27 +204,19 @@ export function IntegrationsSection() {
url.searchParams.delete(provider);
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
- const key = provider === "polar" ? queryKeys.polar() : queryKeys.oura();
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
- queryClient.invalidateQueries({ queryKey: key });
+ // Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
+ // consolidated envelope — invalidate both so the card repaints either way.
+ queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
} else {
// Known reason tags resolve to a specific message; anything else falls
// back to the generic copy (matching the Withings handler).
- const knownReasons = new Set([
- "csrf1",
- "state",
- "cross_user",
- "nocode",
- "nocreds",
- "token",
- "rate_limited",
- ]);
- const reasonKey = knownReasons.has(oauthOutcome.reason)
- ? `settings.${provider}OauthError.${oauthOutcome.reason}`
- : `settings.${provider}OauthError.generic`;
toast.error(t(`settings.${provider}OauthFailed`), {
- description: t(reasonKey),
+ description: t(oauthReasonKey(provider, oauthOutcome.reason)),
duration: 10_000,
});
}
@@ -147,6 +225,10 @@ export function IntegrationsSection() {
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
+ // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
+ // no longer fire their own /api//status round-trip.
+ const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
+ const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
return (
-
-
+
+
);
diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
index 83090442..80672df8 100644
--- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
+++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
@@ -23,7 +23,13 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { OAuthProviderCard } from "../oauth-provider-card";
-function render({ credentials = false }: { credentials?: boolean } = {}) {
+function render({
+ credentials = false,
+ viewModel,
+}: {
+ credentials?: boolean;
+ viewModel?: Parameters[0]["viewModel"];
+} = {}) {
return renderToStaticMarkup(
,
);
@@ -74,6 +81,25 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
expect(html).toContain('href="/insights/sleep"');
});
+ it("reads off the passed view-model instead of the per-card fetch (04-M2)", () => {
+ // The per-card useQuery mock returns null; the card must render the
+ // connected state from the supplied envelope view-model alone, proving the
+ // /api//status round-trip is no longer the source.
+ statusPayload = null;
+ const html = render({
+ viewModel: {
+ connected: true,
+ configured: true,
+ available: true,
+ state: "connected",
+ lastSuccessAt: "2026-06-01T00:00:00.000Z",
+ lastError: null,
+ },
+ });
+ expect(html).toContain('data-testid="polar-data-link"');
+ expect(html).toContain("Test connection");
+ });
+
it("does not render the data link or test button when disconnected", () => {
statusPayload = {
connected: false,
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index d7642eca..1b0e5aa4 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -36,7 +36,11 @@ import {
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
-import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
+import {
+ invalidateKeys,
+ measurementDependentKeys,
+ queryKeys,
+} from "@/lib/query-keys";
import { IntegrationSetupGuideLink } from "./setup-guide-link";
export interface OAuthProviderStatus {
@@ -85,6 +89,13 @@ export interface OAuthProviderCardProps {
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
credentials?: boolean;
enabled?: boolean;
+ /**
+ * When provided, the card reads its status from this view-model (sourced off
+ * the consolidated `/api/integrations/status` envelope) instead of firing its
+ * own `/api//status` round-trip. v1.17.1 folds Polar/Oura onto the
+ * same envelope WHOOP/Fitbit already use, so the page reads from one source.
+ */
+ viewModel?: OAuthProviderStatus;
}
export function OAuthProviderCard({
@@ -95,6 +106,7 @@ export function OAuthProviderCard({
dataHref,
credentials = false,
enabled = true,
+ viewModel,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -107,12 +119,17 @@ export function OAuthProviderCard({
null,
);
- const { data: status } = useQuery({
+ // Read off the consolidated envelope when a view-model is passed; otherwise
+ // fall back to the per-card status fetch (still used by any caller that has
+ // not been migrated onto the envelope). The fetch is disabled once a
+ // view-model is supplied so the page makes one request, not one-per-card.
+ const { data: fetchedStatus } = useQuery({
queryKey: statusQueryKey,
queryFn: async () => apiGet(`/api/${provider}/status`),
- enabled,
+ enabled: enabled && !viewModel,
refetchOnWindowFocus: true,
});
+ const status = viewModel ?? fetchedStatus;
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
@@ -134,6 +151,11 @@ export function OAuthProviderCard({
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: statusQueryKey });
+ // The card may read off the consolidated envelope — invalidate it too
+ // so the saved-credentials state repaints regardless of the source.
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
} else {
try {
const json = await res.json();
@@ -156,6 +178,9 @@ export function OAuthProviderCard({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.integrationsStatus(),
+ });
},
});
diff --git a/src/components/settings/integrations/oura-card.tsx b/src/components/settings/integrations/oura-card.tsx
index a3ce82e7..6d9243a8 100644
--- a/src/components/settings/integrations/oura-card.tsx
+++ b/src/components/settings/integrations/oura-card.tsx
@@ -6,10 +6,19 @@
import { CircleDot } from "lucide-react";
-import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
+import {
+ OAuthProviderCard,
+ type OAuthProviderStatus,
+} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
-export function OuraCard({ enabled = true }: { enabled?: boolean }) {
+export function OuraCard({
+ enabled = true,
+ viewModel,
+}: {
+ enabled?: boolean;
+ viewModel?: OAuthProviderStatus;
+}) {
return (
);
}
diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx
index 56a31021..3ee6ff5c 100644
--- a/src/components/settings/integrations/polar-card.tsx
+++ b/src/components/settings/integrations/polar-card.tsx
@@ -6,10 +6,19 @@
import { Watch } from "lucide-react";
-import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
+import {
+ OAuthProviderCard,
+ type OAuthProviderStatus,
+} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
-export function PolarCard({ enabled = true }: { enabled?: boolean }) {
+export function PolarCard({
+ enabled = true,
+ viewModel,
+}: {
+ enabled?: boolean;
+ viewModel?: OAuthProviderStatus;
+}) {
return (
);
}
diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx
index 8990d665..3213a02b 100644
--- a/src/components/settings/integrations/shared.tsx
+++ b/src/components/settings/integrations/shared.tsx
@@ -18,7 +18,13 @@ import { queryKeys } from "@/lib/query-keys";
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
// IntegrationStatusPill now owns state + last-sync presentation, and
// the actionable error message is shown inline above the action row.
-export type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog";
+export type IntegrationKey =
+ | "withings"
+ | "whoop"
+ | "fitbit"
+ | "moodlog"
+ | "polar"
+ | "oura";
export type IntegrationState =
| "connected"
| "error_transient"
@@ -52,6 +58,11 @@ export interface IntegrationStatusViewModel {
// moodLog webhook secret + entry count.
webhookSecret?: string | null;
entryCount?: number;
+ // Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available`
+ // greys out the connect button when no credentials resolve; `hasOwnCredentials`
+ // drives the saved-placeholder UI.
+ available?: boolean;
+ hasOwnCredentials?: boolean;
}
export interface IntegrationStatusEnvelope {
From 497982b4b40d308ecb2aa28f8133451f68a4662a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:40:36 +0200
Subject: [PATCH 76/79] =?UTF-8?q?chore(release):=20v1.17.1=20=E2=80=94=20p?=
=?UTF-8?q?reventive=20care,=20lab=20results,=20and=20more=20of=20the=20da?=
=?UTF-8?q?ta=20you=20already=20have?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++
docs/api/openapi.yaml | 2 +-
package.json | 2 +-
public/sw.js | 2 +-
4 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71e96a1b..f6c5db66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,45 @@
## [Unreleased]
+## [1.17.1] — 2026-06-15 — preventive care, lab results, and more of the data you already have
+
+This release closes the loop from tracking to acting. Preventive-care reminders tell you when to measure or check what, structured lab results give your bloodwork a home, and a new recovery view surfaces signals that were already being collected but never shown. Sleep timelines now read in real clock times, marking a dose on one device clears its reminder on the others within seconds, and self-hosters gain a generic webhook channel, email, one-tap Web Push setup and a proper notifications guide. Every new number is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app. No breaking changes.
+
+### Added
+
+- **Preventive-care reminders.** Set a reminder to measure your blood pressure on a cadence or to schedule an annual blood panel, with a clear next-due date and where to do it. A matching measurement marks it done on its own; the rest you tick off. Reminders reach you over every notification channel, and the cards stay calm — status is a quiet badge, never an alarming colour.
+- **Structured lab results.** Record bloodwork and biomarkers with their reference range, see each one trend over time, and carry them into the doctor-report PDF and the FHIR export. An out-of-range value is shown plainly, not in red.
+- **A recovery view, and sleep quality in depth.** A new recovery page gathers strain, training load and autonomic-charge readings, and the sleep page gains an efficiency, performance and sleep-score block — metrics a connected device was already sending that nothing surfaced before. Each appears only once it has data and stays calm until it has enough to be sure.
+- **Import and backdated entry.** Import a CSV of past measurements with a previewed, per-row result and unit conversion, and log an entry with a past date and time — the cold-start escape hatches for bringing existing history in.
+- **Onboarding that does what it says.** A short, skippable health-baseline step, and the goals you pick now actually seed your dashboard.
+- **Polar and Oura credentials in the browser.** Both connect with your own developer-app credentials entered in settings, like the other integrations — no environment file needed.
+- **More ways to be notified.** A generic webhook channel reaches a self-hosted notifier or a chat service, an email channel sends over your own SMTP server, and an operator can see delivery health across every channel. Web Push keys can be generated in one click from the admin panel.
+
+### Changed
+
+- **Sleep reads in real clock times.** Per-stage rows now carry their own start and end, so the "last night" timeline lays out across the night instead of stacking — measured where the device reports stage timing, and an honestly-labelled reconstruction where it only reports stage totals. Nights logged by more than one source resolve to a single total.
+- **A dose taken on one device clears the others in seconds.** Marking a dose sends a silent sync to your other devices, so a lock-screen reminder ends without waiting for the next app open.
+- **More of a connected device's data flows in.** Readiness contributors, body-temperature deviation, blood-oxygen, a sleep score and autonomic-charge and training-load now come through where the source provides them. Body weight is never taken from a wearable strap.
+- **One product across every screen.** Desktop and mobile navigation now tell the same story, the coach has a single home, the layout and reminder settings each gather under one hub, and every integration card links to its setup guide.
+
+### Fixed
+
+- Sleep nights from some sources were stamped at the wrong instant and sat shifted earlier in the day; corrected, with a one-time backfill that re-syncs affected nights.
+- The doctor-report sleep figure now matches the dashboard and the companion app, reading the same reconstructed per-night total as every other surface.
+- Polish across the new surfaces: consistent loading and empty states, design-token colours, responsive grids, and a calm confirmation for regenerating Web Push keys.
+
+### Security
+
+- The webhook channel pins a public host and refuses a private or loopback address unless explicitly allowed; webhook secrets, SMTP credentials and the Web Push private key are kept out of any recorded error; the key-generation and delivery-health endpoints are reachable only from an authenticated admin session, never a token.
+
+### Self-hosting
+
+- A notifications guide spells out that no Apple account is needed — Web Push, Telegram, ntfy, the new webhook and email all work without one — alongside a backup-and-restore callout and a clearer note on which variables must be whitelisted to reach the container.
+
+### Operator note
+
+- Migrations 0162–0166 are additive. A boot-time backfill re-syncs sleep nights from the affected sources to the corrected timeline once; it is idempotent and bounded.
+
## [1.17.0] — 2026-06-14 — clinical depth for glucose and sleep, and three new sources
This release builds new depth on the coherent foundation laid over the v1.16 line. Blood glucose gains a clinical panel, sleep gains a debt and chronotype reading, and three new data sources — Nightscout, Polar and Oura — join Withings, WHOOP, Fitbit and Apple Health. Every new metric is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app, and each holds back a confident reading behind a calm "still learning" state until it has enough data. No breaking changes.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 5344d929..0a6360a7 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.17.0
+ version: 1.17.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/package.json b/package.json
index cff5b962..844133e9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.17.0",
+ "version": "1.17.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/public/sw.js b/public/sw.js
index 545453b9..2a9c762d 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.17.0";
+ /* @sw-version-fallback */ "v1.17.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
From 78f0e067b5bc973b1d1d91a6fd7b7f4fd1184663 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 01:49:28 +0200
Subject: [PATCH 77/79] fix(migrations): reference the mapped measurement_type
enum in the reminders table
The generated 0162 migration typed the measurement_reminders.measurement_type
column as the Prisma enum name "MeasurementType" rather than its @@map'd
Postgres type "measurement_type", so a fresh migrate deploy failed with
'type "MeasurementType" does not exist'. Unit typecheck passed (the generated
client is unaffected); only a real-Postgres deploy surfaces it. Verified the
full 0001-0166 chain applies clean against Postgres 16.
---
prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql
index 8a7615be..528b9896 100644
--- a/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql
+++ b/prisma/migrations/0162_v1171_creds_reminders_labs/migration.sql
@@ -45,7 +45,7 @@ CREATE TABLE IF NOT EXISTS "measurement_reminders" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"label" TEXT NOT NULL,
- "measurement_type" "MeasurementType",
+ "measurement_type" "measurement_type",
"interval_days" INTEGER,
"rrule" TEXT,
"anchor_date" TIMESTAMP(3),
From cbd5c2ba6f51c6e6b92c14563e49a63950b0f562 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 02:02:12 +0200
Subject: [PATCH 78/79] fix(withings): key sleep re-sync on the stable
externalId, not the shifting measuredAt
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With measuredAt now stamped at the segment END, a night that Withings
re-aggregates between syncs shifts its enddate, so the measuredAt-keyed
lookup missed the prior row and the create then collided with the unique
externalId — leaving the night stuck at its stale value. Key the lookup on
the stable externalId and refresh value, measuredAt and stage on update.
Caught by the withings-sleep-sync integration test against real Postgres.
---
src/lib/withings/__tests__/sync-sleep.test.ts | 8 +++++++-
src/lib/withings/sync-sleep.ts | 11 ++++++++---
2 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts
index 6ae04f3a..6cee9986 100644
--- a/src/lib/withings/__tests__/sync-sleep.test.ts
+++ b/src/lib/withings/__tests__/sync-sleep.test.ts
@@ -240,9 +240,15 @@ describe("syncUserSleep — segment writes + idempotency", () => {
const imported = await syncUserSleep("user-1");
expect(imported).toBe(1);
expect(prisma.measurement.create).not.toHaveBeenCalled();
+ // Re-sync keys on the stable externalId and refreshes value, the
+ // END-stamped measuredAt (enddate may shift) and the stage.
expect(prisma.measurement.update).toHaveBeenCalledWith({
where: { id: "row-1" },
- data: { value: 60 },
+ data: {
+ value: 60,
+ measuredAt: new Date(1715003600 * 1000),
+ sleepStage: "DEEP",
+ },
});
});
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index 4b92080d..27b643ba 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -275,20 +275,25 @@ export async function syncUserSleep(
const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segmentIndex}`;
try {
+ // Key the re-sync lookup on the STABLE `externalId` (segment id +
+ // index), not `measuredAt`. Withings re-aggregates a night between
+ // syncs, so a segment's `enddate` — and therefore its END-stamped
+ // `measuredAt` — shifts; keying on `measuredAt` would miss the prior
+ // row and then collide with the unique `externalId`, leaving the night
+ // stuck at the stale value. Update both `value` and `measuredAt`.
const existing = await prisma.measurement.findFirst({
where: {
userId,
type: SLEEP_TYPE,
- measuredAt,
source: "WITHINGS",
- sleepStage: stage,
+ externalId,
},
select: { id: true },
});
if (existing) {
await prisma.measurement.update({
where: { id: existing.id },
- data: { value: minutes },
+ data: { value: minutes, measuredAt, sleepStage: stage },
});
} else {
await prisma.measurement.create({
From f903b320af616694b0db670da14185b027824d00 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 02:11:04 +0200
Subject: [PATCH 79/79] test(rollups): compare rounded rollup means to live SQL
within hundredths, not bit-for-bit
The rollup tier rounds its bucket mean to two decimals and live SQL rounds the
full-day AVG to two decimals; rounding through different intermediate paths can
land one cent apart on a half-cent boundary, so the 5-decimal toBeCloseTo was a
data-dependent flake. Assert to within a few hundredths; min/max stay exact.
---
tests/integration/measurement-rollups.test.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/tests/integration/measurement-rollups.test.ts b/tests/integration/measurement-rollups.test.ts
index b911cffe..6fc26b3b 100644
--- a/tests/integration/measurement-rollups.test.ts
+++ b/tests/integration/measurement-rollups.test.ts
@@ -458,8 +458,12 @@ describe("measurement rollups — integration", () => {
expect(rollupDaily!.length).toBe(liveDaily.length);
for (let i = 0; i < liveDaily.length; i++) {
expect(rollupDaily![i].day).toBe(liveDaily[i].day);
- // round2 of the bucket mean == ROUND(AVG, 2) of the same rows.
- expect(rollupDaily![i].value).toBeCloseTo(liveDaily[i].value, 5);
+ // The rollup rounds its bucket mean to two decimals; live SQL rounds
+ // the full-day AVG to two decimals. Both are 2-decimal, but rounding
+ // through different intermediate paths can land one cent apart on a
+ // half-cent boundary — so assert agreement to within a few hundredths,
+ // not bit-for-bit. min/max above stay exact.
+ expect(rollupDaily![i].value).toBeCloseTo(liveDaily[i].value, 1);
}
}
});