diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index ebdea3088..e2367f2b7 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -33,6 +33,12 @@ jobs:
- name: ESLint
run: pnpm lint
+ # Prettier formatting gate. The format pass excludes generator-owned
+ # artifacts via .prettierignore (notably docs/api/openapi.yaml, which
+ # the OpenAPI drift check below enforces byte-for-byte).
+ - name: Prettier format check
+ run: pnpm format:check
+
- name: TypeScript strict check
run: pnpm typecheck
diff --git a/.gitignore b/.gitignore
index ec6b028a4..132725d27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,8 +46,9 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
-# prisma generated
-/src/generated/prisma
+# prisma generated — ignore the whole path so a stray symlink at
+# `src/generated` (e.g. from a worktree) can never be committed
+/src/generated
# v1.4.43 QoL (L3) — generated at build time by
# scripts/generate-sw-version.mjs; loaded by public/sw.js to key
diff --git a/.prettierignore b/.prettierignore
index 06202557b..68f690586 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,5 +2,17 @@ node_modules
.next
out
build
+coverage
+
+# Prisma-generated client.
src/generated
+
+# Lockfile — owned by pnpm.
pnpm-lock.yaml
+
+# Generator-owned API contract. The openapi:check job enforces its exact
+# bytes against the Zod registry, so prettier must never rewrite it.
+docs/api/openapi.yaml
+
+# Build-generated service worker (the PWA build emits it).
+public/sw.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c55c05f4..4b7edb69d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,52 @@
## [Unreleased]
+## [1.18.7] — 2026-06-19 — dashboard, Coach and insights polish
+
+A broad UI, insights, and AI-efficiency release. It ships database migrations (see the migration note below) and two client-facing contract changes: Coach budget responses now arrive as a stream error rather than a 429, and the measurement-reminder push carries the reminder id.
+
+### Added
+
+- The daily briefing surfaces "signals of the day" — today's readings against your 7- and 30-day trend, emerging slopes and recent outliers — and leads with one concrete, present-focused nudge.
+- Preventive-care cards show a quiet seven-day strip of the metric, and their menu jumps straight to the measurement history filtered to that type.
+- The Coach reads your active illness state and Rest Mode, can point you to the relevant in-depth guide, and draws on a tiered view of your history (recent days in full, older periods progressively summarised with peaks preserved).
+- Dictate to the Coach by voice.
+- The share-link section is back as its own Settings entry, next to the health-record export: mint a time-boxed, revocable, read-only link to share your record with a clinician.
+- Share links now carry a passphrase second factor. A new link mints a one-time passphrase embedded in a QR code (in the URL fragment, never sent to the server); the public view stays locked until the passphrase is verified. Links created before this release keep working without one.
+- Labs, preventive care, and the condition journal each have their settings as a proper section of the Settings shell, in the same layout as every other section, instead of a separate page.
+
+### Changed
+
+- The daily briefing regenerates every day and reads warmer and more motivating.
+- The Coach interface is rebuilt around a calm, centred reading column with a collapsible conversation list, a thinking indicator that hands off to streaming, and a quieter scrollbar.
+- The health-score rings are flattened to match the chart style — the glow, sheen and pulse are gone.
+- Sleep drops the redundant "last night" card; the chronotype reads as a prominent summary with your natural sleep midpoint and an expandable detail.
+- Every integration card follows one layout — a short description ending in an inline setup-guide link, with the credential hint above the save button.
+- A warm insight cycle now sends far fewer AI requests: the per-metric status assessments are batched into a single call, and the comprehensive path recovers from a malformed reply with one corrective retry instead of failing outright.
+- The daily briefing now varies its wording day to day while the underlying findings stay stable, so a quiet day no longer reads as a frozen cache.
+- Prompt construction is consolidated: grounding, tone, and the medication-safety rules come from one shared source across the briefing, status cards, narratives, and the Coach.
+- Settings cards share one card primitive and a consistent padding step, and touch controls across the app meet a 44-pixel target on phones while staying compact on wider screens.
+- The local-AI private-host switch accepts an explicit host allowlist instead of an all-or-nothing flag (the previous `true` still means "any private host").
+- Coach conversations are pruned on a schedule, alongside the existing notification-attempt retention.
+
+### Fixed
+
+- The daily briefing no longer goes stale: a fresh daily signal forces regeneration instead of re-stamping an unchanged narrative.
+- The sleep assessment no longer shows the "connect an AI provider" prompt when a provider is already connected.
+- A medication's supply now updates on the next read: adding a container or registering a new medication evicts the cached list instead of serving the pre-change stock for the cache window, and the card and table refresh together.
+- The seed for the public demo no longer produces an impossible blood-pressure reading, and reads as a current, healthy, fully-used account.
+
+### Security
+
+- The Coach budget gate reserves spend atomically before each request, so concurrent requests can no longer slip past the cap together, and tokens burned on an empty or refused reply are still counted.
+- New share links require a passphrase to open the record; the passphrase is stored only as an HMAC hash, verified in constant time behind a rate limit, and unlocks a short-lived view cookie scoped to that one link.
+- The session and authentication-challenge tables are indexed on their expiry column for the reaper.
+- The development key-padding fallback fails closed unless the environment is explicitly development or test, so a missing `NODE_ENV` can no longer silently derive a weak key.
+
+### Migration
+
+- This release adds three migrations: expiry indexes on the session and challenge tables, a daily-briefing reroll marker, and the nullable share-link passphrase hash. Run `prisma migrate deploy` (the entrypoint does this automatically). All three are additive — no backfill, no downtime.
+
## [1.18.6.1] — 2026-06-18 — settings and Coach UI follow-ups
A small UI and settings patch on top of v1.18.6. No migration, no API contract change.
@@ -2287,6 +2333,7 @@ iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neithe
### Tests
Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11:
+
- BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline`
- BP with null readings ever → tile NOT emitted
- Weight within 7 days → behaviour unchanged (regression guard)
@@ -2652,7 +2699,7 @@ self-heal via boot-time backfill on first reach.
card; not exposed via any route yet.
- **`computeLongWindowSummary(userId, type, windowDays)`** in
`summaries-slice.ts` — granularity-routed `count / min / max /
- mean / sum` aggregate for long-window consumers.
+mean / sum` aggregate for long-window consumers.
- **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` /
`readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with
legacy-NULL fallback.
@@ -2682,7 +2729,7 @@ rides the post-deploy window.
- **`/api/measurements?groupBy=day` cumulative path:** consumes
`sum_value` directly; eliminates per-type JS aggregation on
`ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE /
- TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
+TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
- **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing
425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type
loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes
@@ -2739,10 +2786,10 @@ false and stranded the whole helper on live SQL across the full
### Fixed
- **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS +
- PULSE + WEIGHT`** — the three types the helper actually reads.
+PULSE + WEIGHT`** — the three types the helper actually reads.
`isFullyCovered(coverage)` AND-term dropped.
- **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS +
- BLOOD_PRESSURE_DIA`**. Same fix.
+BLOOD_PRESSURE_DIA`**. Same fix.
- **`health-score-fast-path` gates only on `WEIGHT`**. Same fix.
Coverage-probe semantics unchanged. Each helper now consults the
@@ -3049,7 +3096,7 @@ without changing existing behaviour.
`aps.category = "MEDICATION_REMINDER"` on every medication-reminder
payload so iOS renders the three action-buttons (Genommen / Snooze
15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content
- = 1` is set by default so a future Notification Service Extension
+= 1` is set by default so a future Notification Service Extension
can hook the payload without a server-side change. The med-reminder
metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min"
action pins to the schedule slot rather than wall-clock delivery
@@ -3248,7 +3295,7 @@ dashboard SQL surfaced.
list through a Zod `refine` chained on the existing
`isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at
the write boundary so the new `to_char(measured_at AT TIME ZONE
- $tz)` SQL in the dashboard summary route cannot be self-DoS'd by
+$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
a corrupted stored value.
### Tests
@@ -3576,7 +3623,7 @@ surfaces city + country when the carrier lookup misses.
buckets + a freshly logged WEIGHT type) falls back to the live
aggregate so no type silently underreports.
- **Daily chart fetches opt into rollup buckets.** `GET
- /api/measurements?aggregate=daily&source=rollup` reads DAY buckets
+/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
from `measurement_rollups` instead of scanning the measurements
table when the window exceeds seven days. The Insights chart
fetchers pass `source=rollup`; iOS and other clients are
@@ -3643,7 +3690,7 @@ surfaces city + country when the carrier lookup misses.
`pickCumulativeDaySum` helper, source-priority-aware in the same
shape SLEEP_DURATION already used.
- **Insights nav strict-gate** flipped (`if (!availability) return
- true` → `return false`). Types without measurements no longer
+true` → `return false`). Types without measurements no longer
appear in the strip.
- **About → Admin Console.** "Über HealthLog" moved out of the
personal dropdown into a new `/admin/about` slug. The public
@@ -3669,7 +3716,7 @@ surfaces city + country when the carrier lookup misses.
### Schema
- **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL
- DEFAULT '{}'`). Migration
+DEFAULT '{}'`). Migration
`0068_v1436_insights_exclude_metrics` is idempotent under re-run.
### Refactor
@@ -3679,7 +3726,7 @@ surfaces city + country when the carrier lookup misses.
`applyInsightsExcludeFilter`, `pickCumulativeDaySum`,
`probeRollupCoverage`, `isFullyCovered`. Tested standalone.
- `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed:
- true, rollup_refresh_error: ... }` on failure instead of swallowing
+true, rollup_refresh_error: ... }` on failure instead of swallowing
silently. The read path still returns `{ recomputed: false }` so
the response never fails because of a populator hiccup.
- Cumulative metric-key lookup hoisted out of a four-level nested
@@ -3741,7 +3788,7 @@ zero reader-path impact (serial concurrency, runs in the background).
in place. The discovery query finds users with at least one row in
`measurements` and zero rows in `measurement_rollups`, then sends
one job per uncovered account with `singletonKey:
- boot-backfill|{userId}` so a fast restart while a fold is queued
+boot-backfill|{userId}` so a fast restart while a fold is queued
doesn't pile up duplicates.
- **Best-effort:** any error during discovery is captured in the
return value and logged through `workerLog`; the worker boot never
@@ -3756,7 +3803,7 @@ zero reader-path impact (serial concurrency, runs in the background).
user lands on the queue when a third already-folded user co-exists;
zero jobs enqueued when every account is already covered.
- Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm
- typecheck` + `pnpm lint` clean.
+typecheck` + `pnpm lint` clean.
## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap
@@ -3774,7 +3821,7 @@ across every analytics surface is planned for a follow-up.
- **New table `measurement_rollups`** keyed on
`(user_id, type, granularity, bucket_start)` with
`count / mean / min_value / max_value / sd / slope / r2 /
- computed_at`. Backed by a composite-descending index on
+computed_at`. Backed by a composite-descending index on
`(user_id, type, granularity, bucket_start DESC)`. Additive only —
no `ALTER` on existing tables. Migration
`0067_v1434_measurement_rollups` is idempotent under re-run.
@@ -3850,8 +3897,8 @@ viewport-zoom fix surfaced during the mobile-deep pass.
- `tests/integration/withings-oauth.test.ts` covers connect /
callback / disconnect plus state-token replay rejection.
- `tests/integration/passkey-register.test.ts` covers register-options
- + register-verify + tampered-attestation rejection + challenge
- single-use.
+ - register-verify + tampered-attestation rejection + challenge
+ single-use.
- `tests/integration/cache-invalidation-coverage.test.ts` grids the
v1.4.34 IW-G invalidation matrix across 5 write surfaces.
- Integration suite goes from 197/50 to 222/55. tsc + lint clean.
@@ -3923,7 +3970,7 @@ freeze.
- **Focus rings on mobile + tablet navigation.** The bottom-nav primary
links, the More overflow trigger, and the mobile top-bar user menu
trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2
- focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
+focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
and switch-control users can now see focus on every nav surface.
- **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and
DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px
@@ -4157,7 +4204,7 @@ without leaking userIds.
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
- upserting | done | failed`), the content-hash for idempotency
+upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
@@ -4408,8 +4455,7 @@ the affected surfaces.
`very_late`.** Defensive fallback when the dose-window
estimator can't resolve a window.
- **`/api/insights/generate` POST gating.** Endpoint now refuses
- on a disabled assistant master flag instead of returning a
- 500.
+ on a disabled assistant master flag instead of returning a 500.
- **Mood Log overflow on narrow viewports.** Cards no longer
shred their internal grid below 360 CSS px.
- **F13 username readability.** Header username now respects
@@ -4756,7 +4802,7 @@ permission picker and the future Insights nav.
### Added
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
- date)` mints `"stats::"`
+date)` mints `"stats::"`
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
locked in the iOS coordination notes (§12). iOS
emits one row per day per cumulative type via
@@ -4839,11 +4885,12 @@ encounter them in read paths because no source writes them yet, and
the codegen path will pick them up on the next iOS regeneration.
**Cutover sequence.** v1.4.30 ships with the helper + drain script
-+ server tolerance for both shapes. The next iOS TestFlight build
-adopts `HealthKitStatisticsService.swift` and starts posting daily-
-aggregated rows for the five cumulative types. Operator runs the
-drain script once after the new TestFlight cuts over; per-sample
-row pressure on `Measurement` drops 50-200× for cumulative types.
+
+- server tolerance for both shapes. The next iOS TestFlight build
+ adopts `HealthKitStatisticsService.swift` and starts posting daily-
+ aggregated rows for the five cumulative types. Operator runs the
+ drain script once after the new TestFlight cuts over; per-sample
+ row pressure on `Measurement` drops 50-200× for cumulative types.
## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix
@@ -5100,7 +5147,7 @@ internal-only `/api/internal/web-vitals` beacon route).
GLP-1 row painted with a brand icon and a middle-dot separator;
every other row painted two-line without an icon. Both routes
through a shared `` now — line 1 is `{name}
- {dose}`, line 2 is the class label plus state badges. The GLP-1
+{dose}`, line 2 is the class label plus state badges. The GLP-1
outlier shape is gone.
- **HealthScore card came up short below the action+prompts column.**
The card now opts into `flex h-full flex-col` with the disclaimer
@@ -5138,7 +5185,7 @@ internal-only `/api/internal/web-vitals` beacon route).
- **Medications detail-page chrome collapsed to one heading scale.**
Every section on `/medications/[id]` mounts through
`` (`text-base font-semibold leading-6
- tracking-tight`). DrugLevelChart's standalone header migrates to
+tracking-tight`). DrugLevelChart's standalone header migrates to
`` with the same classes. Micro labels lift from
`text-[10px]` / `text-[11px]` to `text-xs` across Scheduling,
Titration, SideEffects. Three scales survive the page: heading,
@@ -5663,7 +5710,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
`router.replace`s to a clean URL.
- **`/api/version` register button below the tap floor.** The
`/auth/register` submit button promotes to `size="lg" min-h-11
- w-full` so the primary action stays finger-tap reachable on
+w-full` so the primary action stays finger-tap reachable on
narrow viewports.
- **Workouts pagination broken under canonical dedup.** Pulling the
full filtered set and slicing post-dedup yields the correct
@@ -5759,8 +5806,8 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
contract and the SSR `useIsMobile` first-paint.
- **16 new `metric-availability.test.ts` cases** cover each metric ×
`{has data, no data, undefined summaries, missing summary entry,
- BMI-from-WEIGHT derivation, sys-vs-dia independence,
- mood/medication overrides}`.
+BMI-from-WEIGHT derivation, sys-vs-dia independence,
+mood/medication overrides}`.
- **6 new `insights-tab-strip.test.tsx` cases** assert
backward-compat without `availability`, pill-drop when data is
missing, overview pill always renders, mood + medication
@@ -5928,7 +5975,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
browser-detect maps `fr|es|it|pl` to the corresponding bundle.
- **Coach — native first-party system prompts across six locales.**
Coach + insights system prompts move from EN body + `REPLY
- LANGUAGE` footer to native per-locale bodies; the safety contract
+LANGUAGE` footer to native per-locale bodies; the safety contract
matrix lives as YAML per locale (single source of truth across
drafting + tests). The refusal-probe matrix in CI catches
cross-locale prompt regressions before they reach the dispatch
@@ -5939,7 +5986,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
- **Personal Records end-to-end — schema, detection worker, badge,
push opt-in.** Migration 0054 introduces the `PersonalRecord`
table + `PersonalRecordDirection` helper; `GET
- /api/personal-records` is paginated (default twenty-five, max two
+/api/personal-records` is paginated (default twenty-five, max two
hundred). A pg-boss worker sweeps MAX / MIN per metric and the
workout slots on every batch-ingest + a thirty-minute fallback
cron (concurrency five, warmup gate so the very first datapoint
@@ -6010,7 +6057,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
GLP-1 detail page renders the user's current step + remaining
ladder as observational reference, framed as reference-not-advice
and bound by GROUND RULE 15. `GET
- /api/medications/[id]/titration-ladder` returns the structured
+/api/medications/[id]/titration-ladder` returns the structured
ladder + current step.
- **GLP-1 — pen-and-vial inventory with a 30-day in-use clock.**
Migration 0056 introduces the `MedicationInventoryItem` table +
@@ -6064,7 +6111,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
and the per-row collapse affordance comes off.
- **DELETE `/api/measurements/by-external-ids` for iOS deletion
sync.** Idempotent batch delete keyed on `(user, source,
- externalId)` tuples; rate-limited the same way as the batch
+externalId)` tuples; rate-limited the same way as the batch
ingest endpoint.
- **Multi-arch Docker image.** GHCR publish workflow builds
`linux/amd64` on `ubuntu-latest` plus `linux/arm64` on
@@ -6072,7 +6119,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
Macs and arm64 clouds now pull native; the previously-stale
README claim is accurate again.
- **GLP-1 endpoint hardening.** `POST
- /api/medications/[id]/glp1` now parses through bounded Zod
+/api/medications/[id]/glp1` now parses through bounded Zod
schemas (`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`)
with length-capped notes, finite-number guards on dose value,
and bounded `effectiveFrom`. Every write produces an audit-log
@@ -6157,7 +6204,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
`src/lib/medications/route-guards.ts` rather than open-coding
the lookup; rate-limit headers across the same surface align
on the option-bag form `apiError(..., { headers:
- rateLimitHeaders(rl) })`.
+rateLimitHeaders(rl) })`.
### Fixed
@@ -6237,7 +6284,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
single-entry POST stripped it on the Zod boundary. Mirror parity
restored.
- **Personal-records endpoint unpaginated.** `GET
- /api/personal-records` now clamps `?limit` (default twenty-five,
+/api/personal-records` now clamps `?limit` (default twenty-five,
maximum two hundred); the unbounded read is gone.
- **Withings activity sync bucketed positive-offset users into the
wrong day.** The per-day row was anchored at 23:59:59 UTC, so a
@@ -6356,7 +6403,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
2024 pharmacokinetic reference. The acknowledgment version
re-prompts the user on bump.
- **GLP-1 convenience endpoint hardened.** `POST
- /api/medications/[id]/glp1` parses through bounded Zod schemas
+/api/medications/[id]/glp1` parses through bounded Zod schemas
(`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`), enforces
length caps on notes, finite-number guards on dose value, and
bounded `effectiveFrom`; every write produces an audit-log row
@@ -6562,7 +6609,7 @@ estimates and source citations, is tracked internally. Headline items:
- **iOS Swift app — P1 through P5.** Login + dashboard + widget
(P1); Apple Health sync (P2); Coach extended for HRV / sleep /
resting HR / steps (P3); per-metric APNs alerts (P4); workouts
- + GeoJSON routes (P5). All server contracts locked in v1.4.25.
+ - GeoJSON routes (P5). All server contracts locked in v1.4.25.
- **Workout ingest API matching the v1.5 iOS contract.**
Schema shipped this release; endpoint signature finalised
during the iOS sprint.
diff --git a/CLAUDE.md b/CLAUDE.md
index eeb174169..cc180a5fe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,19 +32,19 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
## Tech stack at a glance
-| Layer | Tech | Notes |
-|---|---|---|
-| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
-| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
-| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
-| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
-| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
-| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
-| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
-| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
-| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
-| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
-| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
+| Layer | Tech | Notes |
+| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
+| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
+| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
+| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
+| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
+| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
+| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
+| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
+| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
+| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
+| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
@@ -110,23 +110,23 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
## Critical-files map
-| Concern | File | What it owns |
-|---|---|---|
-| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
-| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
-| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
-| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
-| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
-| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
-| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
-| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
-| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
-| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
-| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
-| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
-| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
-| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
-| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
+| Concern | File | What it owns |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
+| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
+| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
+| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
+| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
+| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
+| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
+| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
+| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
+| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
+| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
+| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
+| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
+| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
+| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
@@ -181,21 +181,22 @@ The native SwiftUI client lives in a separate repository and rides public beta v
## Pointers
-| What | Where |
-|---|---|
-| Public face | `README.md` |
-| Release history | `CHANGELOG.md` |
-| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
-| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
-| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
-| Self-hosting guide | `docs/self-hosting/` |
-| Public API contract | `docs/api/openapi.yaml` |
-| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
-| iOS coordination notes | `.planning/ios-coord/` |
-| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
-| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
+| What | Where |
+| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Public face | `README.md` |
+| Release history | `CHANGELOG.md` |
+| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
+| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
+| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
+| Self-hosting guide | `docs/self-hosting/` |
+| Public API contract | `docs/api/openapi.yaml` |
+| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
+| iOS coordination notes | `.planning/ios-coord/` |
+| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
+| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
## Agent context hygiene (ECONNRESET / oversized-request prevention)
-- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET` on large turns. Regenerate via `pnpm prisma generate` if needed; never open it. (Enforced via `.claude/settings.local.json` Read-deny.)
+
+- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.)
- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads.
- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions.
diff --git a/docs/adr/0001-insights-naming-convention.md b/docs/adr/0001-insights-naming-convention.md
index 2dd5ba69a..6d52afa06 100644
--- a/docs/adr/0001-insights-naming-convention.md
+++ b/docs/adr/0001-insights-naming-convention.md
@@ -32,7 +32,7 @@ Separate the two naming axes cleanly:
- **UI copy is always localised.** Every visible string resolves through an
i18n `t()` key and is translated across all six locales (`de / en / es / fr /
- it / pl`). The user never sees a raw identifier.
+it / pl`). The user never sees a raw identifier.
- **Internal identifiers are English.** Route slugs (`/insights/`), the
tile-layout enum (`INSIGHTS_TILE_IDS`), query keys, and the internal
slug→metric / slug→target maps all use established English terms. English is
@@ -54,7 +54,7 @@ The migration must not break either client-visible boundary:
navigation keep resolving.
- **Tile-layout contract.** The layout endpoint keeps **accepting** the legacy
German tile ids on input. The Zod enum is built from
- `ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids *plus* the legacy German
+ `ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids _plus_ the legacy German
aliases), so a `PUT` carrying old ids validates rather than tripping a 422.
`normalizeInsightsTileId` collapses each legacy id onto its canonical English
replacement; this normalisation runs on **both** the read path
diff --git a/docs/api/cycle-healthkit-mapping.md b/docs/api/cycle-healthkit-mapping.md
index 69bce5e91..bf827824f 100644
--- a/docs/api/cycle-healthkit-mapping.md
+++ b/docs/api/cycle-healthkit-mapping.md
@@ -13,16 +13,16 @@ row. The fold only runs when cycle tracking is enabled for the account.
## Day-log fields
-| HealthKit category type | CycleDayLog field | Values |
-|---|---|---|
-| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
-| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
-| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
-| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
-| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
-| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
-| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
-| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
+| HealthKit category type | CycleDayLog field | Values |
+| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
+| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
+| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
+| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
+| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
+| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
+| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
+| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
@@ -31,21 +31,21 @@ logged-but-unspecified bleeding day is at least light flow).
## Symptom category types → seeded symptom keys
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
-sample whose severity is *not present* (codepoint 1) does NOT create a link.
+sample whose severity is _not present_ (codepoint 1) does NOT create a link.
-| HealthKit category type | CycleSymptom key |
-|---|---|
-| `AbdominalCramps` | `cramps` |
-| `Headache` | `headache` |
-| `Bloating` | `bloating` |
-| `Acne` | `acne` |
-| `BreastPain` | `breast_tenderness` |
-| `Fatigue` | `fatigue` |
-| `LowerBackPain` | `back_pain` |
-| `SleepChanges` | `insomnia` |
-| `MoodChanges` | `mood_swings` |
-| `AppetiteChanges` | `food_cravings` |
-| `Nausea` | `nausea` |
+| HealthKit category type | CycleSymptom key |
+| ----------------------- | ------------------- |
+| `AbdominalCramps` | `cramps` |
+| `Headache` | `headache` |
+| `Bloating` | `bloating` |
+| `Acne` | `acne` |
+| `BreastPain` | `breast_tenderness` |
+| `Fatigue` | `fatigue` |
+| `LowerBackPain` | `back_pain` |
+| `SleepChanges` | `insomnia` |
+| `MoodChanges` | `mood_swings` |
+| `AppetiteChanges` | `food_cravings` |
+| `Nausea` | `nausea` |
## Profile-level signals
@@ -64,8 +64,8 @@ the temperature-trend ovulation method directly from `Measurement`.
## Deferred
-| HealthKit category type | Status |
-|---|---|
-| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
-| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
-| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
+| HealthKit category type | Status |
+| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
+| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
+| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
+| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index f560b3954..10a6ed223 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.18.6.1
+ version: 1.18.7
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -989,7 +989,9 @@ paths:
- Export
summary: Create a clinician share link (v1.11.0)
description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY
- ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen
+ ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with
+ `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase
+ cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen
write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited
(`share-link:`, 20/h). Strict: unknown keys 422."
requestBody:
@@ -1148,6 +1150,50 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
+ /api/c/{token}/unlock:
+ post:
+ tags:
+ - Export
+ summary: Verify a share-link passphrase (v1.18.7, public)
+ description: Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted
+ passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, httpOnly
+ cookie so `/c/` renders the record. Rate-limited (`share-unlock::`); every failure class
+ (bad token, no passphrase set, wrong passphrase) answers one blunt 401.
+ parameters:
+ - in: path
+ name: token
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ passphrase:
+ type: string
+ minLength: 1
+ maxLength: 64
+ required:
+ - passphrase
+ additionalProperties: false
+ responses:
+ "200":
+ description: Passphrase accepted; short-lived token-scoped unlock cookie set.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ShareLinkUnlock"
+ "401":
+ description: Invalid passphrase (blunt — no failure detail leaked).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
/api/fhir/metadata:
get:
tags:
@@ -9701,6 +9747,8 @@ components:
type: string
allowFhirApi:
type: boolean
+ protected:
+ type: boolean
expiresAt:
type: string
createdAt:
@@ -9720,6 +9768,16 @@ components:
token:
type: string
description: Raw `hls_` token — returned ONCE and unrecoverable thereafter.
+ passphrase:
+ type: string
+ description: Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable.
+ shareUrl:
+ type: string
+ description: Absolute `/c/` URL (no passphrase).
+ qrUrl:
+ type: string
+ description: Absolute share URL with the passphrase in the URL FRAGMENT (`#k=`). Encode this as the QR
+ payload; the fragment is never sent to the server.
required:
- id
- label
@@ -9727,6 +9785,7 @@ components:
- rangeEnd
- resourceTypes
- allowFhirApi
+ - protected
- expiresAt
- createdAt
- revokedAt
@@ -9734,6 +9793,9 @@ components:
- accessCount
- active
- token
+ - passphrase
+ - shareUrl
+ - qrUrl
additionalProperties: false
error:
type: "null"
@@ -9774,6 +9836,8 @@ components:
type: string
allowFhirApi:
type: boolean
+ protected:
+ type: boolean
expiresAt:
type: string
createdAt:
@@ -9797,6 +9861,7 @@ components:
- rangeEnd
- resourceTypes
- allowFhirApi
+ - protected
- expiresAt
- createdAt
- revokedAt
@@ -9845,6 +9910,30 @@ components:
- data
- error
additionalProperties: false
+ ShareLinkUnlock:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ unlocked:
+ type: boolean
+ const: true
+ required:
+ - unlocked
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
LoginResponse:
type: object
properties:
diff --git a/docs/apple-store-connect-checklist.md b/docs/apple-store-connect-checklist.md
index 88806d1d1..c16fe0e45 100644
--- a/docs/apple-store-connect-checklist.md
+++ b/docs/apple-store-connect-checklist.md
@@ -11,6 +11,7 @@ last_updated: 2026-05-15
## TL;DR
Three submission gates that need the maintainer's hand at ASC:
+
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
3. Standard metadata (name, description, screenshots, categories, age rating)
@@ -24,9 +25,11 @@ Plus pre-submit final checks.
**Decision**: HealthLog is **NOT a medical device** under EU MDR 2017/745 / FDA 21 CFR 880 / Apple's interpretation.
**Recommended wording in the declaration form**:
+
> HealthLog is a personal health-data logging and visualization app. It displays user-entered observations + integrates with Apple HealthKit, Withings, and manual entry. It does not diagnose, treat, prescribe, or recommend dosing. The AI Coach surfaces are explicit-refusal-gated (GROUND RULES 9 + 15) against any medical-device-class output. The drug-level estimation chart is display-only behind a versioned MDR-acknowledgment dialog citing EU 2017/745 + MDCG 2021-24. For medical advice, users must consult licensed clinicians.
**What to check**:
+
- [ ] Check "No" on the medical-device toggle
- [ ] Add the description above to the medical-device-justification field if Apple shows one
- [ ] Cross-reference the in-app `/privacy` page (https:///privacy) §7 MDR-boundary
@@ -44,22 +47,26 @@ Plus pre-submit final checks.
For each category below: **Linked to user, used for App Functionality + Account Management**. Optional purposes: never enable Analytics, Tracking, Advertising, or Third-Party Tracking.
**Health & Fitness**
+
- Health: yes (all HKQuantityType + HKCategoryType the iOS app reads — see `/privacy` page §2)
- Fitness: yes (steps, distance, active energy, workouts)
- Purpose: App Functionality
- Linked to user: yes
**Contact Info**
+
- Email Address: yes (for account login + GDPR data-subject correspondence)
- Purpose: App Functionality + Account Management
- Linked to user: yes
**Sensitive Info**
+
- Blood pressure, glucose, medications, drug-related observations: yes
- Purpose: App Functionality
- Linked to user: yes
**User Content**
+
- Emails or Text Messages: NO (the AI Coach is in-app conversation, but iOS does not send emails)
- Photos or Videos: NO
- Audio Data: NO (HealthKit audio-exposure is environmental dB; not the user's voice)
@@ -69,12 +76,14 @@ For each category below: **Linked to user, used for App Functionality + Account
- Linked to user: yes
**Identifiers**
+
- User ID: yes (server-side user UUID)
- Device ID: yes (X-Device-Id Keychain-stored UUID, APNs push token)
- Purpose: App Functionality + Account Management
- Linked to user: yes
**Other Data Types**
+
- Other Data Types: yes (general medical observations not fitting above)
- Purpose: App Functionality
- Linked to user: yes
@@ -97,6 +106,7 @@ For each category below: **Linked to user, used for App Functionality + Account
## Section 3 — Standard Metadata Checklist
### App Information
+
- [ ] Name: "HealthLog"
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
- [ ] Primary category: Health & Fitness
@@ -105,25 +115,30 @@ For each category below: **Linked to user, used for App Functionality + Account
- [ ] Age rating: 17+ (because Sensitive Health Info content)
### Description
+
- [ ] Long-form description (4000 char max): cover Insights + Coach + GLP-1 + Withings + Apple HealthKit + open-source nature + EU GDPR compliance
- [ ] What's New (version-by-version notes): v0.3.0 = "Server-sync, comprehensive Apple HealthKit support across 19 sample types, smart Insights, AI Coach, Health Score, GLP-1 tracking with Research-Mode-gated drug-level chart, interactive charts with drill-down, push notifications, deep links, and account deletion."
### Screenshots
+
- [ ] 6.5" iPhone screenshots: cover Onboarding (welcome carousel + permissions), Dashboard (Health Score tile + tile-strip), Insights (sub-page with severity sort), Coach (refusal example to show MDR boundary), Medications (GLP-1 detail stack), Settings (Research Mode toggle + account deletion).
- [ ] 5.5" iPhone screenshots: same set (Apple's legacy size).
- [ ] iPad screenshots if supporting iPad.
### URLs
+
- [ ] Marketing URL: https:// (or the marketing site once the landing repo's v1.4.25 is deployed)
- [ ] Privacy Policy URL: **https:///privacy** (ships with v1.4.25.1 hotfix)
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
### App Review Information
+
- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
- [ ] Sign-in required: yes (provide demo credentials)
### Pricing + Availability
+
- [ ] Free
- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
@@ -154,6 +169,7 @@ Run through this list:
---
**Source links**:
+
- Apple Health App Guidelines: https://developer.apple.com/app-store/review/guidelines/#health-and-health-research
- Apple App Privacy Details: https://developer.apple.com/app-store/app-privacy-details/
- EU MDR 2017/745: https://eur-lex.europa.eu/eli/reg/2017/745/oj
diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md
index 5311e5ed7..101cabfed 100644
--- a/docs/diagrams/README.md
+++ b/docs/diagrams/README.md
@@ -6,13 +6,13 @@ pages as ` ` tags. Mirrored into `healthlog-docs/src/assets/diagrams/`
and `healthlog-landing/public/diagrams/` so each surface ships its own
copy.
-| File | Story it tells |
-| ---- | -------------- |
-| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
-| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
-| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
-| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
-| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
+| File | Story it tells |
+| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
+| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
+| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
+| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
+| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
## Editing
diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md
index 547364392..1b1600b7e 100644
--- a/docs/integrations/ai-providers.md
+++ b/docs/integrations/ai-providers.md
@@ -9,12 +9,12 @@ Ollama endpoint can fall back from one to the other automatically.
## Provider matrix
-| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
-| -------- | ---------------- | ------------- | -------- | -------------- |
-| `OPENAI` | | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
-| `ANTHROPIC` | | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
-| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
-| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
+| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
+| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- |
+| `OPENAI` | | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
+| `ANTHROPIC` | | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
+| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
+| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
The four spellings above are the canonical `User.aiProvider` enum
values. Anything else falls through to the admin-shared OpenAI key
@@ -134,11 +134,14 @@ API key at rest like every other provider credential.
**SSRF guard.** Local endpoints route through the same validation
as every other outbound URL HealthLog hits. The guard rejects
-private-IP-range targets by default — set
-`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment to
-allow `localhost`, RFC1918, and link-local destinations
-(`src/lib/ai/provider.ts:456-462`). Leave it unset on public-facing
-instances unless you specifically run an internal endpoint.
+private-IP-range targets by default. `ALLOW_LOCAL_AI_PRIVATE_HOSTS`
+opens the escape hatch and accepts two forms: a comma-separated host
+allowlist (e.g. `ALLOW_LOCAL_AI_PRIVATE_HOSTS=ollama.lan,10.0.0.5`),
+which permits only those exact hostnames, or the legacy
+`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which permits any private host —
+including the cloud-metadata endpoint, so prefer the host list. Leave
+it unset on public-facing instances unless you specifically run an
+internal endpoint.
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
@@ -232,9 +235,11 @@ error.
provider in `/settings/ai`, or configure an admin-shared OpenAI
key in the admin panel.
- **Local endpoint rejected as "internal/private host".** Set
- `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment.
- Leave it unset on internet-facing instances unless you specifically
- want to allow private-range targets.
+ `ALLOW_LOCAL_AI_PRIVATE_HOSTS` to the endpoint's exact hostname
+ (e.g. `ollama.lan`; comma-separate several) in the container
+ environment, or to `true` to allow any private host. Prefer the host
+ list; leave it unset on internet-facing instances unless you
+ specifically want to allow private-range targets.
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
refresh tokens lapse after extended idle periods. Reconnect via
the **Connect ChatGPT** button — HealthLog re-runs the device-code
diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md
index 285982d81..3f9d3eefe 100644
--- a/docs/integrations/apple-health.md
+++ b/docs/integrations/apple-health.md
@@ -97,12 +97,12 @@ they triggered.
The parser folds four XML element types into the existing HealthLog
schema (CHANGELOG v1.4.34):
-| XML element | Maps to | Notes |
-| ----------- | ------- | ----- |
-| `` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:` fallback so re-imports stay idempotent. |
-| `` | `Workout` rows | Activity type + duration + energy + distance. |
-| `` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
-| `` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
+| XML element | Maps to | Notes |
+| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:` fallback so re-imports stay idempotent. |
+| `` | `Workout` rows | Activity type + duration + energy + distance. |
+| `` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
+| `` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
The HK quantity types that map to HealthLog metric types live in
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
@@ -187,13 +187,13 @@ Override per-user via the Sources section of `/settings/thresholds`.
## Failure modes
-| Failure | What you see | Recovery |
-| ------- | ------------ | -------- |
-| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
-| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
-| Unsupported compression method | `failureReason: "Unsupported ZIP compression method for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
-| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
-| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
+| Failure | What you see | Recovery |
+| --------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
+| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
+| Unsupported compression method | `failureReason: "Unsupported ZIP compression method for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
+| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
+| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
The wide-event log line `import.apple-health.kickoff` (or
`import.apple-health.kickoff.denied` on failure) captures every
diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md
index 514a6907b..a128097a8 100644
--- a/docs/integrations/data-import.md
+++ b/docs/integrations/data-import.md
@@ -36,8 +36,12 @@ arrays:
```json
{
- "measurements": [ /* … */ ],
- "moodEntries": [ /* … */ ]
+ "measurements": [
+ /* … */
+ ],
+ "moodEntries": [
+ /* … */
+ ]
}
```
@@ -48,26 +52,26 @@ skipped and counted under `skipped` in the response.
### Measurement entry
-| Field | Required | Type | Notes |
-|---|---|---|---|
-| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
-| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
-| `unit` | yes | string | The canonical unit for the type (see the table). |
-| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
-| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
-| `notes` | no | string | Optional free text. |
-| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
+| Field | Required | Type | Notes |
+| ---------------- | -------- | ------ | --------------------------------------------------------------------------------- |
+| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
+| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
+| `unit` | yes | string | The canonical unit for the type (see the table). |
+| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
+| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
+| `notes` | no | string | Optional free text. |
+| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
### Mood entry
-| Field | Required | Type | Notes |
-|---|---|---|---|
-| `date` | yes | string | `YYYY-MM-DD`. |
-| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
-| `score` | yes | integer | `1`–`5`. |
-| `tags` | no | string | Comma-separated tags. |
-| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
-| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
+| Field | Required | Type | Notes |
+| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `date` | yes | string | `YYYY-MM-DD`. |
+| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
+| `score` | yes | integer | `1`–`5`. |
+| `tags` | no | string | Comma-separated tags. |
+| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
+| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
@@ -76,72 +80,72 @@ display per the user's preference. Event-class types record a single
fired event (`value` is `1`) and are normally produced by a device, not
hand-authored.
-| Type | Canonical unit |
-|---|---|
-| `WEIGHT` | kg |
-| `BLOOD_PRESSURE_SYS` | mmHg |
-| `BLOOD_PRESSURE_DIA` | mmHg |
-| `PULSE` | bpm |
-| `RESTING_HEART_RATE` | bpm |
-| `WALKING_HEART_RATE_AVERAGE` | bpm |
-| `AVERAGE_HEART_RATE` | bpm |
-| `MAX_HEART_RATE` | bpm |
-| `CARDIO_RECOVERY` | bpm |
-| `HEART_RATE_VARIABILITY` | ms (SDNN) |
-| `HRV_RMSSD` | ms (RMSSD) |
-| `RESPIRATORY_RATE` | count/min |
-| `OXYGEN_SATURATION` | percent (0–100) |
-| `BLOOD_GLUCOSE` | mg/dL |
-| `BODY_FAT` | percent (0–100) |
-| `FAT_MASS` | kg |
-| `FAT_FREE_MASS` | kg |
-| `LEAN_BODY_MASS` | kg |
-| `MUSCLE_MASS` | kg |
-| `BONE_MASS` | kg |
-| `TOTAL_BODY_WATER` | kg |
-| `VISCERAL_FAT` | rating |
-| `BODY_MASS_INDEX` | kg/m² |
-| `BODY_TEMPERATURE` | °C (core) |
-| `SKIN_TEMPERATURE` | °C (dermal spot) |
-| `WRIST_TEMPERATURE` | °C (overnight wrist) |
-| `SLEEP_DURATION` | minutes |
-| `SLEEP_NEED` | minutes |
-| `SLEEP_PERFORMANCE` | percent (0–100) |
-| `SLEEP_EFFICIENCY` | percent (0–100) |
-| `SLEEP_CONSISTENCY` | percent (0–100) |
-| `SLEEP_DISTURBANCE_COUNT` | count |
-| `BREATHING_DISTURBANCES` | count |
-| `ACTIVITY_STEPS` | count |
-| `FLIGHTS_CLIMBED` | count |
-| `WALKING_RUNNING_DISTANCE` | metres |
-| `SIX_MINUTE_WALK_DISTANCE` | metres |
-| `WALKING_STEP_LENGTH` | metres |
-| `WALKING_SPEED` | m/s |
-| `STAIR_ASCENT_SPEED` | m/s |
-| `STAIR_DESCENT_SPEED` | m/s |
-| `WALKING_STEADINESS` | percent (0–100) |
-| `WALKING_ASYMMETRY` | percent (0–100) |
-| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
-| `ACTIVE_ENERGY_BURNED` | kcal |
-| `ENERGY_EXPENDITURE_KJ` | kJ |
-| `VO2_MAX` | mL/(kg·min) |
-| `PULSE_WAVE_VELOCITY` | m/s |
-| `VASCULAR_AGE` | years |
-| `FALL_COUNT` | count |
-| `AUDIO_EXPOSURE_ENV` | dBA |
-| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
-| `TIME_IN_DAYLIGHT` | minutes |
-| `RECOVERY_SCORE` | score (0–100) |
-| `STRESS_SCORE` | score (0–100) |
-| `STRAIN_SCORE` | score (0–100) |
-| `DAY_STRAIN` | score (0–21) |
-| `WORKOUT_STRAIN` | score (0–21) |
-| `AUDIO_EXPOSURE_EVENT` | count (event) |
-| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
-| `HIGH_HEART_RATE_EVENT` | event |
-| `LOW_HEART_RATE_EVENT` | event |
-| `WALKING_STEADINESS_EVENT` | event |
-| `BREATHING_DISTURBANCE_EVENT` | event |
+| Type | Canonical unit |
+| ------------------------------- | -------------------- |
+| `WEIGHT` | kg |
+| `BLOOD_PRESSURE_SYS` | mmHg |
+| `BLOOD_PRESSURE_DIA` | mmHg |
+| `PULSE` | bpm |
+| `RESTING_HEART_RATE` | bpm |
+| `WALKING_HEART_RATE_AVERAGE` | bpm |
+| `AVERAGE_HEART_RATE` | bpm |
+| `MAX_HEART_RATE` | bpm |
+| `CARDIO_RECOVERY` | bpm |
+| `HEART_RATE_VARIABILITY` | ms (SDNN) |
+| `HRV_RMSSD` | ms (RMSSD) |
+| `RESPIRATORY_RATE` | count/min |
+| `OXYGEN_SATURATION` | percent (0–100) |
+| `BLOOD_GLUCOSE` | mg/dL |
+| `BODY_FAT` | percent (0–100) |
+| `FAT_MASS` | kg |
+| `FAT_FREE_MASS` | kg |
+| `LEAN_BODY_MASS` | kg |
+| `MUSCLE_MASS` | kg |
+| `BONE_MASS` | kg |
+| `TOTAL_BODY_WATER` | kg |
+| `VISCERAL_FAT` | rating |
+| `BODY_MASS_INDEX` | kg/m² |
+| `BODY_TEMPERATURE` | °C (core) |
+| `SKIN_TEMPERATURE` | °C (dermal spot) |
+| `WRIST_TEMPERATURE` | °C (overnight wrist) |
+| `SLEEP_DURATION` | minutes |
+| `SLEEP_NEED` | minutes |
+| `SLEEP_PERFORMANCE` | percent (0–100) |
+| `SLEEP_EFFICIENCY` | percent (0–100) |
+| `SLEEP_CONSISTENCY` | percent (0–100) |
+| `SLEEP_DISTURBANCE_COUNT` | count |
+| `BREATHING_DISTURBANCES` | count |
+| `ACTIVITY_STEPS` | count |
+| `FLIGHTS_CLIMBED` | count |
+| `WALKING_RUNNING_DISTANCE` | metres |
+| `SIX_MINUTE_WALK_DISTANCE` | metres |
+| `WALKING_STEP_LENGTH` | metres |
+| `WALKING_SPEED` | m/s |
+| `STAIR_ASCENT_SPEED` | m/s |
+| `STAIR_DESCENT_SPEED` | m/s |
+| `WALKING_STEADINESS` | percent (0–100) |
+| `WALKING_ASYMMETRY` | percent (0–100) |
+| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
+| `ACTIVE_ENERGY_BURNED` | kcal |
+| `ENERGY_EXPENDITURE_KJ` | kJ |
+| `VO2_MAX` | mL/(kg·min) |
+| `PULSE_WAVE_VELOCITY` | m/s |
+| `VASCULAR_AGE` | years |
+| `FALL_COUNT` | count |
+| `AUDIO_EXPOSURE_ENV` | dBA |
+| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
+| `TIME_IN_DAYLIGHT` | minutes |
+| `RECOVERY_SCORE` | score (0–100) |
+| `STRESS_SCORE` | score (0–100) |
+| `STRAIN_SCORE` | score (0–100) |
+| `DAY_STRAIN` | score (0–21) |
+| `WORKOUT_STRAIN` | score (0–21) |
+| `AUDIO_EXPOSURE_EVENT` | count (event) |
+| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
+| `HIGH_HEART_RATE_EVENT` | event |
+| `LOW_HEART_RATE_EVENT` | event |
+| `WALKING_STEADINESS_EVENT` | event |
+| `BREATHING_DISTURBANCE_EVENT` | event |
The live, machine-readable list of writable types and their units is
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
diff --git a/docs/integrations/whoop.md b/docs/integrations/whoop.md
index d9f9ce759..8359de2d0 100644
--- a/docs/integrations/whoop.md
+++ b/docs/integrations/whoop.md
@@ -31,6 +31,7 @@ grant scoped to credentials they control.
and the connection dies when the first access token expires (~1 hour).
The remaining `read:*` scopes are read-only; request all of them so the
user grants once and every sync resource is covered.
+
4. Save the app. WHOOP issues a **Client ID** and **Client Secret** —
keep the secret out of any chat log; it grants read access to the
linked account's full WHOOP history.
diff --git a/docs/integrations/withings.md b/docs/integrations/withings.md
index 41fa96bc4..465ad4465 100644
--- a/docs/integrations/withings.md
+++ b/docs/integrations/withings.md
@@ -11,14 +11,14 @@ relay.
The Withings client (`src/lib/withings/client.ts`) maps the following
metric types into `Measurement` rows tagged `source = WITHINGS`:
-| Withings meastype | HealthLog `MeasurementType` |
-| ----------------- | --------------------------- |
-| 1 | `WEIGHT` |
-| 5 | `FAT_FREE_MASS` |
-| 9 | `BLOOD_PRESSURE_DIA` |
-| 10 | `BLOOD_PRESSURE_SYS` |
-| 11 | `PULSE` |
-| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
+| Withings meastype | HealthLog `MeasurementType` |
+| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
+| 1 | `WEIGHT` |
+| 5 | `FAT_FREE_MASS` |
+| 9 | `BLOOD_PRESSURE_DIA` |
+| 10 | `BLOOD_PRESSURE_SYS` |
+| 11 | `PULSE` |
+| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
| Plus the body-composition family (fat ratio, muscle mass, bone mass, hydration) and sleep / heart-rate variability via the heart-list endpoint |
Activity data (steps, active energy, distance, floors climbed) lands
@@ -136,7 +136,7 @@ Override per-user via the Sources section of `/settings/thresholds`.
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
are set in the container's runtime environment (`docker compose
- exec app env | grep WITHINGS`).
+exec app env | grep WITHINGS`).
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
must match an entry in the developer-portal app's callback list.
Add the public URL exactly, including the `https://` scheme and
@@ -183,7 +183,7 @@ The Settings page **Disconnect** button:
the database — disconnect does not delete data. Re-linking the
same Withings account merges back onto the same metric
timeline; the upsert path matches on `(userId, type, measuredAt,
- source)` so previously-synced rows update in place.
+source)` so previously-synced rows update in place.
## Admin probe
diff --git a/docs/ops/env-check.md b/docs/ops/env-check.md
index 3dd76f928..2e186ca46 100644
--- a/docs/ops/env-check.md
+++ b/docs/ops/env-check.md
@@ -39,11 +39,11 @@ export without polluting the local shell.
Output is one row per declared variable, grouped by section. Three
states:
-| Marker | Meaning |
-| --------------------- | ---------------------------------------------------------------------------------- |
-| `[OK]` | Variable is set and non-empty. |
-| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
-| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
+| Marker | Meaning |
+| -------------------- | -------------------------------------------------------------- |
+| `[OK]` | Variable is set and non-empty. |
+| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
+| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
The grep-friendly format lets a CI pipeline do `grep -q
'\[MISSING-REQUIRED\]'` to gate a deploy. The CI integration itself
@@ -93,7 +93,7 @@ so changes go through a PR. To add a new variable:
Deferred to v1.4.43:
- GitHub Actions workflow that runs `pnpm check-env --file
- .env.production.example` on every PR, blocking changes that
+.env.production.example` on every PR, blocking changes that
introduce new required vars without updating the example file.
- Coolify pre-deploy hook that runs `pnpm check-env` inside the
target container and aborts the deploy on exit code 1.
diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md
index e71dc5e05..ef77cb16c 100644
--- a/docs/ops/intake-repair.md
+++ b/docs/ops/intake-repair.md
@@ -96,6 +96,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
```
`--tombstone-implausible` is refused without `--fix`.
+
- **Drifted schedule windows are reconciled** to
`min/max(times_of_day)` so the legacy pair stops contradicting the
canonical dose times. Overnight windows (`window_end < window_start`)
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index 996bd107d..03135bd6f 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -9,8 +9,8 @@ already installed, longer if a TLS-fronted public hostname is in scope
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
pulls a multi-arch image from GitHub Container Registry (linux/amd64
- + linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
- unchanged.
+ - linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
+ unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
hold a 1.5 GB upload in `/tmp` while it parses.
@@ -43,11 +43,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
-| Variable | Purpose | Rotation cost |
-| -------- | ------- | ------------- |
-| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
-| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
-| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
+| Variable | Purpose | Rotation cost |
+| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
+| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
+| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
+| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
Then open `.env` and confirm `DATABASE_URL` includes the password you
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
@@ -131,9 +131,9 @@ compose `environment:` block has no effect — they only take hold when
you build your own image. They are intentionally absent from the
compose runtime whitelist for that reason.
-| Variable | Default | Effect when set |
-| --- | --- | --- |
-| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
+| Variable | Default | Effect when set |
+| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
To flip one, rebuild the image with the variable exported:
@@ -144,18 +144,18 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
## 7. Where to go next
-| Next step | File |
-| --------- | ---- |
-| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
-| Push notifications — Web Push, Telegram, ntfy, webhook, email, 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` |
-| Withings device sync | `docs/integrations/withings.md` |
-| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
-| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
-| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
-| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
+| Next step | File |
+| ------------------------------------------------------------------- | ------------------------------------- |
+| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
+| Push notifications — Web Push, Telegram, ntfy, webhook, email, 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` |
+| Withings device sync | `docs/integrations/withings.md` |
+| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
+| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
+| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
+| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
## Troubleshooting
@@ -168,7 +168,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
`app_settings.singleton.registrationEnabled`. The admin panel can
flip it back if a previous owner closed it.
- **Service unreachable on `localhost:3000`.** Check `docker compose
- ps` — the `app` healthcheck polls `/api/version` every 30 seconds
+ps` — the `app` healthcheck polls `/api/version` every 30 seconds
and will mark the container unhealthy if Postgres is still
starting. Tail `docker compose logs db` to confirm Postgres came up
cleanly.
diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md
index 19e6878f4..b3bd02df0 100644
--- a/docs/self-hosting/notifications.md
+++ b/docs/self-hosting/notifications.md
@@ -24,14 +24,14 @@ Apple-platform exclusives the web cannot offer.
## 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 |
-| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
-| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
-| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
+| 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 |
+| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
+| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
+| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
Web Push is the recommended default — it needs no third-party account,
just a keypair you generate yourself once.
@@ -173,7 +173,7 @@ 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
+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/`.
@@ -268,14 +268,14 @@ under your own Apple identity. Everything below is that path.
The APNs env vars (`src/lib/notifications/senders/apns.ts`):
-| Variable | Purpose |
-| --- | --- |
-| `APNS_KEY_ID` | The `.p8` key's Key ID |
-| `APNS_TEAM_ID` | Your Apple Developer Team ID |
-| `APNS_BUNDLE_ID` | Your 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 |
+| Variable | Purpose |
+| --------------------------- | ----------------------------------------------------------------------------------- |
+| `APNS_KEY_ID` | The `.p8` key's Key ID |
+| `APNS_TEAM_ID` | Your Apple Developer Team ID |
+| `APNS_BUNDLE_ID` | Your 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_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement |
APNs is **all-or-none**: either set the three IDs plus exactly one key
@@ -311,14 +311,14 @@ Two gotchas worth knowing before you blame a stale token:
## 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, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
-| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with 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. |
-| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
-| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
+| 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, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
+| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with 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. |
+| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
+| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
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
diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md
index bb30fd909..051c5bdfd 100644
--- a/docs/self-hosting/reproductive-health-data.md
+++ b/docs/self-hosting/reproductive-health-data.md
@@ -45,7 +45,7 @@ reached:
- **Physical or legal seizure of your own host.** If the machine
running PostgreSQL — your VPS, your home server, your NAS — is seized,
imaged, or compelled, the data is on it. Encryption at rest protects
- against a stolen disk or a leaked backup only while the key is *not*
+ against a stolen disk or a leaked backup only while the key is _not_
also available; if the host is running, or the key is stored next to
the data, that protection is gone.
- **Your hosting provider.** A VPS provider can snapshot your disk and
@@ -77,7 +77,7 @@ record, not speculation:
California privacy statute over the sharing of menstrual-tracking
data; the verdict and the underlying class action are public record.
-These are cited as factual public record to explain *why* the
+These are cited as factual public record to explain _why_ the
"who holds the data" question is the one that matters for this category.
HealthLog makes no claim about any other product's current practices.
diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md
index 471e630ed..b75df95b1 100644
--- a/docs/self-hosting/reverse-proxy.md
+++ b/docs/self-hosting/reverse-proxy.md
@@ -11,11 +11,11 @@ the env-var pairing is identical across them.
## Pre-flight — env vars the proxy interacts with
-| Variable | Required for | Notes |
-| -------- | ------------ | ----- |
-| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
-| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
-| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
+| Variable | Required for | Notes |
+| --------------------- | ---------------------------------------------- | ---------------------------------------- |
+| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
+| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
+| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
Set the URL pair to the public origin (`https://your-instance.example.com`)
and restart the `app` container. The image reads both at startup; no
@@ -28,12 +28,12 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
-| Topology | `TRUST_PROXY_HOPS` |
-| -------- | ------------------ |
-| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
-| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
-| Cloudflare → your proxy → app | `2` |
-| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
+| Topology | `TRUST_PROXY_HOPS` |
+| --------------------------------------------------------- | -------------------------- |
+| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
+| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
+| Cloudflare → your proxy → app | `2` |
+| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
@@ -132,7 +132,7 @@ operator attention:
The following variables are **build-time only** — they are injected
via `--build-arg` by the `docker-publish` workflow and baked into the
-shipped image. Setting them under Coolify's *Environment Variables*
+shipped image. Setting them under Coolify's _Environment Variables_
panel has zero effect on the running container, and the stale value
becomes a misleading artefact next time someone audits the deploy
config:
diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md
index d6480cfca..9e314b2a2 100644
--- a/docs/self-hosting/scaling.md
+++ b/docs/self-hosting/scaling.md
@@ -90,7 +90,7 @@ services:
app:
image: ghcr.io/mbombeck/healthlog:latest
environment:
- DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
+ DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
HEALTHLOG_PROCESS_TYPE: web
```
diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md
index 3e58b9069..48b78efd7 100644
--- a/docs/ui-guidelines.md
+++ b/docs/ui-guidelines.md
@@ -235,7 +235,7 @@ lists) uses the unified `` rail from
labelled ``s and date inputs.
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
- [Zurücksetzen] … [count]`. Canonical dimension order is date range,
+[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts
index 4d913274d..2b3599965 100644
--- a/e2e/coach-guided-questions.spec.ts
+++ b/e2e/coach-guided-questions.spec.ts
@@ -243,7 +243,9 @@ test.describe("Coach guided clarifying questions", () => {
await expect(current).toHaveCount(0);
// Settling the offer (decline via ✕) releases the next question.
- await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click();
+ await adopt
+ .locator('[data-slot="coach-self-context-adopt-dismiss"]')
+ .click();
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
current.locator('[data-slot="coach-guided-progress"]'),
diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts
index 0a4891df2..13589d544 100644
--- a/e2e/dashboard.spec.ts
+++ b/e2e/dashboard.spec.ts
@@ -114,7 +114,12 @@ test.describe("authenticated dashboard render", () => {
const types = (url.searchParams.get("types") ?? "WEIGHT").split(",");
const series: Record<
string,
- Array<{ type: string; value: number; measuredAt: string; count: number }>
+ Array<{
+ type: string;
+ value: number;
+ measuredAt: string;
+ count: number;
+ }>
> = {};
for (const type of types) {
const baseValue =
diff --git a/e2e/insights-scroll-restoration.spec.ts b/e2e/insights-scroll-restoration.spec.ts
index bed265f4e..011d93d25 100644
--- a/e2e/insights-scroll-restoration.spec.ts
+++ b/e2e/insights-scroll-restoration.spec.ts
@@ -24,9 +24,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("v1.4.28 — insights scroll restoration", () => {
test.use({ storageState: STORAGE_STATE_PATH });
- test.fixme(
- "returning to /insights from a sub-page lands at the top",
- async ({ page }) => {
+ test.fixme("returning to /insights from a sub-page lands at the top", async ({
+ page,
+ }) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Force the mother page tall enough that scrolling has somewhere
// to go even on a sparse seed; the rendered Insights overview
@@ -42,10 +42,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
await page.waitForURL(/\/insights\//);
// Back to the mother page via the overview pill (pill index 0).
- await page
- .locator("[data-slot='insights-tab-strip-pill']")
- .first()
- .click();
+ await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
await page.waitForURL(/\/insights\/?$/);
// The mother page's mount-effect deferred a `scrollTo(0)` to the
@@ -53,8 +50,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
await page.waitForTimeout(50);
const y = await page.evaluate(() => window.scrollY);
expect(y).toBeLessThan(50);
- },
- );
+ });
test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({
page,
@@ -63,7 +59,9 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
const nav = page.locator("[data-slot='insights-tab-strip']");
await expect(nav).toBeVisible();
const touchAction = await nav.evaluate(
- (el) => (el as HTMLElement).style.touchAction || getComputedStyle(el).touchAction,
+ (el) =>
+ (el as HTMLElement).style.touchAction ||
+ getComputedStyle(el).touchAction,
);
expect(touchAction).toContain("pan-y");
});
diff --git a/e2e/medications-wizard-compose.spec.ts b/e2e/medications-wizard-compose.spec.ts
index 47a751fb6..76ddb71f7 100644
--- a/e2e/medications-wizard-compose.spec.ts
+++ b/e2e/medications-wizard-compose.spec.ts
@@ -165,10 +165,7 @@ test.describe("medication wizard — compose-mode", () => {
expect(capture.postBody).toMatchObject({
name: "Insulin",
oneShot: false,
- schedules: [
- { rrule: "FREQ=DAILY" },
- { rrule: "FREQ=WEEKLY;BYDAY=WE" },
- ],
+ schedules: [{ rrule: "FREQ=DAILY" }, { rrule: "FREQ=WEEKLY;BYDAY=WE" }],
});
await expectMedicationOnList(page, {
diff --git a/e2e/medications-wizard-helpers.ts b/e2e/medications-wizard-helpers.ts
index 2b99cb842..6e75ac063 100644
--- a/e2e/medications-wizard-helpers.ts
+++ b/e2e/medications-wizard-helpers.ts
@@ -221,9 +221,13 @@ export async function expectStep(
// stepper landed, so it is hidden on the desktop e2e viewport. Assert the
// viewport-independent root data-attributes the dialog always carries.
const root = page.locator('[data-slot="medication-wizard-dialog"]');
- await expect(root).toHaveAttribute("data-display-step", String(displayIndex), {
- timeout: 5_000,
- });
+ await expect(root).toHaveAttribute(
+ "data-display-step",
+ String(displayIndex),
+ {
+ timeout: 5_000,
+ },
+ );
if (totalSteps != null) {
await expect(root).toHaveAttribute("data-total-steps", String(totalSteps));
}
diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts
index 32deb2de9..a29965b07 100644
--- a/e2e/mobile-viewport.spec.ts
+++ b/e2e/mobile-viewport.spec.ts
@@ -159,7 +159,9 @@ test.describe("mobile-viewport smoke", () => {
// trimmed text. A role-based label is more stable across
// re-skins than the raw `innerText` that brittle selectors
// surfaced previously.
- const ariaLabel = await btn.getAttribute("aria-label").catch(() => null);
+ const ariaLabel = await btn
+ .getAttribute("aria-label")
+ .catch(() => null);
const innerText = await btn.innerText().catch(() => "");
const label = ariaLabel || innerText || "(no text)";
failures.push(
diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts
index dd1573888..7cb9b8c83 100644
--- a/e2e/v1427-coach-launch.spec.ts
+++ b/e2e/v1427-coach-launch.spec.ts
@@ -138,17 +138,17 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
// CCH-04 — the per-metric inline launch icon is retired; no header
// launcher renders on the sub-page.
- await expect(
- page.locator('[data-slot="coach-launch-icon"]'),
- ).toHaveCount(0);
+ await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
+ 0,
+ );
// The FAB is the permanent launcher; with no unread nudge it
// renders without the unread dot.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
- await expect(
- page.locator('[data-slot="coach-fab-unread"]'),
- ).toHaveCount(0);
+ await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
+ 0,
+ );
});
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
@@ -161,15 +161,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
- await expect(
- page.locator('[data-slot="coach-launch-icon"]'),
- ).toHaveCount(0);
+ await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
+ 0,
+ );
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
- await expect(
- page.locator('[data-slot="coach-fab-unread"]'),
- ).toHaveCount(0);
+ await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
+ 0,
+ );
});
}
@@ -255,9 +255,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
await expect(fab).toHaveAttribute("data-unread", "true", {
timeout: 10_000,
});
- await expect(
- page.locator('[data-slot="coach-fab-unread"]'),
- ).toBeVisible();
+ await expect(page.locator('[data-slot="coach-fab-unread"]')).toBeVisible();
await fab.click();
// v1.16.11 — the FAB opens the side drawer in place instead of
@@ -274,9 +272,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
// Opening the Coach counts as reading the nudge on this device —
// the dot clears and the unread flag drops off the FAB.
- await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
- 0,
- );
+ await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(0);
await expect(fab).not.toHaveAttribute("data-unread", "true");
});
@@ -318,9 +314,9 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
// No in-panel tray opens — the click navigates to the full view.
await page.waitForURL("**/coach", { timeout: 10_000 });
- await expect(
- page.locator('[data-slot="coach-page"]'),
- ).toBeVisible({ timeout: 10_000 });
+ await expect(page.locator('[data-slot="coach-page"]')).toBeVisible({
+ timeout: 10_000,
+ });
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toHaveCount(0);
diff --git a/e2e/v1427-insights-empty-state.spec.ts b/e2e/v1427-insights-empty-state.spec.ts
index cd30003cf..64c306a3e 100644
--- a/e2e/v1427-insights-empty-state.spec.ts
+++ b/e2e/v1427-insights-empty-state.spec.ts
@@ -148,9 +148,7 @@ test.describe("v1.4.27 — insights empty-state with metric gating", () => {
// title). Pin on the sub-page slot's h1 so we don't accidentally
// match a sidebar / nav element.
await expect(
- page.locator(
- '[data-slot="insights-subpage"] #insights-subpage-title',
- ),
+ page.locator('[data-slot="insights-subpage"] #insights-subpage-title'),
).toContainText(headingMatch);
});
}
diff --git a/e2e/v1427-public-pages.spec.ts b/e2e/v1427-public-pages.spec.ts
index 68c4c8eda..356f635f5 100644
--- a/e2e/v1427-public-pages.spec.ts
+++ b/e2e/v1427-public-pages.spec.ts
@@ -38,14 +38,14 @@ test.describe("v1.4.27 — public pages", () => {
// No bounce to the login page.
expect(page.url()).toMatch(/\/about(\?|$)/);
- await expect(page.getByRole("heading", { name: /about/i }).first()).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: /about/i }).first(),
+ ).toBeVisible();
// GeoLite2 attribution is the load-bearing reason this page exists.
await expect(page.getByText(/GeoLite2/)).toBeVisible();
await expect(page.getByText(/MaxMind/)).toBeVisible();
- await expect(
- page.getByText(/Attribution-ShareAlike 4\.0/i),
- ).toBeVisible();
+ await expect(page.getByText(/Attribution-ShareAlike 4\.0/i)).toBeVisible();
});
// v1.4.28 backlog: Next.js's `not-found.tsx` may return 200 or 404
diff --git a/e2e/v1427-responsive-sheet.spec.ts b/e2e/v1427-responsive-sheet.spec.ts
index b3e83e391..0670342de 100644
--- a/e2e/v1427-responsive-sheet.spec.ts
+++ b/e2e/v1427-responsive-sheet.spec.ts
@@ -58,9 +58,7 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
waitUntil: "domcontentloaded",
});
- const content = page.locator(
- '[data-slot="responsive-sheet-content"]',
- );
+ const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible({ timeout: 10_000 });
await expect(content).toHaveAttribute("data-variant", "sheet");
@@ -85,9 +83,7 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
waitUntil: "domcontentloaded",
});
- const content = page.locator(
- '[data-slot="responsive-sheet-content"]',
- );
+ const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible({ timeout: 10_000 });
await expect(content).toHaveAttribute("data-variant", "dialog");
});
diff --git a/eslint-plugins/healthlog/queryKey-factory.js b/eslint-plugins/healthlog/queryKey-factory.js
index 2dd2dca3a..f13026eb8 100644
--- a/eslint-plugins/healthlog/queryKey-factory.js
+++ b/eslint-plugins/healthlog/queryKey-factory.js
@@ -125,8 +125,8 @@ const queryKeyFactoryRule = {
node.key.type === "Identifier"
? node.key.name
: node.key.type === "Literal"
- ? node.key.value
- : null;
+ ? node.key.value
+ : null;
if (keyName !== "queryKey" && keyName !== "mutationKey") return;
context.report({
diff --git a/eslint-plugins/healthlog/safe-fetch-required.js b/eslint-plugins/healthlog/safe-fetch-required.js
index 32446780f..4cff31d44 100644
--- a/eslint-plugins/healthlog/safe-fetch-required.js
+++ b/eslint-plugins/healthlog/safe-fetch-required.js
@@ -81,7 +81,7 @@ const safeFetchRequiredRule = {
schema: [],
messages: {
rawFetch:
- "Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.",
+ 'Raw fetch() bypasses the safeFetch wrapper\'s manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from "@/lib/safe-fetch" and call it instead.',
},
},
create(context) {
diff --git a/knip.json b/knip.json
index 3b6200073..baae9fd35 100644
--- a/knip.json
+++ b/knip.json
@@ -63,21 +63,10 @@
"testcontainers",
"tw-animate-css"
],
- "ignoreBinaries": [
- "docker",
- "docker-compose",
- "pg_isready",
- "tsx",
- "cosign"
- ],
+ "ignoreBinaries": ["docker", "docker-compose", "pg_isready", "tsx", "cosign"],
"ignoreExportsUsedInFile": true,
"ignoreIssues": {
- "src/components/ui/**": [
- "exports",
- "types"
- ],
- "src/lib/validations/**": [
- "types"
- ]
+ "src/components/ui/**": ["exports", "types"],
+ "src/lib/validations/**": ["types"]
}
-}
\ No newline at end of file
+}
diff --git a/messages/de.json b/messages/de.json
index da00a7739..366e5ba2e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Tagesbriefing",
"keyFindingsTitle": "Wichtige Befunde",
+ "signalsTitle": "Signale des Tages",
"emptyTitle": "Noch kein Briefing",
"emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.",
"emptyAction": "Briefing erstellen",
@@ -2263,7 +2264,11 @@
"settingsClustersLabel": "Daten, die der Coach sieht",
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten",
- "sourcesMemberCount": "{count} Werte"
+ "sourcesMemberCount": "{count} Werte",
+ "dictate": "Diktieren",
+ "dictateStop": "Diktat beenden",
+ "showConversations": "Unterhaltungen anzeigen",
+ "hideConversations": "Unterhaltungen ausblenden"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -2568,7 +2573,6 @@
"chronotype": {
"title": "Chronotyp",
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
- "midSleepCaption": "Deine natürliche Schlafmitte an freien Tagen liegt etwa um {clock}.",
"advancedToggle": "Mehr Details",
"socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag",
"socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.",
@@ -2582,7 +2586,7 @@
"late": "Abendtyp",
"extreme_late": "Ausgeprägter Abendtyp"
},
- "cornerLabel": "Chronotyp"
+ "midpointHeadline": "Deine natürliche Schlafmitte liegt etwa um {clock}."
},
"rhythm": {
"loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht."
@@ -4021,6 +4025,21 @@
"description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl.",
"subtitle": "Organisiere deine Stimmungs-Tags, Gruppen und Auswahlreihenfolge."
},
+ "labs": {
+ "title": "Laborwerte",
+ "description": "Ansicht, Sortierung und Biomarker deiner Laborwerte.",
+ "subtitle": "Ansicht, Sortierung und Biomarker verwalten."
+ },
+ "illness": {
+ "title": "Krankheitstagebuch",
+ "description": "Ansicht und Reihenfolge deiner Krankheitsepisoden.",
+ "subtitle": "Ansicht und Reihenfolge der Episoden verwalten."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "description": "Ansicht, Reihenfolge und einzelne Vorsorge-Erinnerungen.",
+ "subtitle": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten."
+ },
"modules": {
"title": "Module",
"saved": "Gespeichert.",
@@ -4324,7 +4343,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
+ "withingsDescription": "Verbinde deine Withings-Waage und dein Blutdruckmessgerät, um Gewicht, Körperzusammensetzung und Blutdruck automatisch zu synchronisieren.",
"withingsCredentials": "API-Zugangsdaten",
"withingsClientId": "Client-ID",
"withingsClientSecret": "Client-Secret",
@@ -4362,10 +4381,8 @@
"withingsConnect": "Mit Withings verbinden",
"withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.",
"whoop": "WHOOP",
- "whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts zu synchronisieren.",
- "whoopOverlapNote": "Wenn WHOOP und eine weitere Quelle denselben Vitalwert liefern – Ruhepuls, Blutsauerstoff, Körpertemperatur, Atemfrequenz oder Schlafphasen –, können beide Werte angezeigt werden, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
+ "whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts direkt in HealthLog zu synchronisieren.",
"whoopCredentials": "API-Zugangsdaten",
- "whoopCredentialsHelp": "Registriere deine eigene WHOOP-Entwickler-App und füge hier Client ID und Client Secret ein.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Zugangsdaten gespeichert",
@@ -4399,12 +4416,9 @@
},
"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.",
- "fitbitOverlapNote": "Wenn Google Health und eine andere Quelle denselben Vitalwert liefern — Ruhepuls, Sauerstoffsättigung, Körpertemperatur oder Atemfrequenz —, siehst du möglicherweise beide Werte, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
+ "fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
"fitbitExperimentalBadge": "Experimentell",
- "fitbitExperimentalNote": "Diese Integration befindet sich in der Beta — die Datenabdeckung und die Feldzuordnung werden noch gegen echte Google-Health-Konten geprüft, daher können einzelne Werte fehlen oder sich in einem künftigen Update ändern.",
"fitbitCredentials": "API-Zugangsdaten",
- "fitbitCredentialsHelp": "Registriere deinen eigenen Google-Cloud-OAuth-Client für die Google-Health-API und füge hier dessen Client-ID und Client-Secret ein.",
"fitbitClientId": "Client-ID",
"fitbitClientSecret": "Client-Secret",
"fitbitCredentialsSaved": "Zugangsdaten gespeichert",
@@ -4662,7 +4676,16 @@
"statusActive": "Aktiv",
"statusRevoked": "Widerrufen",
"statusExpired": "Abgelaufen",
- "inactiveTitle": "Widerrufen und abgelaufen ({count})"
+ "inactiveTitle": "Widerrufen und abgelaufen ({count})",
+ "shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
+ "qrLabel": "QR-Code",
+ "qrAlt": "QR-Code mit Freigabelink und Passphrase",
+ "linkLabel": "Link",
+ "passphraseLabel": "Passphrase",
+ "passphraseHint": "Die Empfängerin oder der Empfänger benötigt Link und Passphrase. Der QR-Code enthält beides.",
+ "copyLink": "Link kopieren",
+ "copyPassphrase": "Passphrase kopieren",
+ "protected": "Passphrase"
},
"genderOther": "Divers",
"cycleTracking": {
@@ -4674,9 +4697,8 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.",
+ "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.",
"nightscoutViewData": "Glukose ansehen",
- "nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.",
"nightscoutUrl": "Nightscout-URL",
"nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen",
"nightscoutToken": "API-Token",
@@ -4693,11 +4715,10 @@
"nightscoutDisconnectTitle": "Nightscout trennen?",
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
"polar": "Polar",
- "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
+ "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und tägliche Aktivitä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.",
"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",
@@ -4721,11 +4742,10 @@
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"oura": "Oura",
- "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
+ "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivitä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.",
"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",
@@ -4758,7 +4778,14 @@
"savedOff": "Coach ist ausgeblendet.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
}
- }
+ },
+ "withingsDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "whoopDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "fitbitDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben."
},
"admin": {
"title": "Administration",
@@ -5945,7 +5972,14 @@
"strain": "Belastungswert",
"score": "Wellness-Wert"
},
- "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker."
+ "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker.",
+ "unlock": {
+ "title": "Passphrase eingeben",
+ "description": "Diese geteilte Gesundheitsakte ist geschützt. Geben Sie die erhaltene Passphrase ein, um sie anzuzeigen.",
+ "label": "Passphrase",
+ "error": "Diese Passphrase ist nicht korrekt.",
+ "submit": "Entsperren"
+ }
},
"cycle": {
"symptomCategory": {
@@ -6404,11 +6438,6 @@
"onTrack": "Im Plan",
"due": "Jetzt fällig"
},
- "settings": {
- "title": "Vorsorge anpassen",
- "description": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten.",
- "back": "Zurück zur Vorsorge"
- },
"capture": {
"title": "Messwert erfassen",
"description": "Wert eintragen; die Erinnerung wird als erledigt markiert."
@@ -6421,6 +6450,14 @@
"dashboard": {
"empty": "Keine anstehenden Vorsorge-Erinnerungen.",
"openLink": "Vorsorge öffnen"
+ },
+ "showMeasurements": "Messergebnisse anzeigen",
+ "trendStrip": {
+ "label": "Verlauf der letzten 7 Tage",
+ "summary": "Letzter Wert {value}, {direction} über 7 Tage.",
+ "rising": "steigend",
+ "falling": "fallend",
+ "steady": "stabil"
}
},
"moduleList": {
@@ -6450,11 +6487,6 @@
"deleteError": "Wert konnte nicht gelöscht werden",
"loadError": "Laborwerte konnten nicht geladen werden",
"customize": "Laborwerte anpassen",
- "settings": {
- "title": "Laborwerte anpassen",
- "description": "Ansicht, Sortierung und Biomarker verwalten.",
- "back": "Zurück zu den Laborwerten"
- },
"sort": {
"heading": "Sortierung",
"label": "Reihenfolge der Werte",
@@ -6653,11 +6685,6 @@
"title": "Krankheitstagebuch",
"subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
"customize": "Krankheitstagebuch anpassen",
- "settings": {
- "title": "Krankheitstagebuch anpassen",
- "description": "Ansicht und Reihenfolge der Episoden verwalten.",
- "back": "Zurück zum Krankheitstagebuch"
- },
"newEpisode": "Neue Episode",
"logDay": "Tag erfassen",
"markRecovered": "Als genesen markieren",
diff --git a/messages/en.json b/messages/en.json
index 4a9917e07..45e6a3f98 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Daily Briefing",
"keyFindingsTitle": "Key findings",
+ "signalsTitle": "Signals of the day",
"emptyTitle": "No briefing yet",
"emptyDescription": "Generate today's briefing to get a narrative read of your trends.",
"emptyAction": "Generate briefing",
@@ -2263,7 +2264,11 @@
"settingsClustersLabel": "Data the Coach sees",
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet",
- "sourcesMemberCount": "{count} metrics"
+ "sourcesMemberCount": "{count} metrics",
+ "dictate": "Dictate",
+ "dictateStop": "Stop dictation",
+ "showConversations": "Show conversations",
+ "hideConversations": "Hide conversations"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -2568,7 +2573,6 @@
"chronotype": {
"title": "Chronotype",
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
- "midSleepCaption": "Your natural sleep midpoint on free days lands around {clock}.",
"advancedToggle": "Advanced",
"socialJetlagValue": "{hours}h {minutes}m social jetlag",
"socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.",
@@ -2582,7 +2586,7 @@
"late": "Evening type",
"extreme_late": "Strong evening type"
},
- "cornerLabel": "Chronotype"
+ "midpointHeadline": "Your natural sleep midpoint is around {clock}."
},
"rhythm": {
"loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles."
@@ -4021,6 +4025,21 @@
"description": "Groups, tags, and archived tags for the mood picker.",
"subtitle": "Organize your mood tags, groups, and picker order."
},
+ "labs": {
+ "title": "Labs",
+ "description": "View, sort order, and biomarkers for your labs.",
+ "subtitle": "Manage the view, sort order, and biomarkers."
+ },
+ "illness": {
+ "title": "Illness journal",
+ "description": "View and order of your illness episodes.",
+ "subtitle": "Manage the view and order of episodes."
+ },
+ "vorsorge": {
+ "title": "Preventive care",
+ "description": "View, order, and individual preventive-care reminders.",
+ "subtitle": "Manage the view, order, and individual reminders."
+ },
"modules": {
"title": "Modules",
"saved": "Saved.",
@@ -4324,7 +4343,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Connect your Withings scale and blood pressure monitors.",
+ "withingsDescription": "Connect your Withings scale and blood-pressure monitor to sync weight, body composition, and blood pressure automatically.",
"withingsCredentials": "API Credentials",
"withingsClientId": "Client ID",
"withingsClientSecret": "Client Secret",
@@ -4362,10 +4381,8 @@
"withingsConnect": "Connect with Withings",
"withingsNoCredentials": "Please enter your API credentials above to connect Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain and workouts.",
- "whoopOverlapNote": "If WHOOP and another source both supply a vital — resting heart rate, blood oxygen, body temperature, respiratory rate or sleep stages — you may see both values until a future update settles on a single preferred source.",
+ "whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain, and workouts straight into HealthLog.",
"whoopCredentials": "API Credentials",
- "whoopCredentialsHelp": "Register your own WHOOP developer app and paste its Client ID and Client Secret here.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Credentials saved",
@@ -4399,12 +4416,9 @@
},
"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.",
- "fitbitOverlapNote": "If Google Health and another source both supply a vital — resting heart rate, blood oxygen, body temperature or respiratory rate — you may see both values until a future update settles on a single preferred source.",
+ "fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
"fitbitExperimentalBadge": "Experimental",
- "fitbitExperimentalNote": "This integration is in beta — data coverage and field mapping are still being verified against live Google Health accounts, so some values may be missing or change in a future update.",
"fitbitCredentials": "API Credentials",
- "fitbitCredentialsHelp": "Register your own Google Cloud OAuth client for the Google Health API and paste its Client ID and Client Secret here.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credentials saved",
@@ -4662,7 +4676,16 @@
"statusActive": "Active",
"statusRevoked": "Revoked",
"statusExpired": "Expired",
- "inactiveTitle": "Revoked and expired ({count})"
+ "inactiveTitle": "Revoked and expired ({count})",
+ "shownOnce": "Shown once only. Capture the QR code and passphrase now — neither can be recovered later.",
+ "qrLabel": "QR code",
+ "qrAlt": "QR code containing the share link and passphrase",
+ "linkLabel": "Link",
+ "passphraseLabel": "Passphrase",
+ "passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.",
+ "copyLink": "Copy link",
+ "copyPassphrase": "Copy passphrase",
+ "protected": "Passphrase"
},
"genderOther": "Other",
"cycleTracking": {
@@ -4674,9 +4697,8 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.",
+ "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.",
"nightscoutViewData": "View your glucose",
- "nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.",
"nightscoutUrl": "Nightscout URL",
"nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it",
"nightscoutToken": "API token",
@@ -4697,7 +4719,6 @@
"polarViewData": "View your sleep & recovery",
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
"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",
@@ -4721,11 +4742,10 @@
"generic": "Unknown error while connecting. Please start the connection again."
},
"oura": "Oura",
- "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
+ "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, heart-rate variability, 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.",
"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",
@@ -4758,7 +4778,14 @@
"savedOff": "Coach hidden.",
"saveError": "Could not save the change. Please try again."
}
- }
+ },
+ "withingsDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "whoopDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "fitbitDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above."
},
"admin": {
"title": "Administration",
@@ -5945,7 +5972,14 @@
"strain": "Strain score",
"score": "Wellness score"
},
- "footer": "Shared from HealthLog — a self-hosted personal health tracker."
+ "footer": "Shared from HealthLog — a self-hosted personal health tracker.",
+ "unlock": {
+ "title": "Enter passphrase",
+ "description": "This shared health record is protected. Enter the passphrase you were given to view it.",
+ "label": "Passphrase",
+ "error": "That passphrase is not correct.",
+ "submit": "Unlock"
+ }
},
"cycle": {
"symptomCategory": {
@@ -6404,11 +6438,6 @@
"onTrack": "On track",
"due": "Due now"
},
- "settings": {
- "title": "Customize preventive care",
- "description": "Manage the view, order, and individual reminders.",
- "back": "Back to preventive care"
- },
"capture": {
"title": "Log measurement",
"description": "Enter the reading; the reminder is marked done."
@@ -6421,6 +6450,14 @@
"dashboard": {
"empty": "No upcoming preventive-care reminders.",
"openLink": "Open preventive care"
+ },
+ "showMeasurements": "Show measurements",
+ "trendStrip": {
+ "label": "Last 7 days",
+ "summary": "Latest value {value}, {direction} over 7 days.",
+ "rising": "rising",
+ "falling": "falling",
+ "steady": "steady"
}
},
"moduleList": {
@@ -6450,11 +6487,6 @@
"deleteError": "Could not delete the result",
"loadError": "Could not load lab results",
"customize": "Customize labs",
- "settings": {
- "title": "Customize labs",
- "description": "Manage the view, sort order, and biomarkers.",
- "back": "Back to labs"
- },
"sort": {
"heading": "Sort order",
"label": "Order of values",
@@ -6653,11 +6685,6 @@
"title": "Illness journal",
"subtitle": "A retrospective log of your illness and condition episodes.",
"customize": "Customize illness journal",
- "settings": {
- "title": "Customize illness journal",
- "description": "Manage the view and order of episodes.",
- "back": "Back to the illness journal"
- },
"newEpisode": "New episode",
"logDay": "Log a day",
"markRecovered": "Mark recovered",
diff --git a/messages/es.json b/messages/es.json
index d922014dd..983170479 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Resumen del día",
"keyFindingsTitle": "Hallazgos clave",
+ "signalsTitle": "Señales del día",
"emptyTitle": "Aún no hay resumen",
"emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.",
"emptyAction": "Generar resumen",
@@ -2263,7 +2264,11 @@
"settingsClustersLabel": "Datos que ve el Coach",
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos",
- "sourcesMemberCount": "{count} métricas"
+ "sourcesMemberCount": "{count} métricas",
+ "dictate": "Dictar",
+ "dictateStop": "Detener el dictado",
+ "showConversations": "Mostrar conversaciones",
+ "hideConversations": "Ocultar conversaciones"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -2568,7 +2573,6 @@
"chronotype": {
"title": "Cronotipo",
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
- "midSleepCaption": "El punto medio natural de tu sueño en días libres ronda las {clock}.",
"advancedToggle": "Avanzado",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.",
@@ -2582,7 +2586,7 @@
"late": "Vespertino",
"extreme_late": "Vespertino marcado"
},
- "cornerLabel": "Cronotipo"
+ "midpointHeadline": "El punto medio natural de tu sueño ronda las {clock}."
},
"rhythm": {
"loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión."
@@ -4021,6 +4025,21 @@
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.",
"subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector."
},
+ "labs": {
+ "title": "Laboratorio",
+ "description": "Vista, orden y biomarcadores de tu laboratorio.",
+ "subtitle": "Gestiona la vista, el orden y los biomarcadores."
+ },
+ "illness": {
+ "title": "Diario de enfermedad",
+ "description": "Vista y orden de tus episodios de enfermedad.",
+ "subtitle": "Gestiona la vista y el orden de los episodios."
+ },
+ "vorsorge": {
+ "title": "Prevención",
+ "description": "Vista, orden y recordatorios de prevención individuales.",
+ "subtitle": "Gestiona la vista, el orden y los recordatorios individuales."
+ },
"modules": {
"title": "Módulos",
"saved": "Guardado.",
@@ -4324,7 +4343,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Conecta tu báscula y tensiómetros Withings.",
+ "withingsDescription": "Conecta tu báscula y tensiómetro Withings para sincronizar peso, composición corporal y tensión arterial automáticamente.",
"withingsCredentials": "Credenciales de API",
"withingsClientId": "ID de cliente",
"withingsClientSecret": "Secreto de cliente",
@@ -4362,10 +4381,8 @@
"withingsConnect": "Conectar con Withings",
"withingsNoCredentials": "Introduce primero tus credenciales de API arriba para conectar Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos.",
- "whoopOverlapNote": "Si WHOOP y otra fuente proporcionan el mismo valor vital —frecuencia cardíaca en reposo, oxígeno en sangre, temperatura corporal, frecuencia respiratoria o fases del sueño—, es posible que veas ambos valores hasta que una futura actualización elija una única fuente preferida.",
+ "whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos directamente en HealthLog.",
"whoopCredentials": "Credenciales de API",
- "whoopCredentialsHelp": "Registra tu propia app de desarrollador de WHOOP y pega aquí su Client ID y Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Credenciales guardadas",
@@ -4399,12 +4416,9 @@
},
"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.",
- "fitbitOverlapNote": "Si Google Health y otra fuente proporcionan la misma constante vital —frecuencia cardíaca en reposo, saturación de oxígeno, temperatura corporal o frecuencia respiratoria—, es posible que veas ambos valores hasta que una actualización futura defina una única fuente preferida.",
+ "fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
"fitbitExperimentalBadge": "Experimental",
- "fitbitExperimentalNote": "Esta integración está en fase beta: la cobertura de datos y la asignación de campos aún se están verificando con cuentas reales de Google Health, por lo que algunos valores pueden faltar o cambiar en una actualización futura.",
"fitbitCredentials": "Credenciales de API",
- "fitbitCredentialsHelp": "Registra tu propio cliente OAuth de Google Cloud para la API de Google Health y pega aquí su Client ID y Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credenciales guardadas",
@@ -4662,7 +4676,16 @@
"statusActive": "Activo",
"statusRevoked": "Revocado",
"statusExpired": "Caducado",
- "inactiveTitle": "Revocados y caducados ({count})"
+ "inactiveTitle": "Revocados y caducados ({count})",
+ "shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso — no se pueden recuperar después.",
+ "qrLabel": "Código QR",
+ "qrAlt": "Código QR con el enlace para compartir y la frase de acceso",
+ "linkLabel": "Enlace",
+ "passphraseLabel": "Frase de acceso",
+ "passphraseHint": "El destinatario necesita el enlace y esta frase de acceso. El código QR contiene ambos.",
+ "copyLink": "Copiar enlace",
+ "copyPassphrase": "Copiar frase de acceso",
+ "protected": "Frase de acceso"
},
"genderOther": "Otro",
"cycleTracking": {
@@ -4676,7 +4699,6 @@
"nightscoutTag": "CGM",
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
"nightscoutViewData": "Ver tu glucosa",
- "nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.",
"nightscoutUrl": "URL de Nightscout",
"nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla",
"nightscoutToken": "Token de API",
@@ -4693,11 +4715,10 @@
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
"polar": "Polar",
- "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
+ "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño y 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.",
"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",
@@ -4721,11 +4742,10 @@
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"oura": "Oura",
- "ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
+ "ouraDescription": "Conecta tu cuenta de Oura para sincronizar disposición, fases de sueño, variabilidad cardíaca 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.",
"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",
@@ -4758,7 +4778,14 @@
"savedOff": "Coach oculto.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
}
- }
+ },
+ "withingsDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "whoopDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "fitbitDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba."
},
"admin": {
"title": "Administration",
@@ -5945,7 +5972,14 @@
"strain": "Puntuación de esfuerzo",
"score": "Puntuación de bienestar"
},
- "footer": "Compartido desde HealthLog — un registro de salud autoalojado."
+ "footer": "Compartido desde HealthLog — un registro de salud autoalojado.",
+ "unlock": {
+ "title": "Introduzca la frase de acceso",
+ "description": "Este registro de salud compartido está protegido. Introduzca la frase de acceso que recibió para verlo.",
+ "label": "Frase de acceso",
+ "error": "Esa frase de acceso no es correcta.",
+ "submit": "Desbloquear"
+ }
},
"cycle": {
"symptomCategory": {
@@ -6404,11 +6438,6 @@
"onTrack": "Al día",
"due": "Toca ahora"
},
- "settings": {
- "title": "Personalizar prevención",
- "description": "Gestiona la vista, el orden y los recordatorios individuales.",
- "back": "Volver a prevención"
- },
"capture": {
"title": "Registrar medición",
"description": "Introduce el valor; el recordatorio se marca como hecho."
@@ -6421,6 +6450,14 @@
"dashboard": {
"empty": "No hay recordatorios de prevención próximos.",
"openLink": "Abrir prevención"
+ },
+ "showMeasurements": "Ver mediciones",
+ "trendStrip": {
+ "label": "Últimos 7 días",
+ "summary": "Último valor {value}, {direction} en 7 días.",
+ "rising": "en aumento",
+ "falling": "en descenso",
+ "steady": "estable"
}
},
"moduleList": {
@@ -6450,11 +6487,6 @@
"deleteError": "No se pudo eliminar el resultado",
"loadError": "No se pudieron cargar los resultados",
"customize": "Personalizar laboratorio",
- "settings": {
- "title": "Personalizar laboratorio",
- "description": "Gestiona la vista, el orden y los biomarcadores.",
- "back": "Volver al laboratorio"
- },
"sort": {
"heading": "Orden",
"label": "Orden de los valores",
@@ -6653,11 +6685,6 @@
"title": "Diario de enfermedad",
"subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
"customize": "Personalizar diario de enfermedad",
- "settings": {
- "title": "Personalizar diario de enfermedad",
- "description": "Gestiona la vista y el orden de los episodios.",
- "back": "Volver al diario de enfermedad"
- },
"newEpisode": "Nuevo episodio",
"logDay": "Registrar un día",
"markRecovered": "Marcar como recuperado",
diff --git a/messages/fr.json b/messages/fr.json
index 4837f53e2..b79b3ce16 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Briefing du jour",
"keyFindingsTitle": "Constats clés",
+ "signalsTitle": "Signaux du jour",
"emptyTitle": "Pas encore de briefing",
"emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.",
"emptyAction": "Générer le briefing",
@@ -2263,7 +2264,11 @@
"settingsClustersLabel": "Données vues par le Coach",
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données",
- "sourcesMemberCount": "{count} mesures"
+ "sourcesMemberCount": "{count} mesures",
+ "dictate": "Dicter",
+ "dictateStop": "Arrêter la dictée",
+ "showConversations": "Afficher les conversations",
+ "hideConversations": "Masquer les conversations"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -2568,7 +2573,6 @@
"chronotype": {
"title": "Chronotype",
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
- "midSleepCaption": "Le milieu naturel de votre sommeil les jours de repos se situe vers {clock}.",
"advancedToggle": "Avancé",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.",
@@ -2582,7 +2586,7 @@
"late": "Type du soir",
"extreme_late": "Type du soir marqué"
},
- "cornerLabel": "Chronotype"
+ "midpointHeadline": "Le milieu naturel de votre sommeil se situe vers {clock}."
},
"rhythm": {
"loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie."
@@ -4021,6 +4025,21 @@
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.",
"subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur."
},
+ "labs": {
+ "title": "Analyses",
+ "description": "Affichage, tri et biomarqueurs de vos analyses.",
+ "subtitle": "Gérez l’affichage, le tri et les biomarqueurs."
+ },
+ "illness": {
+ "title": "Journal de maladie",
+ "description": "Affichage et ordre de vos épisodes de maladie.",
+ "subtitle": "Gérez l’affichage et l’ordre des épisodes."
+ },
+ "vorsorge": {
+ "title": "Prévention",
+ "description": "Affichage, ordre et rappels de prévention individuels.",
+ "subtitle": "Gérez l’affichage, l’ordre et les rappels individuels."
+ },
"modules": {
"title": "Modules",
"saved": "Enregistré.",
@@ -4324,7 +4343,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Connectez votre balance et vos tensiomètres Withings.",
+ "withingsDescription": "Connecte ta balance et ton tensiomètre Withings pour synchroniser automatiquement poids, composition corporelle et tension.",
"withingsCredentials": "Identifiants API",
"withingsClientId": "ID client",
"withingsClientSecret": "Secret client",
@@ -4362,10 +4381,8 @@
"withingsConnect": "Se connecter avec Withings",
"withingsNoCredentials": "Saisissez d’abord vos identifiants API ci-dessus pour connecter Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Connectez votre bracelet WHOOP pour synchroniser récupération, sommeil, effort et entraînements.",
- "whoopOverlapNote": "Si WHOOP et une autre source fournissent la même donnée vitale — fréquence cardiaque au repos, oxygène sanguin, température corporelle, fréquence respiratoire ou phases de sommeil —, les deux valeurs peuvent apparaître jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
+ "whoopDescription": "Connecte ton bracelet WHOOP pour synchroniser récupération, sommeil, charge et entraînements directement dans HealthLog.",
"whoopCredentials": "Identifiants API",
- "whoopCredentialsHelp": "Enregistrez votre propre application développeur WHOOP et collez ici son Client ID et son Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Identifiants enregistrés",
@@ -4399,12 +4416,9 @@
},
"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.",
- "fitbitOverlapNote": "Si Google Health et une autre source fournissent la même constante vitale — fréquence cardiaque au repos, saturation en oxygène, température corporelle ou fréquence respiratoire —, vous pourriez voir les deux valeurs jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
+ "fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
"fitbitExperimentalBadge": "Expérimental",
- "fitbitExperimentalNote": "Cette intégration est en bêta : la couverture des données et la correspondance des champs sont encore en cours de vérification avec de vrais comptes Google Health, donc certaines valeurs peuvent manquer ou changer lors d'une future mise à jour.",
"fitbitCredentials": "Identifiants d'API",
- "fitbitCredentialsHelp": "Enregistrez votre propre client OAuth Google Cloud pour l'API Google Health et collez ici son Client ID et son Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Identifiants enregistrés",
@@ -4662,7 +4676,16 @@
"statusActive": "Actif",
"statusRevoked": "Révoqué",
"statusExpired": "Expiré",
- "inactiveTitle": "Révoqués et expirés ({count})"
+ "inactiveTitle": "Révoqués et expirés ({count})",
+ "shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant — ils ne pourront pas être récupérés ensuite.",
+ "qrLabel": "Code QR",
+ "qrAlt": "Code QR contenant le lien de partage et la phrase secrète",
+ "linkLabel": "Lien",
+ "passphraseLabel": "Phrase secrète",
+ "passphraseHint": "Le destinataire a besoin du lien et de cette phrase secrète. Le code QR contient les deux.",
+ "copyLink": "Copier le lien",
+ "copyPassphrase": "Copier la phrase secrète",
+ "protected": "Phrase secrète"
},
"genderOther": "Autre",
"cycleTracking": {
@@ -4674,9 +4697,8 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.",
+ "nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.",
"nightscoutViewData": "Voir votre glycémie",
- "nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.",
"nightscoutUrl": "URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer",
"nightscoutToken": "Jeton d'API",
@@ -4693,11 +4715,10 @@
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
"polar": "Polar",
- "polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
+ "polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité.",
"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.",
"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",
@@ -4721,11 +4742,10 @@
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"oura": "Oura",
- "ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
+ "ouraDescription": "Connecte ton compte Oura pour synchroniser readiness, phases de sommeil, variabilité cardiaque et 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.",
"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",
@@ -4758,7 +4778,14 @@
"savedOff": "Coach masqué.",
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
}
- }
+ },
+ "withingsDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "whoopDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "fitbitDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus."
},
"admin": {
"title": "Administration",
@@ -5945,7 +5972,14 @@
"strain": "Score de charge",
"score": "Score de bien-être"
},
- "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé."
+ "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé.",
+ "unlock": {
+ "title": "Saisir la phrase secrète",
+ "description": "Ce dossier de santé partagé est protégé. Saisissez la phrase secrète qui vous a été communiquée pour le consulter.",
+ "label": "Phrase secrète",
+ "error": "Cette phrase secrète est incorrecte.",
+ "submit": "Déverrouiller"
+ }
},
"cycle": {
"symptomCategory": {
@@ -6404,11 +6438,6 @@
"onTrack": "Dans les temps",
"due": "À faire maintenant"
},
- "settings": {
- "title": "Personnaliser la prévention",
- "description": "Gérer l'affichage, l'ordre et les rappels individuels.",
- "back": "Retour à la prévention"
- },
"capture": {
"title": "Enregistrer une mesure",
"description": "Saisissez la valeur ; le rappel est marqué comme fait."
@@ -6421,6 +6450,14 @@
"dashboard": {
"empty": "Aucun rappel de prévention à venir.",
"openLink": "Ouvrir la prévention"
+ },
+ "showMeasurements": "Afficher les mesures",
+ "trendStrip": {
+ "label": "7 derniers jours",
+ "summary": "Dernière valeur {value}, {direction} sur 7 jours.",
+ "rising": "en hausse",
+ "falling": "en baisse",
+ "steady": "stable"
}
},
"moduleList": {
@@ -6450,11 +6487,6 @@
"deleteError": "Impossible de supprimer le résultat",
"loadError": "Impossible de charger les résultats",
"customize": "Personnaliser les analyses",
- "settings": {
- "title": "Personnaliser les analyses",
- "description": "Gérer l'affichage, le tri et les biomarqueurs.",
- "back": "Retour aux analyses"
- },
"sort": {
"heading": "Tri",
"label": "Ordre des valeurs",
@@ -6653,11 +6685,6 @@
"title": "Journal de maladie",
"subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
"customize": "Personnaliser le journal de maladie",
- "settings": {
- "title": "Personnaliser le journal de maladie",
- "description": "Gérer l'affichage et l'ordre des épisodes.",
- "back": "Retour au journal de maladie"
- },
"newEpisode": "Nouvel épisode",
"logDay": "Enregistrer un jour",
"markRecovered": "Marquer comme rétabli",
diff --git a/messages/it.json b/messages/it.json
index a285b0228..09a66cf1a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Briefing del giorno",
"keyFindingsTitle": "Riscontri principali",
+ "signalsTitle": "Segnali del giorno",
"emptyTitle": "Ancora nessun briefing",
"emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.",
"emptyAction": "Genera briefing",
@@ -2263,7 +2264,11 @@
"settingsClustersLabel": "Dati che il Coach vede",
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato",
- "sourcesMemberCount": "{count} metriche"
+ "sourcesMemberCount": "{count} metriche",
+ "dictate": "Detta",
+ "dictateStop": "Interrompi dettatura",
+ "showConversations": "Mostra conversazioni",
+ "hideConversations": "Nascondi conversazioni"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -2568,7 +2573,6 @@
"chronotype": {
"title": "Cronotipo",
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
- "midSleepCaption": "Il punto medio naturale del tuo sonno nei giorni liberi è intorno alle {clock}.",
"advancedToggle": "Avanzate",
"socialJetlagValue": "{hours} h {minutes} min di jet lag sociale",
"socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.",
@@ -2582,7 +2586,7 @@
"late": "Tipo serale",
"extreme_late": "Tipo serale marcato"
},
- "cornerLabel": "Cronotipo"
+ "midpointHeadline": "Il punto medio naturale del tuo sonno è intorno alle {clock}."
},
"rhythm": {
"loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile."
@@ -4021,6 +4025,21 @@
"description": "Gruppi, tag e tag archiviati del selettore dell’umore.",
"subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore."
},
+ "labs": {
+ "title": "Esami",
+ "description": "Vista, ordinamento e biomarcatori dei tuoi esami.",
+ "subtitle": "Gestisci la vista, l’ordinamento e i biomarcatori."
+ },
+ "illness": {
+ "title": "Diario delle malattie",
+ "description": "Vista e ordine dei tuoi episodi di malattia.",
+ "subtitle": "Gestisci la vista e l’ordine degli episodi."
+ },
+ "vorsorge": {
+ "title": "Prevenzione",
+ "description": "Vista, ordine e singoli promemoria di prevenzione.",
+ "subtitle": "Gestisci la vista, l’ordine e i singoli promemoria."
+ },
"modules": {
"title": "Moduli",
"saved": "Salvato.",
@@ -4324,7 +4343,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Collega la tua bilancia e i misuratori di pressione Withings.",
+ "withingsDescription": "Collega la bilancia e il misuratore di pressione Withings per sincronizzare peso, composizione corporea e pressione automaticamente.",
"withingsCredentials": "Credenziali API",
"withingsClientId": "ID client",
"withingsClientSecret": "Secret client",
@@ -4362,10 +4381,8 @@
"withingsConnect": "Connetti con Withings",
"withingsNoCredentials": "Inserisci prima le tue credenziali API qui sopra per collegare Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Collega la tua fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti.",
- "whoopOverlapNote": "Se WHOOP e un'altra fonte forniscono lo stesso valore vitale — frequenza cardiaca a riposo, ossigeno nel sangue, temperatura corporea, frequenza respiratoria o fasi del sonno —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
+ "whoopDescription": "Collega la fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti direttamente in HealthLog.",
"whoopCredentials": "Credenziali API",
- "whoopCredentialsHelp": "Registra la tua app sviluppatore WHOOP e incolla qui il suo Client ID e Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Credenziali salvate",
@@ -4399,12 +4416,9 @@
},
"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.",
- "fitbitOverlapNote": "Se Google Health e un'altra fonte forniscono lo stesso parametro vitale — frequenza cardiaca a riposo, saturazione di ossigeno, temperatura corporea o frequenza respiratoria —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
+ "fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
"fitbitExperimentalBadge": "Sperimentale",
- "fitbitExperimentalNote": "Questa integrazione è in versione beta: la copertura dei dati e la mappatura dei campi sono ancora in fase di verifica con account Google Health reali, quindi alcuni valori potrebbero mancare o cambiare in un futuro aggiornamento.",
"fitbitCredentials": "Credenziali API",
- "fitbitCredentialsHelp": "Registra il tuo client OAuth di Google Cloud per l'API Google Health e incolla qui il suo Client ID e Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credenziali salvate",
@@ -4662,7 +4676,16 @@
"statusActive": "Attivo",
"statusRevoked": "Revocato",
"statusExpired": "Scaduto",
- "inactiveTitle": "Revocati e scaduti ({count})"
+ "inactiveTitle": "Revocati e scaduti ({count})",
+ "shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase — non potranno essere recuperati in seguito.",
+ "qrLabel": "Codice QR",
+ "qrAlt": "Codice QR contenente il link di condivisione e la passphrase",
+ "linkLabel": "Link",
+ "passphraseLabel": "Passphrase",
+ "passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.",
+ "copyLink": "Copia link",
+ "copyPassphrase": "Copia passphrase",
+ "protected": "Passphrase"
},
"genderOther": "Altro",
"cycleTracking": {
@@ -4674,9 +4697,8 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.",
+ "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.",
"nightscoutViewData": "Vedi la tua glicemia",
- "nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.",
"nightscoutUrl": "URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo",
"nightscoutToken": "Token API",
@@ -4697,7 +4719,6 @@
"polarViewData": "Vedi sonno e recupero",
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"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",
@@ -4721,11 +4742,10 @@
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"oura": "Oura",
- "ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
+ "ouraDescription": "Collega il tuo account Oura per sincronizzare prontezza, fasi del sonno, variabilità cardiaca 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.",
"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",
@@ -4758,7 +4778,14 @@
"savedOff": "Coach nascosto.",
"saveError": "Impossibile salvare la modifica. Riprova."
}
- }
+ },
+ "withingsDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "whoopDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "fitbitDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra."
},
"admin": {
"title": "Administration",
@@ -5945,7 +5972,14 @@
"strain": "Punteggio di sforzo",
"score": "Punteggio di benessere"
},
- "footer": "Condiviso da HealthLog — un tracker della salute self-hosted."
+ "footer": "Condiviso da HealthLog — un tracker della salute self-hosted.",
+ "unlock": {
+ "title": "Inserisci la passphrase",
+ "description": "Questo referto sanitario condiviso è protetto. Inserisci la passphrase che hai ricevuto per visualizzarlo.",
+ "label": "Passphrase",
+ "error": "La passphrase non è corretta.",
+ "submit": "Sblocca"
+ }
},
"cycle": {
"symptomCategory": {
@@ -6404,11 +6438,6 @@
"onTrack": "In linea",
"due": "Da fare ora"
},
- "settings": {
- "title": "Personalizza prevenzione",
- "description": "Gestisci la vista, l'ordine e i singoli promemoria.",
- "back": "Torna alla prevenzione"
- },
"capture": {
"title": "Registra misurazione",
"description": "Inserisci il valore; il promemoria viene segnato come fatto."
@@ -6421,6 +6450,14 @@
"dashboard": {
"empty": "Nessun promemoria di prevenzione in arrivo.",
"openLink": "Apri prevenzione"
+ },
+ "showMeasurements": "Mostra le misurazioni",
+ "trendStrip": {
+ "label": "Ultimi 7 giorni",
+ "summary": "Ultimo valore {value}, {direction} su 7 giorni.",
+ "rising": "in aumento",
+ "falling": "in calo",
+ "steady": "stabile"
}
},
"moduleList": {
@@ -6450,11 +6487,6 @@
"deleteError": "Impossibile eliminare il risultato",
"loadError": "Impossibile caricare i risultati",
"customize": "Personalizza esami",
- "settings": {
- "title": "Personalizza esami",
- "description": "Gestisci la vista, l'ordinamento e i biomarcatori.",
- "back": "Torna agli esami"
- },
"sort": {
"heading": "Ordinamento",
"label": "Ordine dei valori",
@@ -6653,11 +6685,6 @@
"title": "Diario delle malattie",
"subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
"customize": "Personalizza diario delle malattie",
- "settings": {
- "title": "Personalizza diario delle malattie",
- "description": "Gestisci la vista e l'ordine degli episodi.",
- "back": "Torna al diario delle malattie"
- },
"newEpisode": "Nuovo episodio",
"logDay": "Registra un giorno",
"markRecovered": "Segna come guarito",
diff --git a/messages/pl.json b/messages/pl.json
index 8d176eccc..77f4a881d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Briefing dnia",
"keyFindingsTitle": "Kluczowe wnioski",
+ "signalsTitle": "Sygnały dnia",
"emptyTitle": "Jeszcze brak briefingu",
"emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.",
"emptyAction": "Wygeneruj briefing",
@@ -2263,7 +2264,11 @@
"settingsClustersLabel": "Dane widziane przez Coacha",
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych",
- "sourcesMemberCount": "{count} wskaźników"
+ "sourcesMemberCount": "{count} wskaźników",
+ "dictate": "Dyktuj",
+ "dictateStop": "Zatrzymaj dyktowanie",
+ "showConversations": "Pokaż rozmowy",
+ "hideConversations": "Ukryj rozmowy"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -2568,7 +2573,6 @@
"chronotype": {
"title": "Chronotyp",
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
- "midSleepCaption": "Naturalny środek Twojego snu w dni wolne przypada około {clock}.",
"advancedToggle": "Zaawansowane",
"socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego",
"socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.",
@@ -2582,7 +2586,7 @@
"late": "Typ wieczorny",
"extreme_late": "Wyraźny typ wieczorny"
},
- "cornerLabel": "Chronotyp"
+ "midpointHeadline": "Naturalny środek Twojego snu przypada około {clock}."
},
"rhythm": {
"loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje."
@@ -4021,6 +4025,21 @@
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.",
"subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru."
},
+ "labs": {
+ "title": "Wyniki badań",
+ "description": "Widok, sortowanie i biomarkery Twoich wyników badań.",
+ "subtitle": "Zarządzaj widokiem, sortowaniem i biomarkerami."
+ },
+ "illness": {
+ "title": "Dziennik chorób",
+ "description": "Widok i kolejność Twoich epizodów chorobowych.",
+ "subtitle": "Zarządzaj widokiem i kolejnością epizodów."
+ },
+ "vorsorge": {
+ "title": "Profilaktyka",
+ "description": "Widok, kolejność i poszczególne przypomnienia profilaktyczne.",
+ "subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami."
+ },
"modules": {
"title": "Moduły",
"saved": "Zapisano.",
@@ -4324,7 +4343,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Połącz swoją wagę i ciśnieniomierze Withings.",
+ "withingsDescription": "Połącz wagę i ciśnieniomierz Withings, aby automatycznie synchronizować wagę, skład ciała i ciśnienie krwi.",
"withingsCredentials": "Dane logowania API",
"withingsClientId": "ID klienta",
"withingsClientSecret": "Sekret klienta",
@@ -4362,10 +4381,8 @@
"withingsConnect": "Połącz z Withings",
"withingsNoCredentials": "Najpierw wprowadź powyżej dane logowania API, aby połączyć Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi.",
- "whoopOverlapNote": "Jeśli WHOOP i inne źródło dostarczają tę samą wartość życiową — tętno spoczynkowe, saturację, temperaturę ciała, częstość oddechów lub fazy snu — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
+ "whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi prosto do HealthLog.",
"whoopCredentials": "Dane API",
- "whoopCredentialsHelp": "Zarejestruj własną aplikację deweloperską WHOOP i wklej tutaj jej Client ID oraz Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Dane zapisane",
@@ -4399,12 +4416,9 @@
},
"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.",
- "fitbitOverlapNote": "Jeśli Google Health i inne źródło dostarczają ten sam parametr życiowy — tętno spoczynkowe, saturację, temperaturę ciała lub częstość oddechów — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
+ "fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
"fitbitExperimentalBadge": "Eksperymentalne",
- "fitbitExperimentalNote": "Ta integracja jest w wersji beta — zakres danych i mapowanie pól są wciąż weryfikowane na rzeczywistych kontach Google Health, więc niektóre wartości mogą być niedostępne lub zmienić się w przyszłej aktualizacji.",
"fitbitCredentials": "Dane uwierzytelniające API",
- "fitbitCredentialsHelp": "Zarejestruj własny klient OAuth Google Cloud dla interfejsu Google Health API i wklej tutaj jego Client ID oraz Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Dane uwierzytelniające zapisane",
@@ -4662,7 +4676,16 @@
"statusActive": "Aktywny",
"statusRevoked": "Unieważniony",
"statusExpired": "Wygasły",
- "inactiveTitle": "Unieważnione i wygasłe ({count})"
+ "inactiveTitle": "Unieważnione i wygasłe ({count})",
+ "shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło — nie można ich później odzyskać.",
+ "qrLabel": "Kod QR",
+ "qrAlt": "Kod QR zawierający link do udostępniania i hasło",
+ "linkLabel": "Link",
+ "passphraseLabel": "Hasło",
+ "passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.",
+ "copyLink": "Kopiuj link",
+ "copyPassphrase": "Kopiuj hasło",
+ "protected": "Hasło"
},
"genderOther": "Inna",
"cycleTracking": {
@@ -4674,9 +4697,8 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
+ "nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
"nightscoutViewData": "Zobacz swoją glukozę",
- "nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.",
"nightscoutUrl": "Adres URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić",
"nightscoutToken": "Token API",
@@ -4693,11 +4715,10 @@
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
"polar": "Polar",
- "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
+ "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i dzienną aktywność.",
"polarViewData": "Zobacz sen i regenerację",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"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",
@@ -4721,11 +4742,10 @@
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"oura": "Oura",
- "ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
+ "ouraDescription": "Połącz konto Oura, aby synchronizować gotowość, fazy snu, zmienność rytmu serca i dzienną aktywność.",
"ouraViewData": "Zobacz sen i regenerację",
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"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",
@@ -4758,7 +4778,14 @@
"savedOff": "Coach ukryty.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
}
- }
+ },
+ "withingsDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "whoopDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "fitbitDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej."
},
"admin": {
"title": "Administracja",
@@ -5945,7 +5972,14 @@
"strain": "Wynik obciążenia",
"score": "Wynik dobrostanu"
},
- "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia."
+ "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia.",
+ "unlock": {
+ "title": "Wprowadź hasło",
+ "description": "Ta udostępniona dokumentacja zdrowotna jest chroniona. Wprowadź otrzymane hasło, aby ją wyświetlić.",
+ "label": "Hasło",
+ "error": "To hasło jest nieprawidłowe.",
+ "submit": "Odblokuj"
+ }
},
"cycle": {
"symptomCategory": {
@@ -6404,11 +6438,6 @@
"onTrack": "Zgodnie z planem",
"due": "Termin teraz"
},
- "settings": {
- "title": "Dostosuj profilaktykę",
- "description": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami.",
- "back": "Powrót do profilaktyki"
- },
"capture": {
"title": "Zapisz pomiar",
"description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane."
@@ -6421,6 +6450,14 @@
"dashboard": {
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
"openLink": "Otwórz profilaktykę"
+ },
+ "showMeasurements": "Pokaż pomiary",
+ "trendStrip": {
+ "label": "Ostatnie 7 dni",
+ "summary": "Ostatnia wartość {value}, {direction} w ciągu 7 dni.",
+ "rising": "rosnący",
+ "falling": "malejący",
+ "steady": "stabilny"
}
},
"moduleList": {
@@ -6450,11 +6487,6 @@
"deleteError": "Nie udało się usunąć wyniku",
"loadError": "Nie udało się wczytać wyników",
"customize": "Dostosuj wyniki badań",
- "settings": {
- "title": "Dostosuj wyniki badań",
- "description": "Zarządzaj widokiem, sortowaniem i biomarkerami.",
- "back": "Powrót do wyników badań"
- },
"sort": {
"heading": "Sortowanie",
"label": "Kolejność wartości",
@@ -6653,11 +6685,6 @@
"title": "Dziennik chorób",
"subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
"customize": "Dostosuj dziennik chorób",
- "settings": {
- "title": "Dostosuj dziennik chorób",
- "description": "Zarządzaj widokiem i kolejnością epizodów.",
- "back": "Powrót do dziennika chorób"
- },
"newEpisode": "Nowy epizod",
"logDay": "Zapisz dzień",
"markRecovered": "Oznacz jako wyleczone",
diff --git a/next.config.ts b/next.config.ts
index 7413f86b2..8b38781dc 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -140,9 +140,7 @@ const nextConfig: NextConfig = {
return [
{
source: "/:path*",
- headers: [
- { key: "Permissions-Policy", value: "unload=()" },
- ],
+ headers: [{ key: "Permissions-Policy", value: "unload=()" }],
},
{
source: "/((?!api|_next).*)",
diff --git a/package.json b/package.json
index 1e980ffd2..abe773b83 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.18.6.1",
+ "version": "1.18.7",
"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/prisma/migrations/0178_v1187_session_expiry_indexes/migration.sql b/prisma/migrations/0178_v1187_session_expiry_indexes/migration.sql
new file mode 100644
index 000000000..36ec5317d
--- /dev/null
+++ b/prisma/migrations/0178_v1187_session_expiry_indexes/migration.sql
@@ -0,0 +1,19 @@
+-- v1.18.7 — expiry indexes for the two tables swept by the session reaper.
+--
+-- `sessions` (sliding-30-day session expiry) and `auth_challenges` (passkey
+-- registration / authentication challenges) are both pruned on `expires_at`,
+-- but neither carried an index on that column. On a busy multi-user instance
+-- the cleanup degraded to a sequential scan. Every sibling expiry table
+-- (`refresh_tokens`, `whoop_oauth_states`, `idempotency_keys`,
+-- `withings_oauth_states`, …) already indexes its expiry column; this brings
+-- the two stragglers in line. Additive; no data touched.
+--
+-- Reversibility (down):
+-- DROP INDEX IF EXISTS "sessions_expires_at_idx";
+-- DROP INDEX IF EXISTS "auth_challenges_expires_at_idx";
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "sessions_expires_at_idx" ON "sessions"("expires_at");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "auth_challenges_expires_at_idx" ON "auth_challenges"("expires_at");
diff --git a/prisma/migrations/0179_v1187_briefing_reroll_date/migration.sql b/prisma/migrations/0179_v1187_briefing_reroll_date/migration.sql
new file mode 100644
index 000000000..08148180a
--- /dev/null
+++ b/prisma/migrations/0179_v1187_briefing_reroll_date/migration.sql
@@ -0,0 +1,5 @@
+-- v1.18.7 — track the daily-briefing phrasing re-roll date.
+-- The content-hash gate keeps the briefing findings byte-stable on
+-- unchanged data; this column lets the paragraph be re-rolled once per
+-- calendar day (UTC) for phrasing variety without re-paying for findings.
+ALTER TABLE "users" ADD COLUMN "insights_briefing_reroll_date" TEXT;
diff --git a/prisma/migrations/0180_v1187_share_link_passphrase/migration.sql b/prisma/migrations/0180_v1187_share_link_passphrase/migration.sql
new file mode 100644
index 000000000..1ba75e46a
--- /dev/null
+++ b/prisma/migrations/0180_v1187_share_link_passphrase/migration.sql
@@ -0,0 +1,7 @@
+-- v1.18.7 — passphrase second factor for clinician share links.
+-- A leaked share URL on its own no longer opens the record: every new
+-- link carries a high-entropy passphrase, stored only as its HMAC-SHA256
+-- hash (same key as token_hash). The column is NULLABLE so every link
+-- minted before this change keeps the possession-only contract and the
+-- public view renders for it without a gate.
+ALTER TABLE "clinician_share_links" ADD COLUMN "passphrase_hash" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c2f620a3c..7cac927df 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -71,6 +71,13 @@ model User {
// so a persistently broken provider chain cannot be re-driven into a
// full generation attempt by every page-open.
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
+ // v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll.
+ // The content-hash gate keeps the FINDINGS byte-stable on unchanged data,
+ // but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a
+ // higher, seedless temperature so the prose reads fresh day-over-day
+ // without re-paying for the findings. This stamps the day a re-roll ran so
+ // the gate stays at most one extra call per day.
+ insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
@@ -721,6 +728,9 @@ model Session {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
+ // v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without
+ // this index the cleanup degrades to a sequential scan on a busy instance.
+ @@index([expiresAt])
@@map("sessions")
}
@@ -735,6 +745,9 @@ model AuthChallenge {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
+ // v1.18.7 — challenges are swept by expiry; index the column the reaper
+ // filters on so the cleanup stays index-backed.
+ @@index([expiresAt])
@@map("auth_challenges")
}
@@ -3843,6 +3856,13 @@ model ClinicianShareLink {
userId String @map("user_id")
/// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`).
tokenHash String @unique @map("token_hash")
+ /// v1.18.7 — HMAC-SHA256 of the passphrase second factor (same key as
+ /// `tokenHash`). NULL for legacy links minted before the passphrase gate;
+ /// those keep the possession-only contract (the public view renders without
+ /// a gate). A non-null value forces the unlock gate before the record paints.
+ /// The raw passphrase is returned exactly once at create time and stored only
+ /// as this hash — there is no recovery path.
+ passphraseHash String? @map("passphrase_hash")
/// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded.
label String
diff --git a/public/manifest.json b/public/manifest.json
index 399b9f08a..d94dc7fe3 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -41,19 +41,25 @@
"name": "Add measurement",
"short_name": "Add",
"url": "/measurements?add=WEIGHT&source=shortcut",
- "icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
+ "icons": [
+ { "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
+ ]
},
{
"name": "Today",
"short_name": "Today",
"url": "/?source=shortcut",
- "icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
+ "icons": [
+ { "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
+ ]
},
{
"name": "Medications",
"short_name": "Meds",
"url": "/medications?source=shortcut",
- "icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
+ "icons": [
+ { "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
+ ]
}
]
}
diff --git a/public/sw.js b/public/sw.js
index fb3bad369..4ceb3436e 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.18.6";
+ /* @sw-version-fallback */ "v1.18.6.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/scripts/__tests__/generate-sw-version.test.ts b/scripts/__tests__/generate-sw-version.test.ts
index 1df0cf177..bbd634b7f 100644
--- a/scripts/__tests__/generate-sw-version.test.ts
+++ b/scripts/__tests__/generate-sw-version.test.ts
@@ -9,11 +9,7 @@
* `self.__APP_VERSION__` (falling back to a literal).
*/
import { execFileSync } from "node:child_process";
-import {
- existsSync,
- readFileSync,
- rmSync,
-} from "node:fs";
+import { existsSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect, afterAll } from "vitest";
diff --git a/scripts/backfill-mood-note-column.ts b/scripts/backfill-mood-note-column.ts
index 4704a8dea..093d8d2d8 100644
--- a/scripts/backfill-mood-note-column.ts
+++ b/scripts/backfill-mood-note-column.ts
@@ -59,7 +59,9 @@ async function main(): Promise {
const dryRun = !args.confirm;
if (dryRun) {
- console.log("[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.");
+ console.log(
+ "[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.",
+ );
} else {
console.log("[backfill-note] COMMIT MODE — updating rows in place.");
}
@@ -92,7 +94,9 @@ async function main(): Promise {
}
}
- console.log(`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`);
+ console.log(
+ `[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`,
+ );
await prisma.$disconnect();
}
diff --git a/scripts/backfill-rollups.ts b/scripts/backfill-rollups.ts
index d85d26e69..0df75f09a 100644
--- a/scripts/backfill-rollups.ts
+++ b/scripts/backfill-rollups.ts
@@ -66,12 +66,9 @@ async function main(): Promise {
for (const [index, user] of users.entries()) {
const userStarted = Date.now();
try {
- const { rowsUpserted, durationMs } = await recomputeUserRollups(
- user.id,
- {
- granularities: ALL_GRANULARITIES,
- },
- );
+ const { rowsUpserted, durationMs } = await recomputeUserRollups(user.id, {
+ granularities: ALL_GRANULARITIES,
+ });
totalRowsUpserted += rowsUpserted;
console.log(
`[${index + 1}/${users.length}] user=${user.username} ` +
diff --git a/scripts/check-env.ts b/scripts/check-env.ts
index 29e563ec7..cfc5b5874 100644
--- a/scripts/check-env.ts
+++ b/scripts/check-env.ts
@@ -158,11 +158,7 @@ export function checkEnv(
// All-or-none groups: surface a synthetic row when the group is
// partially populated. This catches the v1.4.40 AP-2 pattern even
// when the group itself is `required: false`.
- if (
- group.allOrNone &&
- presentInGroup > 0 &&
- absentInGroup > 0
- ) {
+ if (group.allOrNone && presentInGroup > 0 && absentInGroup > 0) {
results.push({
group: group.name,
variable: "",
@@ -205,8 +201,7 @@ function renderResults(results: CheckResult[]): {
);
} else if (r.required) {
console.log(
- ` [MISSING-REQUIRED] ${r.variable}` +
- (r.note ? ` — ${r.note}` : ""),
+ ` [MISSING-REQUIRED] ${r.variable}` + (r.note ? ` — ${r.note}` : ""),
);
missingRequired++;
} else {
diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json
index 51f68af58..74fa57795 100644
--- a/scripts/env-manifest.json
+++ b/scripts/env-manifest.json
@@ -163,11 +163,17 @@
"required": false,
"allOrNone": true,
"variables": [
- { "name": "BACKUP_S3_ENDPOINT", "purpose": "S3-compatible endpoint URL." },
+ {
+ "name": "BACKUP_S3_ENDPOINT",
+ "purpose": "S3-compatible endpoint URL."
+ },
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
- { "name": "BACKUP_ENCRYPTION_KEY", "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." }
+ {
+ "name": "BACKUP_ENCRYPTION_KEY",
+ "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
+ }
]
}
]
diff --git a/scripts/generate-sw-version.mjs b/scripts/generate-sw-version.mjs
index b206edad8..2fc3b43df 100644
--- a/scripts/generate-sw-version.mjs
+++ b/scripts/generate-sw-version.mjs
@@ -53,4 +53,6 @@ const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`);
if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8");
// One-line log so the build output shows the version we baked in.
-process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`);
+process.stdout.write(
+ `sw-version.js → ${version} (sw.js fallback re-anchored)\n`,
+);
diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md
index 258e64cf6..4eb35bdc4 100644
--- a/scripts/migrate-demo-edge01.README.md
+++ b/scripts/migrate-demo-edge01.README.md
@@ -30,28 +30,28 @@ on both ends** so secret columns can be NULLed and column order is pinned.
## What moves (FK-parent-first, 20 tables)
-| # | Table | Scope predicate |
-|---|---|---|
-| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
-| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
-| 3 | `cycle_profiles` | `user_id = demo` |
-| 4 | `menstrual_cycles` | `user_id = demo` |
-| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
-| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
-| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
-| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
-| 9 | `medications` | `user_id = demo` |
-| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
-| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
-| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
-| 13 | `medication_inventory_items` | `user_id = demo` |
-| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
-| 15 | `medication_side_effects` | `user_id = demo` |
-| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
-| 17 | `personal_records` | `user_id = demo` |
-| 18 | `user_achievements` | `user_id = demo` |
-| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
-| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
+| # | Table | Scope predicate |
+| --- | ----------------------------- | --------------------------------------------------------------------------------- |
+| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
+| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
+| 3 | `cycle_profiles` | `user_id = demo` |
+| 4 | `menstrual_cycles` | `user_id = demo` |
+| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
+| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
+| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
+| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
+| 9 | `medications` | `user_id = demo` |
+| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
+| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
+| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
+| 13 | `medication_inventory_items` | `user_id = demo` |
+| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
+| 15 | `medication_side_effects` | `user_id = demo` |
+| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
+| 17 | `personal_records` | `user_id = demo` |
+| 18 | `user_achievements` | `user_id = demo` |
+| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
+| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
## Secret / credential columns NULLed on the `users` row
@@ -142,7 +142,7 @@ be a write-capable admin. Keep DEMO_MODE enabled.
## Caveats / things to confirm before running
-- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried
+- **`FOREIGN_HITS` assertion** counts _all_ non-demo rows in the carried
tables on edge01, not just what this run inserted. On a clean
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
accounts, treat a non-zero value as a warning to inspect, not an automatic
diff --git a/scripts/promote-digest.ts b/scripts/promote-digest.ts
index 4426a958d..b2930a0d1 100644
--- a/scripts/promote-digest.ts
+++ b/scripts/promote-digest.ts
@@ -151,7 +151,11 @@ async function updateCoolifyEnv(
appUuid: string,
value: string,
): Promise {
- const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
+ const list = await coolifyRequest(
+ base,
+ token,
+ `/applications/${appUuid}/envs`,
+ );
if (!list.ok) {
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
}
diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts
index 44c4d4f31..647df1c14 100644
--- a/scripts/repair-intake-anomalies.ts
+++ b/scripts/repair-intake-anomalies.ts
@@ -100,7 +100,10 @@ const TAG = "[repair-intake-anomalies]";
// LOCAL time by default; this parser pins them back to UTC so every
// Date this script handles is the true instant. (OID 1114 = timestamp
// without time zone.)
-types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
+types.setTypeParser(
+ 1114,
+ (value: string) => new Date(`${value.replace(" ", "T")}Z`),
+);
/**
* Serialise a Date for a `timestamp without time zone` comparison /
@@ -1056,11 +1059,7 @@ async function main(): Promise {
SET "starts_on" = $2::timestamp, "updated_at" = NOW()
WHERE "id" = $1 AND "starts_on" = $3::timestamp
`,
- [
- p.medicationId,
- utcParam(p.firstRow),
- utcParam(p.startsOn),
- ],
+ [p.medicationId, utcParam(p.firstRow), utcParam(p.startsOn)],
);
summary.startsOnAdjusted += res.rowCount ?? 0;
}
@@ -1105,11 +1104,17 @@ async function main(): Promise {
console.log(` duplicate slot groups: ${summary.duplicateGroups}`);
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
- console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
- console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
+ console.log(
+ ` implausible rows tombstoned: ${summary.implausibleTombstoned}`,
+ );
+ console.log(
+ ` window/times drift schedules: ${summary.windowDriftSchedules}`,
+ );
console.log(` windows reconciled: ${summary.windowsReconciled}`);
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
- console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
+ console.log(
+ ` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`,
+ );
console.log(` era-inference candidates: ${summary.eraCandidates}`);
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index 4bf6d1c82..2e2f5acca 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -207,7 +207,11 @@ async function main() {
),
);
results.push(
- await rotateStringColumn("WhoopConnection", field, prisma.whoopConnection),
+ await rotateStringColumn(
+ "WhoopConnection",
+ field,
+ prisma.whoopConnection,
+ ),
);
results.push(
await rotateStringColumn(
@@ -349,11 +353,7 @@ async function main() {
await rotateBytesColumn("LabResult", "noteEncrypted", prisma.labResult),
);
results.push(
- await rotateBytesColumn(
- "Biomarker",
- "contextEncrypted",
- prisma.biomarker,
- ),
+ await rotateBytesColumn("Biomarker", "contextEncrypted", prisma.biomarker),
);
results.push(
await rotateBytesColumn(
diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts
index 44d4db708..25ae998a6 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -1,18 +1,48 @@
/**
* Demo Data Seed Script for HealthLog
*
- * Creates realistic demo data:
+ * Creates a realistic, aspirational demo tenant for the public `demo`
+ * account (demo / demo123demo123 — credentials are intentionally public).
+ * The shape is tuned so a fresh seed reads healthy, engaged, and current
+ * for App-Store / marketing screenshots:
+ *
* - 1 admin user (demo/demo123demo123)
- * - 90 days of measurements (weight, BP, pulse, body fat, sleep, steps)
- * - 3 medications with schedules and intake history
+ * - 90 days of measurements (weight, BP, pulse, resting HR, body fat,
+ * sleep, steps) — flat-or-improving trends, most-recent point from today
+ * - A full week of per-stage sleep nights (awake/REM/light(core)/deep),
+ * ~7.5 h each, including last night
+ * - 90 days of body composition (fat/lean/muscle mass, total body water,
+ * bone mass, visceral fat, BMI) derived from the same-day weight + body fat
+ * - 90 days of blood glucose in a healthy non-diabetic band (fasting +
+ * post-meal + bedtime, contexts tagged)
+ * - 90 days of cardio fitness + vitals (HRV SDNN + RMSSD, SpO2, respiratory
+ * rate, VO2 max, active energy, walking/running distance, flights climbed)
+ * - 90 days of WHOOP-style scores (recovery, day strain, sleep
+ * performance/efficiency/consistency) coherent with the sleep nights
+ * - ~3-4 workouts/week (running, strength, cycling) with per-workout HR
+ * samples and per-workout strain
+ * - 3 medications with schedules and ~90 days of intake history at high
+ * compliance, with today scheduled on-track (taken or not-yet-due)
* - 90 days of mood entries
+ * - Vorsorge (preventive-care) reminders — upcoming dental + annual physical
+ * - One lab panel of biomarkers in healthy ranges
+ * - One resolved illness episode in the past
+ * - An AI-configured Coach with one short sample conversation
* - App settings (registration disabled, English locale)
*
+ * Every date is relative to "now" so the demo stays fresh on every re-seed.
+ *
* Usage: npx tsx scripts/seed-demo.ts
- * Requires DATABASE_URL env var.
+ * Requires DATABASE_URL env var. The Coach sample conversation additionally
+ * needs ENCRYPTION_KEYS / ENCRYPTION_KEY (the same key the app runs with).
*/
+import { Buffer } from "node:buffer";
+
import pg from "pg";
+import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
+import { encrypt } from "../src/lib/crypto";
+import { VALUE_RANGES } from "../src/lib/validations/measurement";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
@@ -45,6 +75,16 @@ function daysAgo(n: number): Date {
return d;
}
+// A specific local instant N days ago, at a fixed wall-clock time. Used for
+// the per-stage sleep segments and the on-track intake slots where the hour
+// matters (a random morning hour would scatter the sleep stages).
+function daysAgoAt(n: number, hour: number, minute = 0): Date {
+ const d = new Date();
+ d.setDate(d.getDate() - n);
+ d.setHours(hour, minute, 0, 0);
+ return d;
+}
+
function formatDate(d: Date): string {
return d.toISOString().split("T")[0];
}
@@ -55,13 +95,20 @@ function randomWalk(
target: number,
days: number,
volatility: number,
+ // Optional physiological band. A free walk can drift below the floor and
+ // mint an out-of-range row (a systolic-0 BP reading was observed in a
+ // fresh seed — iOS #33); clamping every step keeps the series inside the
+ // same band the input validator enforces.
+ clamp?: { min: number; max: number },
): number[] {
const values: number[] = [start];
for (let i = 1; i < days; i++) {
const prev = values[i - 1];
const drift = (target - prev) * 0.03; // mean reversion
const noise = (Math.random() - 0.5) * volatility;
- values.push(Math.round((prev + drift + noise) * 10) / 10);
+ let next = Math.round((prev + drift + noise) * 10) / 10;
+ if (clamp) next = Math.min(clamp.max, Math.max(clamp.min, next));
+ values.push(next);
}
return values;
}
@@ -75,7 +122,14 @@ async function seed() {
await client.query("BEGIN");
console.log("Cleaning existing data...");
+ await client.query("DELETE FROM coach_messages");
+ await client.query("DELETE FROM coach_conversations");
+ await client.query("DELETE FROM illness_episodes");
+ await client.query("DELETE FROM lab_results");
+ await client.query("DELETE FROM measurement_reminders");
await client.query("DELETE FROM mood_entries");
+ await client.query("DELETE FROM workout_samples");
+ await client.query("DELETE FROM workouts");
await client.query("DELETE FROM medication_intake_events");
await client.query("DELETE FROM medication_schedules");
await client.query("DELETE FROM reminder_phase_configs");
@@ -101,9 +155,17 @@ async function seed() {
const passwordHash =
"$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
+ // AI Coach is configured to the Anthropic provider so the Coach surface
+ // reads as connected. The stored key is a placeholder ciphertext: the
+ // public demo never makes a live provider call (the sample conversation
+ // below supplies the visible content), so no real credential is seeded.
+ const aiProvider = "ANTHROPIC";
+ const aiModel = "claude-3-5-sonnet";
+ const aiKeyEncrypted = encrypt("sk-ant-demo-placeholder-not-a-live-key");
+
await client.query(
- `INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`,
+ `INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, ai_provider, ai_model, ai_anthropic_key_encrypted, onboarding_completed_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW(), NOW())`,
[
userId,
"demo",
@@ -115,29 +177,62 @@ async function seed() {
"MALE",
"Europe/Berlin",
"en",
+ aiProvider,
+ aiModel,
+ aiKeyEncrypted,
],
);
// ── Measurements (90 days) ────────────────
+ // The loop runs i = 0..days (inclusive) so the final iteration lands a
+ // point for TODAY (daysAgo(0)) — every headline metric has a current
+ // reading for the dashboard, not one from yesterday.
console.log("Creating 90 days of measurements...");
const days = 90;
+ const span = days + 1; // include today
- // Weight: 86.5 → trending down to ~82.5
- const weights = randomWalk(86.5, 82.0, days, 0.6);
- // Systolic BP: 132 → improving to ~124
- const sysBP = randomWalk(132, 124, days, 4);
- // Diastolic BP: 85 → improving to ~78
- const diaBP = randomWalk(85, 78, days, 3);
- // Pulse: 72 → slight improvement to ~68
- const pulse = randomWalk(72, 68, days, 3);
- // Body fat: 24.5% → trending to ~22%
- const bodyFat = randomWalk(24.5, 22.0, days, 0.4);
- // Sleep: 6.5h → improving to ~7.5h
- const sleep = randomWalk(6.5, 7.5, days, 0.8);
- // Steps: 5000 → improving to ~8000
- const steps = randomWalk(5000, 8500, days, 1500);
+ // Weight: 86.5 → trending down to a stable ~82 (healthy BMI ~24.7 at 182cm)
+ const weights = randomWalk(86.5, 82.0, span, 0.6);
+ // Systolic BP: 128 → settled in the optimal band ~118 (clamped)
+ const sysBP = randomWalk(
+ 128,
+ 118,
+ span,
+ 3,
+ VALUE_RANGES.BLOOD_PRESSURE_SYS,
+ );
+ // Diastolic BP: 82 → settled ~76 (clamped)
+ const diaBP = randomWalk(
+ 82,
+ 76,
+ span,
+ 2.5,
+ VALUE_RANGES.BLOOD_PRESSURE_DIA,
+ );
+ // Spot pulse (daytime heart rate): ~72, gently lower
+ const pulse = randomWalk(74, 70, span, 3, VALUE_RANGES.PULSE);
+ // Resting heart rate: clean ~60, the metric the resting-pulse tile scores
+ const restingHr = randomWalk(
+ 64,
+ 58,
+ span,
+ 2,
+ VALUE_RANGES.RESTING_HEART_RATE,
+ );
+ // Body fat: 24% → trending to a healthy ~19%
+ const bodyFat = randomWalk(24.0, 19.0, span, 0.4, VALUE_RANGES.BODY_FAT);
+ // Sleep duration in MINUTES (the SLEEP_DURATION unit): ~7h → ~7h45m
+ const sleepMin = randomWalk(
+ 420,
+ 465,
+ span,
+ 25,
+ VALUE_RANGES.SLEEP_DURATION,
+ );
+ // Steps: 6500 → a solid ~9000
+ const steps = randomWalk(6500, 9000, span, 1500);
- for (let i = 0; i < days; i++) {
+ for (let i = 0; i < span; i++) {
const date = daysAgo(days - i);
// Weight (daily)
@@ -159,13 +254,20 @@ async function seed() {
[cuid(), userId, Math.round(diaBP[i]), date],
);
- // Pulse (daily)
+ // Spot pulse (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(pulse[i]), date],
);
+ // Resting heart rate (daily) — scored by the resting-pulse tile
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'RESTING_HEART_RATE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
+ [cuid(), userId, Math.round(restingHr[i]), date],
+ );
+
// Body fat (every 2-3 days)
if (i % 2 === 0 || Math.random() > 0.5) {
await client.query(
@@ -175,12 +277,19 @@ async function seed() {
);
}
- // Sleep (daily)
- await client.query(
- `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
- VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`,
- [cuid(), userId, Math.round(sleep[i] * 10) / 10, date],
- );
+ // Sleep duration (daily, in minutes). This single aggregate row keeps
+ // the 90-day sleep series continuous for the trend chart. The most
+ // recent seven nights are instead written as per-stage breakdowns below
+ // (one row per stage); skip the aggregate for those so a night is not
+ // double-counted (aggregate + summed stages).
+ const nightsAgo = days - i;
+ if (nightsAgo >= 7) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'MANUAL', $4, $4, $4)`,
+ [cuid(), userId, Math.round(sleepMin[i]), date],
+ );
+ }
// Steps (daily)
await client.query(
@@ -190,6 +299,382 @@ async function seed() {
);
}
+ // ── Per-stage sleep nights (last 7 nights, incl. last night) ──
+ // One SLEEP_DURATION row per stage, all stamped at the same wake instant,
+ // mirroring how a HealthKit night lands (AWAKE / REM / CORE / DEEP, with
+ // the legacy aggregate kept above). The reader sums the stages for the
+ // night's total. Durations target a healthy ~7.5 h with a normal stage
+ // mix (~55% core, ~22% REM, ~18% deep, ~5% awake). Source APPLE_HEALTH
+ // with device_type 'watch' so the night reads as device-tracked rather
+ // than a manual estimate.
+ console.log("Creating per-stage sleep nights (7 nights)...");
+ const sleepStages: Array<{ stage: string; fraction: number }> = [
+ { stage: "AWAKE", fraction: 0.05 },
+ { stage: "REM", fraction: 0.22 },
+ { stage: "CORE", fraction: 0.55 },
+ { stage: "DEEP", fraction: 0.18 },
+ ];
+ for (let n = 1; n <= 7; n++) {
+ // Wake at ~06:50 with small per-night jitter; total ~7.5 h.
+ const wake = daysAgoAt(n - 1, 6, 40 + Math.floor(Math.random() * 25));
+ const totalMin = 440 + Math.floor(Math.random() * 40); // 7h20m – 8h
+ for (const { stage, fraction } of sleepStages) {
+ const stageMin = Math.round(totalMin * fraction);
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, sleep_stage, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'APPLE_HEALTH', $4, 'watch', $5, $5, $5)`,
+ [cuid(), userId, stageMin, stage, wake],
+ );
+ }
+ }
+
+ // ── Body composition (smart-scale series) ──
+ // A full scale-style breakdown, written every 2–3 days like a real
+ // body-composition scale would record. Every component is DERIVED from
+ // the same-day weight + body-fat series above so the numbers reconcile:
+ // fat mass = weight × bodyFat%, fat-free mass = weight − fat mass, and
+ // the muscle / water / bone / BMI components hang off those so the demo
+ // never contradicts its own headline weight + body-fat tiles. Source
+ // APPLE_HEALTH with device_type 'scale' so they read as device-tracked.
+ // Canonical units (see prisma/schema.prisma + VALUE_RANGES): mass
+ // components in kg, VISCERAL_FAT a 1–12 rating, BODY_MASS_INDEX kg/m².
+ console.log("Creating body composition series...");
+ const HEIGHT_M = 1.82;
+ for (let i = 0; i < span; i++) {
+ // Roughly every other day, jittered, so the cadence reads organic.
+ if (i % 2 !== 0 && Math.random() > 0.4) continue;
+ const date = daysAgo(days - i);
+ const weightKg = weights[i];
+ const fatPct = bodyFat[i];
+ const fatMass = Math.round(weightKg * (fatPct / 100) * 10) / 10;
+ const fatFree = Math.round((weightKg - fatMass) * 10) / 10;
+ // Skeletal muscle is a subset of fat-free mass (~75% of it for a lean
+ // adult male); lean body mass ≈ fat-free mass.
+ const muscleMass = Math.round(fatFree * 0.75 * 10) / 10;
+ const leanMass = fatFree;
+ // Total body water ≈ 73% of fat-free mass (kg of water).
+ const bodyWater = Math.round(fatFree * 0.73 * 10) / 10;
+ const boneMass =
+ Math.round((3.1 + (Math.random() - 0.5) * 0.2) * 10) / 10;
+ // Visceral-fat rating on Withings' 1–12 scale; a healthy ~6, easing
+ // down slightly as the body fat trends down.
+ const visceralFat = Math.round(7 - (i / span) * 1.5);
+ const bmi = Math.round((weightKg / (HEIGHT_M * HEIGHT_M)) * 10) / 10;
+
+ const composition: Array<{ type: string; value: number; unit: string }> =
+ [
+ { type: "FAT_MASS", value: fatMass, unit: "kg" },
+ { type: "FAT_FREE_MASS", value: fatFree, unit: "kg" },
+ { type: "LEAN_BODY_MASS", value: leanMass, unit: "kg" },
+ { type: "MUSCLE_MASS", value: muscleMass, unit: "kg" },
+ { type: "TOTAL_BODY_WATER", value: bodyWater, unit: "kg" },
+ { type: "BONE_MASS", value: boneMass, unit: "kg" },
+ { type: "VISCERAL_FAT", value: visceralFat, unit: "rating" },
+ { type: "BODY_MASS_INDEX", value: bmi, unit: "kg/m²" },
+ ];
+ for (const c of composition) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'scale', $6, $6, $6)`,
+ [cuid(), userId, c.type, c.value, c.unit, date],
+ );
+ }
+ }
+
+ // ── Blood glucose (healthy non-diabetic series) ──
+ // A few readings per day over the full window, every value in a healthy
+ // non-diabetic band: fasting ~85–95 mg/dL, a well-controlled post-meal
+ // peak ~110–135, and a calm bedtime ~95–105. Canonical storage is mg/dL
+ // (schema note on BLOOD_GLUCOSE); the per-reading context lands in the
+ // glucose_context column (FASTING / POSTPRANDIAL / BEDTIME) so the
+ // glucose panel can apply the right target band per reading.
+ console.log("Creating blood glucose series...");
+ const glucoseReadings: Array<{
+ context: string;
+ hour: number;
+ base: number;
+ jitter: number;
+ }> = [
+ { context: "FASTING", hour: 7, base: 89, jitter: 6 },
+ { context: "POSTPRANDIAL", hour: 13, base: 122, jitter: 12 },
+ { context: "POSTPRANDIAL", hour: 19, base: 118, jitter: 12 },
+ { context: "BEDTIME", hour: 22, base: 99, jitter: 6 },
+ ];
+ for (let i = 0; i < span; i++) {
+ for (const r of glucoseReadings) {
+ const at = daysAgoAt(days - i, r.hour, Math.floor(Math.random() * 30));
+ const value = Math.round(r.base + (Math.random() - 0.5) * r.jitter);
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'BLOOD_GLUCOSE', $3, 'mg/dL', 'APPLE_HEALTH', $4, 'phone', $5, $5, $5)`,
+ [cuid(), userId, value, r.context, at],
+ );
+ }
+ }
+
+ // ── Cardio fitness + vitals ───────────────
+ // The dashboard + insights surface these device-tracked vitals. All
+ // recent (full window, latest point today), all in healthy/aspirational
+ // bands. Canonical units per schema + VALUE_RANGES: HRV ms,
+ // OXYGEN_SATURATION percent, RESPIRATORY_RATE breaths/min, VO2_MAX
+ // mL/(kg·min), ACTIVE_ENERGY_BURNED kcal, WALKING_RUNNING_DISTANCE metres,
+ // FLIGHTS_CLIMBED count. Source APPLE_HEALTH device_type 'watch'.
+ console.log("Creating cardio fitness + vitals series...");
+ // HRV SDNN: a healthy ~55 ms, gently rising; also write the RMSSD variant.
+ const hrvSdnn = randomWalk(
+ 48,
+ 58,
+ span,
+ 5,
+ VALUE_RANGES.HEART_RATE_VARIABILITY,
+ );
+ const hrvRmssd = randomWalk(42, 52, span, 5, VALUE_RANGES.HRV_RMSSD);
+ // SpO2: 97–99%.
+ const spo2 = randomWalk(98, 98, span, 0.8, VALUE_RANGES.OXYGEN_SATURATION);
+ // Respiratory rate: 13–16 breaths/min.
+ const respRate = randomWalk(
+ 15,
+ 14,
+ span,
+ 0.8,
+ VALUE_RANGES.RESPIRATORY_RATE,
+ );
+ // Active energy: ~550 kcal/day.
+ const activeKcal = randomWalk(
+ 480,
+ 600,
+ span,
+ 80,
+ VALUE_RANGES.ACTIVE_ENERGY_BURNED,
+ );
+ // Walking + running distance (metres): ~6–8 km/day, tracks steps.
+ const distM = randomWalk(
+ 6200,
+ 7800,
+ span,
+ 900,
+ VALUE_RANGES.WALKING_RUNNING_DISTANCE,
+ );
+ // Flights climbed: ~8–14/day.
+ const flights = randomWalk(9, 13, span, 4, VALUE_RANGES.FLIGHTS_CLIMBED);
+
+ for (let i = 0; i < span; i++) {
+ const date = daysAgo(days - i);
+ const vitals: Array<{ type: string; value: number; unit: string }> = [
+ {
+ type: "HEART_RATE_VARIABILITY",
+ value: Math.round(hrvSdnn[i]),
+ unit: "ms",
+ },
+ { type: "HRV_RMSSD", value: Math.round(hrvRmssd[i]), unit: "ms" },
+ {
+ type: "OXYGEN_SATURATION",
+ value: Math.round(spo2[i] * 10) / 10,
+ unit: "%",
+ },
+ {
+ type: "RESPIRATORY_RATE",
+ value: Math.round(respRate[i] * 10) / 10,
+ unit: "count/min",
+ },
+ {
+ type: "ACTIVE_ENERGY_BURNED",
+ value: Math.round(activeKcal[i]),
+ unit: "kcal",
+ },
+ {
+ type: "WALKING_RUNNING_DISTANCE",
+ value: Math.round(distM[i]),
+ unit: "m",
+ },
+ {
+ type: "FLIGHTS_CLIMBED",
+ value: Math.round(flights[i]),
+ unit: "count",
+ },
+ ];
+ for (const v of vitals) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'watch', $6, $6, $6)`,
+ [cuid(), userId, v.type, v.value, v.unit, date],
+ );
+ }
+ // VO2 max: only every ~10 days (Apple samples it rarely), ~46–49.
+ if (i % 10 === 0) {
+ const vo2 = Math.round((46 + (i / span) * 3) * 10) / 10;
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'VO2_MAX', $3, 'mL/(kg·min)', 'APPLE_HEALTH', 'watch', $4, $4, $4)`,
+ [cuid(), userId, vo2, date],
+ );
+ }
+ }
+
+ // ── Recovery / strain / sleep scores (WHOOP-style) ──
+ // The WHOOP-style readiness surface. These are stored as Measurement rows
+ // (type RECOVERY_SCORE / DAY_STRAIN / SLEEP_PERFORMANCE / SLEEP_EFFICIENCY
+ // / SLEEP_CONSISTENCY, unit 'score' or '%') with source WHOOP, distinct
+ // from the COMPUTED engine's own rows. Coherent with the seeded sleep:
+ // well-slept nights pair with high recovery + efficiency, and day-strain
+ // sits in a sustainable band. RECOVERY_SCORE / sleep percentages are
+ // 0–100; DAY_STRAIN is on WHOOP's 0–21 scale.
+ console.log("Creating recovery / strain / sleep scores...");
+ const recovery = randomWalk(68, 78, span, 8, VALUE_RANGES.RECOVERY_SCORE);
+ const dayStrain = randomWalk(11, 13, span, 2.5, VALUE_RANGES.DAY_STRAIN);
+ const sleepPerf = randomWalk(
+ 82,
+ 90,
+ span,
+ 6,
+ VALUE_RANGES.SLEEP_PERFORMANCE,
+ );
+ const sleepEff = randomWalk(88, 93, span, 4, VALUE_RANGES.SLEEP_EFFICIENCY);
+ const sleepConsistency = randomWalk(
+ 74,
+ 84,
+ span,
+ 6,
+ VALUE_RANGES.SLEEP_CONSISTENCY,
+ );
+ for (let i = 0; i < span; i++) {
+ // Anchor scores to the morning wake instant so a night's recovery sits
+ // on the day it belongs to.
+ const at = daysAgoAt(days - i, 7, 0);
+ const scores: Array<{ type: string; value: number; unit: string }> = [
+ {
+ type: "RECOVERY_SCORE",
+ value: Math.round(recovery[i]),
+ unit: "score",
+ },
+ {
+ type: "DAY_STRAIN",
+ value: Math.round(dayStrain[i] * 10) / 10,
+ unit: "score",
+ },
+ {
+ type: "SLEEP_PERFORMANCE",
+ value: Math.round(sleepPerf[i]),
+ unit: "%",
+ },
+ { type: "SLEEP_EFFICIENCY", value: Math.round(sleepEff[i]), unit: "%" },
+ {
+ type: "SLEEP_CONSISTENCY",
+ value: Math.round(sleepConsistency[i]),
+ unit: "%",
+ },
+ ];
+ for (const s of scores) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'WHOOP', 'band', $6, $6, $6)`,
+ [cuid(), userId, s.type, s.value, s.unit, at],
+ );
+ }
+ }
+
+ // ── Workouts ──────────────────────────────
+ // ~3–4 sessions/week across the window: a mix of running, strength, and
+ // cycling, with the fields the workout tiles render — duration, energy,
+ // avg/max HR, distance (running/cycling). Each workout carries a sparse
+ // HR sample series in WorkoutSamples (the per-workout HR chart) and its
+ // per-workout strain in metadata (WORKOUT_STRAIN lives on the workout row
+ // per the schema note, not as a free-floating Measurement). Distances in
+ // metres, energy in kcal, durations in seconds — matching the model.
+ console.log("Creating workouts...");
+ const workoutPlan: Array<{
+ sport: string;
+ durationMin: number;
+ kcal: number;
+ avgHr: number;
+ maxHr: number;
+ distanceM: number | null;
+ strain: number;
+ }> = [
+ {
+ sport: "running",
+ durationMin: 38,
+ kcal: 420,
+ avgHr: 148,
+ maxHr: 171,
+ distanceM: 6500,
+ strain: 13.4,
+ },
+ {
+ sport: "strength",
+ durationMin: 52,
+ kcal: 360,
+ avgHr: 118,
+ maxHr: 152,
+ distanceM: null,
+ strain: 10.2,
+ },
+ {
+ sport: "cycling",
+ durationMin: 65,
+ kcal: 520,
+ avgHr: 134,
+ maxHr: 158,
+ distanceM: 24000,
+ strain: 12.1,
+ },
+ ];
+ // Step backwards through the window placing ~2 sessions a week per slot
+ // so the cadence lands around 3–4/week without overlapping.
+ let planIdx = 0;
+ for (let dayOffset = days - 1; dayOffset >= 1; dayOffset -= 2) {
+ // Skip ~25% of slots so the week isn't perfectly regular.
+ if (Math.random() < 0.25) continue;
+ const plan = workoutPlan[planIdx % workoutPlan.length];
+ planIdx += 1;
+ const startHour = 17 + Math.floor(Math.random() * 3);
+ const startedAt = daysAgoAt(
+ dayOffset,
+ startHour,
+ Math.floor(Math.random() * 50),
+ );
+ const durationSec = plan.durationMin * 60;
+ const endedAt = new Date(startedAt.getTime() + durationSec * 1000);
+ const workoutId = cuid();
+ await client.query(
+ `INSERT INTO workouts (id, user_id, sport_type, started_at, ended_at, duration_sec, total_energy_kcal, total_distance_m, avg_heart_rate, max_heart_rate, source, metadata, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'APPLE_HEALTH', $11, $4, $4)`,
+ [
+ workoutId,
+ userId,
+ plan.sport,
+ startedAt,
+ endedAt,
+ durationSec,
+ plan.kcal,
+ plan.distanceM,
+ plan.avgHr,
+ plan.maxHr,
+ JSON.stringify({ workoutStrain: plan.strain }),
+ ],
+ );
+ // A sparse HR series — one sample every ~5 minutes across the session,
+ // rising into the working band and easing at the end. The per-workout
+ // HR chart renders straight from this WorkoutSamples blob.
+ const sampleCount = Math.max(2, Math.round(plan.durationMin / 5));
+ const samples: Array<{ t: string; hr: number }> = [];
+ for (let s = 0; s < sampleCount; s++) {
+ const frac = s / (sampleCount - 1);
+ // Warm up from ~60% to the avg, peak near the max around 70% through.
+ const peakBias = 1 - Math.abs(frac - 0.7) * 0.6;
+ const hr = Math.round(
+ plan.avgHr + (plan.maxHr - plan.avgHr) * peakBias * 0.7,
+ );
+ const t = new Date(startedAt.getTime() + frac * durationSec * 1000);
+ samples.push({ t: t.toISOString(), hr });
+ }
+ await client.query(
+ `INSERT INTO workout_samples (id, workout_id, samples, sample_count, created_at)
+ VALUES ($1, $2, $3, $4, NOW())`,
+ [cuid(), workoutId, JSON.stringify(samples), samples.length],
+ );
+ }
+
// ── Medications ───────────────────────────
console.log("Creating medications...");
@@ -211,7 +696,7 @@ async function seed() {
const med2Id = cuid();
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
- VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`,
+ VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $3)`,
[med2Id, userId, daysAgo(90)],
);
const sched2Id = cuid();
@@ -236,13 +721,17 @@ async function seed() {
);
// ── Medication Intake Events ──────────────
+ // High compliance with no overdue doses. Historical days (i < days) carry
+ // a taken/skip row at ~95%+ adherence. TODAY is handled separately below
+ // so the dashboard always reads on-track (morning doses taken, the
+ // evening dose left as a not-yet-due pending row — never a miss).
console.log("Creating intake events (90 days)...");
for (let i = 0; i < days; i++) {
const date = daysAgo(days - i);
- // Ramipril: 96% compliance (miss ~1 every 25 days)
- const takeMed1 = Math.random() > 0.04;
+ // Ramipril: 98% compliance (miss ~1 every 50 days)
+ const takeMed1 = Math.random() > 0.02;
if (takeMed1) {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(Math.random() * 45), 0);
@@ -259,8 +748,8 @@ async function seed() {
);
}
- // Vitamin D3: 92% compliance
- const takeMed2 = Math.random() > 0.08;
+ // Vitamin D3: 95% compliance
+ const takeMed2 = Math.random() > 0.05;
if (takeMed2) {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0);
@@ -277,9 +766,9 @@ async function seed() {
);
}
- // Magnesium: only last 60 days, 88% compliance
+ // Magnesium: only last 60 days, 94% compliance
if (i >= 30) {
- const takeMed3 = Math.random() > 0.12;
+ const takeMed3 = Math.random() > 0.06;
if (takeMed3) {
const takenTime = new Date(date);
takenTime.setHours(20, Math.floor(Math.random() * 60), 0);
@@ -298,6 +787,31 @@ async function seed() {
}
}
+ // ── Today's doses (on-track, no overdue) ──
+ // Morning meds: taken inside their 08:00–10:00 window. Evening Magnesium:
+ // a pending row anchored on tonight's 20:00 slot, never auto-missed — it
+ // reads as "not yet due" regardless of the seed wall-clock, and the
+ // compliance engine excludes a plain pending row from the denominator, so
+ // "today" never shows 0% or an overdue dose.
+ console.log("Creating today's on-track doses...");
+ const todayMorning = daysAgoAt(0, 8, 30);
+ await client.query(
+ `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
+ [cuid(), userId, med1Id, todayMorning],
+ );
+ await client.query(
+ `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
+ [cuid(), userId, med2Id, todayMorning],
+ );
+ const todayEvening = daysAgoAt(0, 20, 0);
+ await client.query(
+ `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_missed, source, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, NULL, false, false, 'WEB', $4, $4)`,
+ [cuid(), userId, med3Id, todayEvening],
+ );
+
// ── Mood Entries ─────────────────────────
console.log("Creating mood entries (90 days)...");
@@ -310,10 +824,11 @@ async function seed() {
["exhausted", "pain"],
];
- // Mood trend: starts around 3, improves to ~4 over time
- const moodTrend = randomWalk(3.0, 4.2, days, 0.8);
+ // Mood trend: starts solid (~3.5) and improves to ~4.4 — flat-or-up, so
+ // the score never dips into an alarming band.
+ const moodTrend = randomWalk(3.5, 4.4, span, 0.7);
- for (let i = 0; i < days; i++) {
+ for (let i = 0; i < span; i++) {
// Skip ~5% of days (forgot to log)
if (Math.random() < 0.05) continue;
@@ -339,22 +854,121 @@ async function seed() {
);
}
+ // ── Vorsorge reminders (preventive care) ──
+ // User-created (origin VORSORGE) checklist reminders with no auto-resolve
+ // measurement target. Both anchored to fire comfortably in the future so
+ // the Vorsorge surface shows sensible upcoming items, not overdue ones.
+ console.log("Creating Vorsorge reminders...");
+ const dentalNextDue = daysAgo(-45); // ~6 weeks out
+ await client.query(
+ `INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at)
+ VALUES ($1, $2, 'Dental check-up', 365, $3, 'VORSORGE', 9, 'Dentist', $4, NOW(), NOW())`,
+ [cuid(), userId, daysAgo(320), dentalNextDue],
+ );
+ const physicalNextDue = daysAgo(-90); // ~3 months out
+ await client.query(
+ `INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at)
+ VALUES ($1, $2, 'Annual physical', 365, $3, 'VORSORGE', 9, 'Family doctor', $4, NOW(), NOW())`,
+ [cuid(), userId, daysAgo(275), physicalNextDue],
+ );
+
+ // ── Lab panel (biomarkers in healthy ranges) ──
+ // One grouped panel taken ~10 days ago, every analyte comfortably inside
+ // its reference range, so the labs surface reads clean.
+ console.log("Creating lab panel...");
+ const panelTakenAt = daysAgo(10);
+ const labResults: Array<{
+ analyte: string;
+ value: number;
+ unit: string;
+ low: number;
+ high: number;
+ }> = [
+ {
+ analyte: "Total Cholesterol",
+ value: 178,
+ unit: "mg/dL",
+ low: 0,
+ high: 200,
+ },
+ { analyte: "LDL", value: 98, unit: "mg/dL", low: 0, high: 130 },
+ { analyte: "HDL", value: 58, unit: "mg/dL", low: 40, high: 100 },
+ { analyte: "Triglycerides", value: 92, unit: "mg/dL", low: 0, high: 150 },
+ { analyte: "HbA1c", value: 5.2, unit: "%", low: 4.0, high: 5.6 },
+ {
+ analyte: "Fasting Glucose",
+ value: 89,
+ unit: "mg/dL",
+ low: 70,
+ high: 99,
+ },
+ { analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 },
+ { analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 },
+ { analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 },
+ ];
+ for (const lab of labResults) {
+ await client.query(
+ `INSERT INTO lab_results (id, user_id, panel, analyte, value, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
+ VALUES ($1, $2, 'Annual blood panel', $3, $4, $5, $6, $7, $8, 'MANUAL', NOW(), NOW())`,
+ [
+ cuid(),
+ userId,
+ lab.analyte,
+ lab.value,
+ lab.unit,
+ lab.low,
+ lab.high,
+ panelTakenAt,
+ ],
+ );
+ }
+
+ // ── Resolved illness episode ──────────────
+ // A short cold ~7 weeks ago, fully recovered, so the illness journal shows
+ // a clean "resolved" entry rather than an active sickness.
+ console.log("Creating resolved illness episode...");
+ await client.query(
+ `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, created_at, updated_at)
+ VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, NOW(), NOW())`,
+ [cuid(), userId, daysAgo(49), daysAgo(43)],
+ );
+
// ── Achievements ─────────────────────────
console.log("Creating achievements...");
+ // Every id below is a real key from the achievement registry
+ // (src/lib/gamification/achievements.ts) so the badge resolves to a real
+ // title/icon rather than an "unknown" placeholder. Only milestones the
+ // seeded history genuinely supports are unlocked (first-entry badges,
+ // logging streaks, in-range BP/pulse/BMI streaks, compliance + miss-free
+ // streaks, the self-context + 7-night sleep badges). The dynamic streak
+ // families use the `-` naming the generator emits.
const achievements = [
- { id: "first-measurement", daysAgo: 89 },
- { id: "first-medication", daysAgo: 89 },
- { id: "first-mood", daysAgo: 89 },
- { id: "week-streak-measurements", daysAgo: 82 },
- { id: "week-streak-mood", daysAgo: 82 },
- { id: "month-streak-measurements", daysAgo: 59 },
- { id: "compliance-90", daysAgo: 45 },
- { id: "weight-loss-5", daysAgo: 30 },
- { id: "bp-normal", daysAgo: 20 },
- { id: "steps-10k", daysAgo: 15 },
- { id: "month-streak-mood", daysAgo: 10 },
- { id: "three-month-streak", daysAgo: 2 },
+ { id: "weight-first", daysAgo: 89 },
+ { id: "bp-first", daysAgo: 89 },
+ { id: "pulse-first", daysAgo: 89 },
+ { id: "mood-first", daysAgo: 89 },
+ { id: "self-context-complete", daysAgo: 88 },
+ { id: "entry-streak-7", daysAgo: 82 },
+ { id: "mood-streak-7", daysAgo: 82 },
+ { id: "sleep-log-7", daysAgo: 70 },
+ { id: "mood-up-7", daysAgo: 68 },
+ { id: "weight-50", daysAgo: 60 },
+ { id: "bp-50", daysAgo: 55 },
+ { id: "entry-streak-30", daysAgo: 50 },
+ { id: "mood-streak-30", daysAgo: 50 },
+ { id: "miss-free-7", daysAgo: 48 },
+ { id: "consistent-month", daysAgo: 45 },
+ { id: "bp-green-7", daysAgo: 42 },
+ { id: "pulse-green-7", daysAgo: 40 },
+ { id: "bmi-green-7", daysAgo: 38 },
+ { id: "compliance-80-30", daysAgo: 30 },
+ { id: "miss-free-30", daysAgo: 30 },
+ { id: "on-time-perfect-7", daysAgo: 28 },
+ { id: "measurement-weeks-4", daysAgo: 25 },
+ { id: "bp-green-30", daysAgo: 12 },
+ { id: "pulse-green-30", daysAgo: 10 },
+ { id: "miss-free-90", daysAgo: 2 },
];
for (const ach of achievements) {
@@ -366,6 +980,64 @@ async function seed() {
);
}
+ // ── Coach sample conversation ─────────────
+ // A short, encrypted two-turn exchange so the Coach surface shows real
+ // content. Messages use the same AES-256-GCM Bytes codec the app writes
+ // with (encryptToBytes → encrypt() under ENCRYPTION_KEYS), so they decrypt
+ // exactly like a live conversation — no faked / plaintext rows.
+ console.log("Creating Coach sample conversation...");
+ const convoId = cuid();
+ const convoStart = daysAgo(3);
+ await client.query(
+ `INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $4)`,
+ [convoId, userId, "Blood pressure trend", convoStart],
+ );
+
+ const coachTurns: Array<{
+ role: string;
+ content: string;
+ metricSourceJson?: string;
+ }> = [
+ {
+ role: "user",
+ content: "How has my blood pressure been trending lately?",
+ },
+ {
+ role: "assistant",
+ content:
+ "Your blood pressure has been settling nicely. Over the last few " +
+ "weeks the morning readings have drifted into the optimal band, and " +
+ "your resting heart rate is sitting in a comfortable range too. " +
+ "Keeping up the steady sleep and daily movement looks like it is " +
+ "paying off — worth staying consistent with the morning measurements " +
+ "so the trend stays easy to read.",
+ metricSourceJson: JSON.stringify({
+ window: "last30days",
+ metrics: ["bloodPressure", "restingHeartRate", "sleep"],
+ }),
+ },
+ ];
+ for (let t = 0; t < coachTurns.length; t++) {
+ const turn = coachTurns[t];
+ const ts = new Date(convoStart.getTime() + t * 60_000);
+ const encrypted = Buffer.from(encryptToBytes(turn.content));
+ await client.query(
+ `INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ [
+ cuid(),
+ convoId,
+ turn.role,
+ encrypted,
+ turn.metricSourceJson ?? null,
+ turn.role === "assistant" ? aiProvider : null,
+ turn.role === "assistant" ? "demo" : null,
+ ts,
+ ],
+ );
+ }
+
// ── App Settings ─────────────────────────
console.log("Creating app settings...");
await client.query(
@@ -388,9 +1060,33 @@ async function seed() {
await client.query("COMMIT");
console.log("\nDemo data seeded successfully!");
console.log(` User: demo / demo123demo123`);
- console.log(` Measurements: ${days * 6} entries (7 types, 90 days)`);
- console.log(` Medications: 3 (with ${days * 2 + 60} intake events)`);
- console.log(` Mood: ~${Math.round(days * 0.95)} entries`);
+ console.log(
+ ` Measurements: weight, BP, pulse, resting HR, body fat, steps, sleep`,
+ );
+ console.log(
+ ` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`,
+ );
+ console.log(
+ ` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`,
+ );
+ console.log(
+ ` Cardio + vitals: HRV (SDNN + RMSSD), SpO2, resp. rate, VO2max, active energy, distance, flights`,
+ );
+ console.log(
+ ` Scores: recovery, day strain, sleep performance/efficiency/consistency`,
+ );
+ console.log(
+ ` Sleep: 7 per-stage nights (4 stages each) + daily aggregate`,
+ );
+ console.log(` Medications: 3 (high compliance, today on-track)`);
+ console.log(
+ ` Workouts: ~3-4/week (running, strength, cycling) with HR samples`,
+ );
+ console.log(` Mood: ~${Math.round(span * 0.95)} entries`);
+ console.log(` Vorsorge reminders: 2 (dental, annual physical)`);
+ console.log(` Lab panel: ${labResults.length} biomarkers`);
+ console.log(` Illness: 1 resolved episode`);
+ console.log(` Coach: 1 conversation (${coachTurns.length} messages)`);
console.log(` Achievements: ${achievements.length}`);
} catch (err) {
await client.query("ROLLBACK");
diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts
index e089b8aab..3e29b2e0a 100644
--- a/src/__tests__/api/analytics/since-cap.test.ts
+++ b/src/__tests__/api/analytics/since-cap.test.ts
@@ -64,9 +64,9 @@ vi.mock("next/headers", () => ({
// AsyncLocalStorage `eventStorage.run` wiring intact (the
// `apiHandler` opens an event scope inside `run()`).
vi.mock("@/lib/logging/context", async () => {
- const actual = await vi.importActual<
- typeof import("@/lib/logging/context")
- >("@/lib/logging/context");
+ const actual = await vi.importActual(
+ "@/lib/logging/context",
+ );
return {
...actual,
annotate: vi.fn(actual.annotate),
@@ -153,24 +153,18 @@ beforeEach(async () => {
// `clearAllMocks()` to wipe call history between tests; `vi.fn()` at
// module scope only sets the initial implementation, and the slim
// slice test must observe a resolved value not `undefined`.
- const { ensureUserRollupsFresh } = await import(
- "@/lib/rollups/measurement-rollups"
- );
- const { probeRollupCoverage } = await import(
- "@/lib/rollups/measurement-coverage"
- );
- const { computeBpInTargetFastPath } = await import(
- "@/lib/analytics/bp-in-target-fast-path"
- );
- const { computeUserHealthScoreFastPath } = await import(
- "@/lib/analytics/health-score-fast-path"
- );
- const { computeCorrelationHypothesesFastPath } = await import(
- "@/lib/analytics/correlations-fast-path"
- );
- const { computeSummariesSlice } = await import(
- "@/lib/analytics/summaries-slice"
- );
+ const { ensureUserRollupsFresh } =
+ await import("@/lib/rollups/measurement-rollups");
+ const { probeRollupCoverage } =
+ await import("@/lib/rollups/measurement-coverage");
+ const { computeBpInTargetFastPath } =
+ await import("@/lib/analytics/bp-in-target-fast-path");
+ const { computeUserHealthScoreFastPath } =
+ await import("@/lib/analytics/health-score-fast-path");
+ const { computeCorrelationHypothesesFastPath } =
+ await import("@/lib/analytics/correlations-fast-path");
+ const { computeSummariesSlice } =
+ await import("@/lib/analytics/summaries-slice");
(ensureUserRollupsFresh as ReturnType).mockResolvedValue(
undefined,
@@ -185,9 +179,9 @@ beforeEach(async () => {
priorMonth: null,
priorYear: null,
});
- (computeUserHealthScoreFastPath as ReturnType).mockResolvedValue(
- null,
- );
+ (
+ computeUserHealthScoreFastPath as ReturnType
+ ).mockResolvedValue(null);
(
computeCorrelationHypothesesFastPath as ReturnType
).mockResolvedValue({ hypotheses: [] });
diff --git a/src/__tests__/api/auth/check-user/route.test.ts b/src/__tests__/api/auth/check-user/route.test.ts
index 7cdd433d0..7cd4e5eae 100644
--- a/src/__tests__/api/auth/check-user/route.test.ts
+++ b/src/__tests__/api/auth/check-user/route.test.ts
@@ -62,10 +62,7 @@ vi.mock("next/headers", () => ({
import { POST } from "@/app/api/auth/check-user/route";
import { prisma } from "@/lib/db";
-import {
- checkRateLimit,
- checkAuthSurfaceRateLimit,
-} from "@/lib/rate-limit";
+import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
interface CheckUserBody {
data: {
diff --git a/src/__tests__/i18n-call-site-coverage.test.ts b/src/__tests__/i18n-call-site-coverage.test.ts
index 8e461b4ae..e236f05eb 100644
--- a/src/__tests__/i18n-call-site-coverage.test.ts
+++ b/src/__tests__/i18n-call-site-coverage.test.ts
@@ -27,12 +27,7 @@ const ROOT = join(__dirname, "../..");
const SRC = join(ROOT, "src");
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
-const SKIP_DIRS = new Set([
- "__tests__",
- "node_modules",
- ".next",
- "generated",
-]);
+const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
@@ -48,8 +43,7 @@ function walk(dir: string, out: string[] = []): string[] {
return out;
}
-const KEY_PATTERN =
- /(?)) {
+ if (
+ cur &&
+ typeof cur === "object" &&
+ p in (cur as Record)
+ ) {
cur = (cur as Record)[p];
} else {
return null;
diff --git a/src/__tests__/i18n-drift-guard.test.ts b/src/__tests__/i18n-drift-guard.test.ts
index 4910dc39b..f1348fc4f 100644
--- a/src/__tests__/i18n-drift-guard.test.ts
+++ b/src/__tests__/i18n-drift-guard.test.ts
@@ -30,11 +30,7 @@ function loadBundle(locale: string): Record {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
}
-function flatten(
- obj: unknown,
- prefix: string,
- out: Map,
-): void {
+function flatten(obj: unknown, prefix: string, out: Map): void {
if (obj == null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record)) {
const key = prefix ? `${prefix}.${k}` : k;
diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts
index be205b095..67c3d9cfc 100644
--- a/src/__tests__/i18n-reverse-coverage.test.ts
+++ b/src/__tests__/i18n-reverse-coverage.test.ts
@@ -186,7 +186,11 @@ function isReferenced(leaf: string, ref: Reference): boolean {
}
}
for (const ck of ref.literalKeys) {
- if (base === ck || base.startsWith(`${ck}.`) || ck.startsWith(`${base}.`)) {
+ if (
+ base === ck ||
+ base.startsWith(`${ck}.`) ||
+ ck.startsWith(`${base}.`)
+ ) {
return true;
}
}
diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts
index 6e0043b97..4badffb3a 100644
--- a/src/__tests__/proxy-demo-mode-allowlist.test.ts
+++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts
@@ -42,9 +42,7 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
});
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
- const res = proxy(
- makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"),
- );
+ const res = proxy(makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"));
expect(res.status).not.toBe(403);
});
diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts
index 7b0919563..50f8a0763 100644
--- a/src/__tests__/service-worker.test.ts
+++ b/src/__tests__/service-worker.test.ts
@@ -324,7 +324,9 @@ describe("sw.js — trimCache", () => {
store.keysCalls = 0;
const trimCache = (
- harness.context as { trimCache?: (name: string, max: number) => Promise }
+ harness.context as {
+ trimCache?: (name: string, max: number) => Promise;
+ }
).trimCache;
expect(typeof trimCache).toBe("function");
await trimCache!(CURRENT_PAGE_CACHE, 5);
@@ -340,10 +342,7 @@ describe("sw.js — trimCache", () => {
});
// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ─────────────
-async function dispatchPush(
- harness: SwHarness,
- data: unknown,
-): Promise {
+async function dispatchPush(harness: SwHarness, data: unknown): Promise {
let settled: Promise = Promise.resolve();
harness.listeners.get("push")!({
data: { json: () => data, text: () => JSON.stringify(data) },
diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx
index a37b1b00b..3a3a44700 100644
--- a/src/app/__tests__/dashboard-chart-reveal.test.tsx
+++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx
@@ -147,12 +147,8 @@ describe("dashboard chart-reveal wiring (page.tsx)", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("derives the gated id list from the chart visibility gates", () => {
- expect(src).toMatch(
- /const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/,
- );
- expect(src).toMatch(
- /useDashboardChartReveal\(revealChartIds\)/,
- );
+ expect(src).toMatch(/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/);
+ expect(src).toMatch(/useDashboardChartReveal\(revealChartIds\)/);
});
it("every data-backed chart reports through onDataReady", () => {
diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
index ec774e363..44e5e8ce9 100644
--- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
+++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
@@ -243,9 +243,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
const src = readFileSync(PAGE_PATH, "utf8");
- expect(src).toMatch(
- / \}>/,
- );
+ expect(src).toMatch(/ \}>/);
});
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts
index ab59ec3a6..1b44d387b 100644
--- a/src/app/__tests__/insights-polish.test.ts
+++ b/src/app/__tests__/insights-polish.test.ts
@@ -68,33 +68,29 @@ describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
const shellSource = load(
`${process.cwd()}/src/components/layout/auth-shell.tsx`,
);
- expect(shellSource).toContain(
- 'from "@/lib/insights/coach-launch-context"',
- );
+ expect(shellSource).toContain('from "@/lib/insights/coach-launch-context"');
expect(shellSource).toMatch(//);
expect(shellSource).toMatch(/ /);
});
it("the insights layout no longer double-mounts the provider or drawer", () => {
const layout = load(INSIGHTS_LAYOUT_PATH);
- expect(layout).not.toContain(
- 'from "@/lib/insights/coach-launch-context"',
- );
+ expect(layout).not.toContain('from "@/lib/insights/coach-launch-context"');
expect(layout).not.toContain("import { LayoutCoachMount }");
// FAB stays scoped to `/insights/**` because the floating button
// would distract on surfaces with a contextual inline CTA.
expect(layout).toContain("LayoutCoachFab");
});
- it("hero strip's onAskCoach handler calls the launch context", () => {
+ it("the overview no longer wires the inline ask-the-Coach affordance", () => {
const src = load(INSIGHTS_PATH);
- // The hero supplies onAskCoach which now delegates to
- // `coachLaunch.askCoach(prefill)` from the layout-level provider
- // instead of toggling local state.
- expect(src).toMatch(/ {
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index 7edbb69f5..e32f68e1e 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -50,7 +50,7 @@ function Section({
children: React.ReactNode;
}) {
return (
-
+
{title}
@@ -64,7 +64,7 @@ function Section({
export default function AboutPage() {
return (
-
+
HealthLog stands on a number of open-source libraries and public
- data sources. The list below covers the third-party assets that
- ship with the runtime image and whose licences require an explicit
+ data sources. The list below covers the third-party assets that ship
+ with the runtime image and whose licences require an explicit
attribution.
@@ -150,23 +150,22 @@ export default function AboutPage() {
GeoLite2-ASN
) power the offline IP-to-location and IP-to-carrier lookups that
- decorate the admin login-overview audit table. They are
- distributed under the{" "}
+ decorate the admin login-overview audit table. They are distributed
+ under the{" "}
- Creative Commons Attribution-ShareAlike 4.0 International
- License
+ Creative Commons Attribution-ShareAlike 4.0 International License
.
diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx
index 98dae02ee..40a60c63f 100644
--- a/src/app/achievements/page.tsx
+++ b/src/app/achievements/page.tsx
@@ -222,11 +222,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
value={achievement.progressPercent}
className="h-1.5"
aria-label={t("achievements.criterionHint", {
- current: formatMetric(
- achievement.format,
- achievement.current,
- t,
- ),
+ current: formatMetric(achievement.format, achievement.current, t),
target: formatMetric(achievement.format, achievement.target, t),
})}
/>
@@ -329,8 +325,7 @@ export default function AchievementsPage() {
{t("achievements.subtitle")}
-
-
+
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index fd4e9bb17..f0b98b953 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -268,7 +268,10 @@ function fileHasCall(text: string, needle: string): boolean {
* parameterised insights routes resolve it from a metric map — those gate
* dynamically and are accepted as gated without a literal-key assertion).
*/
-function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set
} {
+function extractModuleKeys(text: string): {
+ hasLiteral: boolean;
+ keys: Set;
+} {
const keys = new Set();
let hasLiteral = false;
const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g;
diff --git a/src/app/api/admin/ai-settings/__tests__/route.test.ts b/src/app/api/admin/ai-settings/__tests__/route.test.ts
index 1ccb38c55..6973b278c 100644
--- a/src/app/api/admin/ai-settings/__tests__/route.test.ts
+++ b/src/app/api/admin/ai-settings/__tests__/route.test.ts
@@ -120,14 +120,14 @@ describe("GET /api/admin/ai-settings", () => {
describe("PUT /api/admin/ai-settings", () => {
function upsertEcho() {
- vi.mocked(prisma.appSettings.upsert).mockImplementation(
- (async (args: { update: Record }) => ({
- adminAiKeyEncrypted: null,
- adminAiModel: "gpt-4o",
- adminAiBaseUrl: "https://api.openai.com/v1",
- ...args.update,
- })) as never,
- );
+ vi.mocked(prisma.appSettings.upsert).mockImplementation((async (args: {
+ update: Record;
+ }) => ({
+ adminAiKeyEncrypted: null,
+ adminAiModel: "gpt-4o",
+ adminAiBaseUrl: "https://api.openai.com/v1",
+ ...args.update,
+ })) as never);
}
it("propagates the admin gate (non-admin cannot write)", async () => {
diff --git a/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts b/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts
index 16ef30c14..10b00c716 100644
--- a/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts
+++ b/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts
@@ -81,22 +81,28 @@ beforeEach(() => {
describe("POST /api/admin/import-apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
- const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
- method: "POST",
- headers: { "content-type": "multipart/form-data; boundary=---x" },
- body: "stub",
- });
+ const req = new NextRequest(
+ "http://localhost/api/admin/import-apple-health-export",
+ {
+ method: "POST",
+ headers: { "content-type": "multipart/form-data; boundary=---x" },
+ body: "stub",
+ },
+ );
const res = await POST(req);
expect(res.status).toBe(401);
});
it("returns 403 when a non-admin cookie session calls the route", async () => {
vi.mocked(getSession).mockResolvedValue(USER_SESSION as never);
- const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
- method: "POST",
- headers: { "content-type": "multipart/form-data; boundary=---x" },
- body: "stub",
- });
+ const req = new NextRequest(
+ "http://localhost/api/admin/import-apple-health-export",
+ {
+ method: "POST",
+ headers: { "content-type": "multipart/form-data; boundary=---x" },
+ body: "stub",
+ },
+ );
const res = await POST(req);
expect(res.status).toBe(403);
});
@@ -107,11 +113,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
"content-type": "multipart/form-data; boundary=---x",
"content-length": String(2 * 1024 * 1024 * 1024),
});
- const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
- method: "POST",
- headers,
- body: "stub",
- });
+ const req = new NextRequest(
+ "http://localhost/api/admin/import-apple-health-export",
+ {
+ method: "POST",
+ headers,
+ body: "stub",
+ },
+ );
const res = await POST(req);
expect(res.status).toBe(413);
});
@@ -119,11 +128,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
it("returns 429 on rate-limit exhaustion", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
- const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
- method: "POST",
- headers: { "content-type": "multipart/form-data; boundary=---x" },
- body: "stub",
- });
+ const req = new NextRequest(
+ "http://localhost/api/admin/import-apple-health-export",
+ {
+ method: "POST",
+ headers: { "content-type": "multipart/form-data; boundary=---x" },
+ body: "stub",
+ },
+ );
const res = await POST(req);
expect(res.status).toBe(429);
});
diff --git a/src/app/api/admin/import-apple-health-export/route.ts b/src/app/api/admin/import-apple-health-export/route.ts
index c8c01446e..2d59f23ec 100644
--- a/src/app/api/admin/import-apple-health-export/route.ts
+++ b/src/app/api/admin/import-apple-health-export/route.ts
@@ -106,10 +106,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "target_user_missing", targetUserId },
});
- return apiError(
- `Target user '${targetUserId}' does not exist`,
- 422,
- );
+ return apiError(`Target user '${targetUserId}' does not exist`, 422);
}
// Same content-hash idempotency as the user-facing route, but scoped
diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts
index 23df2716d..63552fea1 100644
--- a/src/app/api/admin/invites/route.ts
+++ b/src/app/api/admin/invites/route.ts
@@ -22,10 +22,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
-import {
- buildInviteUrl,
- generateInviteToken,
-} from "@/lib/auth/invite-token";
+import { buildInviteUrl, generateInviteToken } from "@/lib/auth/invite-token";
import { inviteCreateSchema } from "@/lib/validations/invite";
export const dynamic = "force-dynamic";
@@ -77,9 +74,7 @@ export const POST = apiHandler(async (req: Request) => {
const { expiresInDays, maxUses } = parsed.data;
const rawToken = generateInviteToken();
- const expiresAt = new Date(
- Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
- );
+ const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
const invite = await prisma.inviteToken.create({
data: {
diff --git a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts
index 05a6d5eee..1f28468ba 100644
--- a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts
+++ b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts
@@ -109,9 +109,7 @@ interface DiagnosticEnvelope {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
- vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
- [] as never,
- );
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
});
diff --git a/src/app/api/admin/notifications/diagnostic/route.ts b/src/app/api/admin/notifications/diagnostic/route.ts
index b85aef231..288048d62 100644
--- a/src/app/api/admin/notifications/diagnostic/route.ts
+++ b/src/app/api/admin/notifications/diagnostic/route.ts
@@ -80,9 +80,10 @@ interface DiagnosticPayload {
* (prefix / suffix both null) — that shape never appears in
* production, but defensive against partial inserts during testing.
*/
-function maskApnsToken(
- token: string | null,
-): { prefix: string | null; suffix: string | null } {
+function maskApnsToken(token: string | null): {
+ prefix: string | null;
+ suffix: string | null;
+} {
if (!token || token.length < 16) return { prefix: null, suffix: null };
return {
prefix: token.slice(0, 8),
diff --git a/src/app/api/admin/notifications/health/route.ts b/src/app/api/admin/notifications/health/route.ts
index f39ff0909..a4fc3825a 100644
--- a/src/app/api/admin/notifications/health/route.ts
+++ b/src/app/api/admin/notifications/health/route.ts
@@ -75,7 +75,13 @@ export const GET = apiHandler(async (request) => {
for (const row of grouped) {
const existing =
byChannel.get(row.channel) ??
- ({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
+ ({
+ channel: row.channel,
+ ok: 0,
+ error: 0,
+ skipped: 0,
+ total: 0,
+ } satisfies ChannelHealthRow);
const count = row._count._all;
if (row.result === "ok") existing.ok += count;
else if (row.result === "error") existing.error += count;
diff --git a/src/app/api/admin/notifications/test/__tests__/route.test.ts b/src/app/api/admin/notifications/test/__tests__/route.test.ts
index 70fe5c7cd..295e44f25 100644
--- a/src/app/api/admin/notifications/test/__tests__/route.test.ts
+++ b/src/app/api/admin/notifications/test/__tests__/route.test.ts
@@ -36,8 +36,9 @@ vi.mock("@/lib/api-handler", async () => {
);
return {
...actual,
- apiHandler: Promise>(h: T): T =>
- h,
+ apiHandler: Promise>(
+ h: T,
+ ): T => h,
requireAdmin: vi.fn(),
};
});
@@ -149,9 +150,7 @@ describe("POST /api/admin/notifications/test — APNS branch (v1.4.46)", () => {
eventType: "SYSTEM_ALERT",
}),
);
- expect(body.data.results).toEqual([
- { channel: "APNS", success: true },
- ]);
+ expect(body.data.results).toEqual([{ channel: "APNS", success: true }]);
expect(body.data.sent).toBe(true);
});
diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts
index c6ead00e2..12d27c357 100644
--- a/src/app/api/admin/notifications/test/route.ts
+++ b/src/app/api/admin/notifications/test/route.ts
@@ -63,8 +63,14 @@ async function ensureTelegramChannel(userId: string): Promise {
}
function isLocale(value: string | null | undefined): value is Locale {
- return value === "de" || value === "en" || value === "fr" ||
- value === "es" || value === "it" || value === "pl";
+ return (
+ value === "de" ||
+ value === "en" ||
+ value === "fr" ||
+ value === "es" ||
+ value === "it" ||
+ value === "pl"
+ );
}
export const POST = apiHandler(async () => {
diff --git a/src/app/api/admin/settings/module-availability/__tests__/route.test.ts b/src/app/api/admin/settings/module-availability/__tests__/route.test.ts
index 2532ed669..846061632 100644
--- a/src/app/api/admin/settings/module-availability/__tests__/route.test.ts
+++ b/src/app/api/admin/settings/module-availability/__tests__/route.test.ts
@@ -48,7 +48,9 @@ describe("GET /api/admin/settings/module-availability", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as unknown as (r: NextRequest) => Promise)(
- new NextRequest("http://localhost/api/admin/settings/module-availability"),
+ new NextRequest(
+ "http://localhost/api/admin/settings/module-availability",
+ ),
);
expect(res.status).toBe(401);
});
@@ -56,7 +58,9 @@ describe("GET /api/admin/settings/module-availability", () => {
it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => {
vi.mocked(getSession).mockResolvedValue(USER_OK as never);
const res = await (GET as unknown as (r: NextRequest) => Promise)(
- new NextRequest("http://localhost/api/admin/settings/module-availability"),
+ new NextRequest(
+ "http://localhost/api/admin/settings/module-availability",
+ ),
);
expect(res.status).toBe(403);
});
@@ -65,7 +69,9 @@ describe("GET /api/admin/settings/module-availability", () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await (GET as unknown as (r: NextRequest) => Promise)(
- new NextRequest("http://localhost/api/admin/settings/module-availability"),
+ new NextRequest(
+ "http://localhost/api/admin/settings/module-availability",
+ ),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
@@ -82,7 +88,9 @@ describe("GET /api/admin/settings/module-availability", () => {
moduleAvailabilityJson: { mood: false },
} as never);
const res = await (GET as unknown as (r: NextRequest) => Promise)(
- new NextRequest("http://localhost/api/admin/settings/module-availability"),
+ new NextRequest(
+ "http://localhost/api/admin/settings/module-availability",
+ ),
);
const body = (await res.json()) as {
data: { availability: Record };
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
index 10cf0bd33..2e7556dcd 100644
--- a/src/app/api/admin/settings/web-push-vapid/generate/route.ts
+++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts
@@ -1,7 +1,12 @@
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 {
+ 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";
@@ -68,7 +73,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const alreadyConfigured = Boolean(
existing?.webPushVapidPublicKey &&
- existing?.webPushVapidPrivateKeyEncrypted,
+ existing?.webPushVapidPrivateKeyEncrypted,
);
if (alreadyConfigured && parsed.data.force !== true) {
diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts
index bb97d59bb..7bcf4b8f2 100644
--- a/src/app/api/admin/status/__tests__/route.test.ts
+++ b/src/app/api/admin/status/__tests__/route.test.ts
@@ -170,5 +170,4 @@ describe("GET /api/admin/status (integration health summary)", () => {
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
});
-
});
diff --git a/src/app/api/admin/users/[id]/__tests__/route.test.ts b/src/app/api/admin/users/[id]/__tests__/route.test.ts
index 07b540371..eaaf4592e 100644
--- a/src/app/api/admin/users/[id]/__tests__/route.test.ts
+++ b/src/app/api/admin/users/[id]/__tests__/route.test.ts
@@ -97,7 +97,10 @@ describe("PUT /api/admin/users/[id]", () => {
it("returns 404 when target user does not exist", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
+ const res = await PUT(
+ jsonReq({ username: "testuser" }),
+ params("u-missing"),
+ );
expect(res.status).toBe(404);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts
index 7d51e212b..ffc4fa0fb 100644
--- a/src/app/api/analytics/__tests__/route.test.ts
+++ b/src/app/api/analytics/__tests__/route.test.ts
@@ -138,18 +138,14 @@ beforeEach(() => {
// findMany + null findFirst means the slim slice's parity check
// diverges and falls back to live SQL (preserves the pre-v1.4.35
// assertions in this file).
- vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
- [] as never,
- );
+ vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
null as never,
);
- vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue(
- { count: 0 } as never,
- );
- vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue(
- {} as never,
- );
+ vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue({
+ count: 0,
+ } as never);
+ vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue({} as never);
// v1.4.37 W2 — `ensureUserRollupsFresh` reads `measurement.findFirst`;
// the per-type coverage probe + the rollup-recompute aggregator ride
// `$queryRaw` / `$queryRawUnsafe`. Default to empty so the route
@@ -318,12 +314,10 @@ describe("GET /api/analytics", () => {
{ type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo },
] as never);
- const req = new Request(
- "http://localhost/api/analytics?slice=summaries",
+ const req = new Request("http://localhost/api/analytics?slice=summaries");
+ const res = await (GET as unknown as (req: Request) => Promise)(
+ req,
);
- const res = await (
- GET as unknown as (req: Request) => Promise
- )(req);
expect(res.status).toBe(200);
// v1.4.34 IW-B — bfcache-friendly directive rides on the slim
// slice too.
@@ -369,17 +363,17 @@ describe("GET /api/analytics", () => {
// both fields rather than 403-ing the whole core-metrics payload.
function seedGlucoseRows() {
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
- vi.mocked(prisma.measurement.findMany).mockImplementation(
- (async (args: { where?: { type?: string } }) => {
- if (args?.where?.type === "BLOOD_GLUCOSE") {
- return [
- { value: 95, measuredAt, glucoseContext: "FASTING" },
- { value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
- ];
- }
- return [];
- }) as never,
- );
+ vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
+ where?: { type?: string };
+ }) => {
+ if (args?.where?.type === "BLOOD_GLUCOSE") {
+ return [
+ { value: 95, measuredAt, glucoseContext: "FASTING" },
+ { value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
+ ];
+ }
+ return [];
+ }) as never);
}
it("serves glucose clinical + per-context when glucose enabled", async () => {
diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts
index 9539cc1ec..d8f0e4e24 100644
--- a/src/app/api/analytics/route.ts
+++ b/src/app/api/analytics/route.ts
@@ -72,7 +72,9 @@ export const GET = apiHandler(async (request?: Request) => {
// instantly while one background recompute refreshes them, instead
// of paying the 2-SQL-pass rebuild inline.
const slim = await cachedSwr(
- caches.analytics as ServerCache>>,
+ caches.analytics as ServerCache<
+ Awaited>
+ >,
`${user.id}|summaries`,
() => computeSummariesSlice(user.id),
annotate,
@@ -95,7 +97,9 @@ export const GET = apiHandler(async (request?: Request) => {
// hero score; after a measurement sync the next mount serves the
// prior body instantly while one background recompute refreshes it.
const cachedBody = await cachedSwr(
- caches.analytics as ServerCache>>,
+ caches.analytics as ServerCache<
+ Awaited>
+ >,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
annotate,
@@ -596,7 +600,6 @@ async function computeSleepStageBreakdown(
// used by the weight-weekday + day-key helpers inside the old inline
// correlation builder.
-
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
// and the inline `computeUserHealthScore` body relocated to
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts
index 759ffc97f..6d62abc84 100644
--- a/src/app/api/auth/login/__tests__/route.test.ts
+++ b/src/app/api/auth/login/__tests__/route.test.ts
@@ -52,8 +52,7 @@ vi.mock("@/lib/auth/hmac", () => ({
// privacy contract is "the typed identifier never serialises into
// the row's body", so the mock must not embed the raw input.
hashToken: vi.fn(
- (raw: string) =>
- "aa".repeat(32) + (raw.length % 8).toString(16),
+ (raw: string) => "aa".repeat(32) + (raw.length % 8).toString(16),
),
}));
diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
index 10b591041..7fb0012ad 100644
--- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
+++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
},
- toJson: (v: T) => v,
+ toJson: (v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts
index 4f5322d44..d64f33bc5 100644
--- a/src/app/api/auth/me/modules/__tests__/route.test.ts
+++ b/src/app/api/auth/me/modules/__tests__/route.test.ts
@@ -87,12 +87,14 @@ function mkPatch(body: unknown): Request {
* (which writes `modulePreferencesJson`) is reflected by the post-update
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
*/
-function primeUser(over: {
- gender?: string | null;
- disableCoach?: boolean;
- modulePreferencesJson?: unknown;
- cycleTrackingEnabled?: boolean | null;
-} = {}) {
+function primeUser(
+ over: {
+ gender?: string | null;
+ disableCoach?: boolean;
+ modulePreferencesJson?: unknown;
+ cycleTrackingEnabled?: boolean | null;
+ } = {},
+) {
const row: {
gender: string | null;
disableCoach: boolean;
@@ -102,9 +104,8 @@ function primeUser(over: {
disableCoach: over.disableCoach ?? false,
modulePreferencesJson: over.modulePreferencesJson ?? null,
};
- vi.mocked(prisma.user.findUnique).mockImplementation(
- (() => Promise.resolve({ ...row })) as never,
- );
+ vi.mocked(prisma.user.findUnique).mockImplementation((() =>
+ Promise.resolve({ ...row })) as never);
vi.mocked(prisma.user.update).mockImplementation(((args: {
data?: { modulePreferencesJson?: unknown };
}) => {
@@ -213,7 +214,9 @@ describe("PATCH /api/auth/me/modules", () => {
// user previously disabled glucose; now disabling sleep too.
primeUser({ modulePreferencesJson: { glucose: false } });
- await (PATCH as (r: Request) => Promise)(mkPatch({ sleep: false }));
+ await (PATCH as (r: Request) => Promise)(
+ mkPatch({ sleep: false }),
+ );
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
@@ -225,7 +228,9 @@ describe("PATCH /api/auth/me/modules", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: { glucose: false } });
- await (PATCH as (r: Request) => Promise)(mkPatch({ glucose: true }));
+ await (PATCH as (r: Request) => Promise)(
+ mkPatch({ glucose: true }),
+ );
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
@@ -299,7 +304,9 @@ describe("PATCH /api/auth/me/modules", () => {
// junk in the stored row must not leak into the persisted merge.
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
- await (PATCH as (r: Request) => Promise)(mkPatch({ labs: false }));
+ await (PATCH as (r: Request) => Promise)(
+ mkPatch({ labs: false }),
+ );
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
diff --git a/src/app/api/auth/me/research-mode/__tests__/route.test.ts b/src/app/api/auth/me/research-mode/__tests__/route.test.ts
index 0f57c8316..705075cce 100644
--- a/src/app/api/auth/me/research-mode/__tests__/route.test.ts
+++ b/src/app/api/auth/me/research-mode/__tests__/route.test.ts
@@ -131,9 +131,7 @@ describe("GET /api/auth/me/research-mode", () => {
};
expect(env.data.enabled).toBe(true);
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
- expect(env.data.acknowledgedVersion).toBe(
- RESEARCH_MODE_DISCLAIMER_VERSION,
- );
+ expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
});
});
@@ -165,9 +163,7 @@ describe("POST /api/auth/me/research-mode", () => {
data: { enabled: boolean; acknowledgedVersion: string };
};
expect(env.data.enabled).toBe(true);
- expect(env.data.acknowledgedVersion).toBe(
- RESEARCH_MODE_DISCLAIMER_VERSION,
- );
+ expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/src/app/api/auth/me/research-mode/route.ts b/src/app/api/auth/me/research-mode/route.ts
index fc8372599..24053a640 100644
--- a/src/app/api/auth/me/research-mode/route.ts
+++ b/src/app/api/auth/me/research-mode/route.ts
@@ -171,7 +171,8 @@ export const POST = apiHandler(async (req: Request) => {
previous: previous ?? null,
next: {
...next,
- researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
+ researchModeAcknowledgedAt:
+ next.researchModeAcknowledgedAt.toISOString(),
},
},
});
diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts
index 8bd6b0f25..6e0d57680 100644
--- a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts
+++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts
@@ -131,7 +131,10 @@ describe("PATCH /api/auth/me/unit-preference", () => {
"user.unit-preference.update",
expect.objectContaining({
userId: "user-1",
- details: expect.objectContaining({ previous: "metric", next: "imperial" }),
+ details: expect.objectContaining({
+ previous: "metric",
+ next: "imperial",
+ }),
}),
);
});
diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts
index 8a5342ed3..bb3ae3a38 100644
--- a/src/app/api/auth/passkey/login-options/route.ts
+++ b/src/app/api/auth/passkey/login-options/route.ts
@@ -1,9 +1,6 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess } from "@/lib/api-response";
-import {
- checkAuthSurfaceRateLimit,
- rateLimitHeaders,
-} from "@/lib/rate-limit";
+import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
diff --git a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts
index dd8b27419..c2b1b1b8c 100644
--- a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts
+++ b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts
@@ -53,10 +53,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
-import {
- checkRateLimit,
- checkAuthSurfaceRateLimit,
-} from "@/lib/rate-limit";
+import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const FAKE_USER = {
id: "user-1",
diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts
index f7a128fdc..5f56f677e 100644
--- a/src/app/api/auth/password/__tests__/route.test.ts
+++ b/src/app/api/auth/password/__tests__/route.test.ts
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn().mockResolvedValue(true),
hashPassword: vi.fn().mockResolvedValue("hashed"),
- checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
+ checkPasswordStrength: vi
+ .fn()
+ .mockReturnValue({ isAcceptable: true, feedback: [] }),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -81,9 +83,7 @@ beforeEach(() => {
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Empty currentPassword + tooShort newPassword.
- const res = await POST(
- postReq({ currentPassword: "", newPassword: "x" }),
- );
+ const res = await POST(postReq({ currentPassword: "", newPassword: "x" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -102,9 +102,7 @@ describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces THREE simultaneous validation errors", async () => {
// Both empty + extra invalid type.
- const res = await POST(
- postReq({ currentPassword: "", newPassword: 123 }),
- );
+ const res = await POST(postReq({ currentPassword: "", newPassword: 123 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array };
diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts
index 5e1f3bfcd..c73f0a211 100644
--- a/src/app/api/auth/register/__tests__/route.test.ts
+++ b/src/app/api/auth/register/__tests__/route.test.ts
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/session", () => ({
vi.mock("@/lib/auth/password", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed"),
- checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
+ checkPasswordStrength: vi
+ .fn()
+ .mockReturnValue({ isAcceptable: true, feedback: [] }),
}));
vi.mock("@/lib/auth/audit", () => ({
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index 2f9910271..e97fcaebe 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -144,7 +144,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
} else {
annotate({
action: { name: "auth.register.invite_rejected" },
- meta: { reason: consumed.reason, registration_open: registrationEnabled },
+ meta: {
+ reason: consumed.reason,
+ registration_open: registrationEnabled,
+ },
});
if (!registrationEnabled) {
return apiError("Invalid or expired invite", 403);
diff --git a/src/app/api/bugreport/__tests__/route.test.ts b/src/app/api/bugreport/__tests__/route.test.ts
index 02e95dbe0..f1ef98855 100644
--- a/src/app/api/bugreport/__tests__/route.test.ts
+++ b/src/app/api/bugreport/__tests__/route.test.ts
@@ -89,9 +89,7 @@ describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
// Schema has only two knobs (description, screenshot). Pin contract
// on ≥ 2.
- const res = await POST(
- postReq({ description: 123, screenshot: 999 }),
- );
+ const res = await POST(postReq({ description: 123, screenshot: 999 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array };
diff --git a/src/app/api/c/[token]/unlock/__tests__/route.test.ts b/src/app/api/c/[token]/unlock/__tests__/route.test.ts
new file mode 100644
index 000000000..7c0828da3
--- /dev/null
+++ b/src/app/api/c/[token]/unlock/__tests__/route.test.ts
@@ -0,0 +1,150 @@
+/**
+ * v1.18.7 — public passphrase-gate verifier (POST /api/c/{token}/unlock).
+ *
+ * Asserts the load-bearing properties:
+ * - a correct passphrase sets a short-lived, token-scoped httpOnly cookie;
+ * - a wrong passphrase is rejected with a blunt 401, no cookie set;
+ * - an unknown / revoked / expired token (gate null) is the same blunt 401;
+ * - a link with no passphrase set cannot be unlocked;
+ * - the route is rate-limited BEFORE any compare.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
+
+const cookieSet = vi.fn();
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: cookieSet,
+ delete: () => {},
+ })),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {},
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
+ resolveShareGateState: vi.fn(),
+}));
+vi.mock("@/lib/clinician-share/passphrase", () => ({
+ verifyPassphrase: vi.fn(),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkAuthSurfaceRateLimit: vi.fn(),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+import { POST } from "../route";
+import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token";
+import { verifyPassphrase } from "@/lib/clinician-share/passphrase";
+import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
+
+const TOKEN = `hls_${"a".repeat(48)}`;
+const TOKEN_HASH = "f".repeat(64);
+
+function req(body: unknown): NextRequest {
+ return new NextRequest(`http://localhost/api/c/${TOKEN}/unlock`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+function call() {
+ return POST(req({ passphrase: "ABCD-EFGH-JKMN-PQRS" }), {
+ params: Promise.resolve({ token: TOKEN }),
+ });
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
+ vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 9,
+ resetAt: Date.now() + 60_000,
+ ip: "1.2.3.4",
+ });
+});
+
+describe("POST /api/c/[token]/unlock", () => {
+ it("sets a token-scoped httpOnly cookie on a correct passphrase", async () => {
+ vi.mocked(resolveShareGateState).mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: "deadbeef",
+ });
+ vi.mocked(verifyPassphrase).mockReturnValue(true);
+
+ const res = await call();
+ expect(res.status).toBe(200);
+
+ expect(cookieSet).toHaveBeenCalledTimes(1);
+ const [name, , opts] = cookieSet.mock.calls[0];
+ expect(name).toContain("hls_unlock_");
+ expect(opts.httpOnly).toBe(true);
+ expect(opts.sameSite).toBe("strict");
+ // Scoped to THIS token's view path only.
+ expect(opts.path).toBe(`/c/${TOKEN}`);
+ expect(opts.maxAge).toBeGreaterThan(0);
+ expect(opts.maxAge).toBeLessThanOrEqual(30 * 60);
+ });
+
+ it("rejects a wrong passphrase with a blunt 401, no cookie", async () => {
+ vi.mocked(resolveShareGateState).mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: "deadbeef",
+ });
+ vi.mocked(verifyPassphrase).mockReturnValue(false);
+
+ const res = await call();
+ expect(res.status).toBe(401);
+ expect(cookieSet).not.toHaveBeenCalled();
+ });
+
+ it("rejects an unknown / revoked / expired token (gate null) with 401", async () => {
+ vi.mocked(resolveShareGateState).mockResolvedValue(null);
+
+ const res = await call();
+ expect(res.status).toBe(401);
+ expect(verifyPassphrase).not.toHaveBeenCalled();
+ expect(cookieSet).not.toHaveBeenCalled();
+ });
+
+ it("cannot unlock a link with no passphrase set", async () => {
+ vi.mocked(resolveShareGateState).mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: null,
+ });
+ // verifyPassphrase returns false for a null stored hash.
+ vi.mocked(verifyPassphrase).mockReturnValue(false);
+
+ const res = await call();
+ expect(res.status).toBe(401);
+ expect(cookieSet).not.toHaveBeenCalled();
+ });
+
+ it("is rate-limited before any compare", async () => {
+ vi.mocked(resolveShareGateState).mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: "deadbeef",
+ });
+ vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 60_000,
+ ip: "1.2.3.4",
+ });
+
+ const res = await call();
+ expect(res.status).toBe(429);
+ expect(verifyPassphrase).not.toHaveBeenCalled();
+ expect(cookieSet).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/c/[token]/unlock/route.ts b/src/app/api/c/[token]/unlock/route.ts
new file mode 100644
index 000000000..9c602abe0
--- /dev/null
+++ b/src/app/api/c/[token]/unlock/route.ts
@@ -0,0 +1,107 @@
+/**
+ * v1.18.7 — POST /api/c/{token}/unlock — public passphrase gate verifier.
+ *
+ * The second factor for a passphrase-protected clinician share link. It is an
+ * anonymous, no-session surface (like the `/c/` view itself): the raw
+ * path token plus the submitted passphrase are the only credentials. On a
+ * correct passphrase it mints a SHORT-LIVED, httpOnly, SameSite=Strict, Secure
+ * cookie SCOPED to this token's view path so the page renders the record; a
+ * cookie minted for one token never unlocks another.
+ *
+ * Brute-force protection is mandatory here — the passphrase is the only thing
+ * standing between a leaked URL and the record — so the route is rate-limited
+ * by `share-unlock::` BEFORE any compare, using the
+ * anonymous-surface limiter so a broken trust chain collapses to one tight
+ * bucket rather than a free-for-all.
+ *
+ * Failure is blunt: a malformed token, an unknown/revoked/expired link, a link
+ * with no passphrase set, or a wrong passphrase all answer the SAME flat error.
+ * `no-store` is preserved end to end. Nothing here leaks why a request failed.
+ */
+import { cookies } from "next/headers";
+import { NextRequest } from "next/server";
+
+import { apiHandler } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import {
+ apiError,
+ apiSuccess,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
+import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
+import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token";
+import { verifyPassphrase } from "@/lib/clinician-share/passphrase";
+import {
+ mintUnlockValue,
+ unlockCookieName,
+ UNLOCK_TTL_SECONDS,
+} from "@/lib/clinician-share/unlock-cookie";
+import { unlockShareLinkSchema } from "@/lib/validations/clinician-share-link";
+
+/** One blunt failure for every class — a probe distinguishes nothing. */
+function reject() {
+ return apiError("Invalid passphrase", 401);
+}
+
+export const POST = apiHandler(
+ async (request: NextRequest, ctx: { params: Promise<{ token: string }> }) => {
+ const { token } = await ctx.params;
+ annotate({ action: { name: "share-link.unlock" } });
+
+ // Resolve the live-gate state first (no counter bump). A malformed /
+ // unknown / revoked / expired token is `null` → the same blunt reject.
+ const gate = await resolveShareGateState(token);
+
+ // Rate-limit BEFORE any compare. Key on the resolved tokenHash when we
+ // have one (so the bucket is per-link), else a fixed sentinel so probing
+ // unknown tokens still shares one tight bucket. The anonymous-surface
+ // limiter collapses a broken trust chain to a single bucket.
+ const rlKey = gate ? gate.tokenHash : "unknown";
+ const rl = await checkAuthSurfaceRateLimit(
+ request,
+ `share-unlock:${rlKey}`,
+ 10,
+ 10 * 60 * 1000,
+ );
+ if (!rl.allowed) {
+ return apiError("Too many attempts", 429);
+ }
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 4 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = unlockShareLinkSchema.safeParse(body);
+ if (!parsed.success) return returnAllZodIssues(parsed.error);
+
+ // A token with no live gate, or one carrying no passphrase, cannot be
+ // unlocked. `verifyPassphrase` is constant-time and returns false for a
+ // null stored hash, so the two branches are indistinguishable in timing.
+ if (!gate) return reject();
+ if (!verifyPassphrase(parsed.data.passphrase, gate.passphraseHash)) {
+ annotate({ meta: { unlock: "rejected" } });
+ return reject();
+ }
+
+ // Correct passphrase — mint the short-lived, token-scoped unlock cookie.
+ const cookieStore = await cookies();
+ cookieStore.set(
+ unlockCookieName(gate.tokenHash),
+ mintUnlockValue(gate.tokenHash),
+ {
+ httpOnly: true,
+ secure: shouldEmitSecureCookie(),
+ sameSite: "strict",
+ // Scope to THIS token's view path only — the cookie unlocks nothing else.
+ path: `/c/${token}`,
+ maxAge: UNLOCK_TTL_SECONDS,
+ },
+ );
+
+ annotate({ meta: { unlock: "granted" } });
+ return apiSuccess({ unlocked: true });
+ },
+);
diff --git a/src/app/api/coach/about-me/questions/route.ts b/src/app/api/coach/about-me/questions/route.ts
index 58841bd91..7278d8014 100644
--- a/src/app/api/coach/about-me/questions/route.ts
+++ b/src/app/api/coach/about-me/questions/route.ts
@@ -13,11 +13,7 @@
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import {
- apiSuccess,
- returnAllZodIssues,
- safeJson,
-} from "@/lib/api-response";
+import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
getPendingQuestionsForUser,
diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts
index 170130bf5..de7a7f1ed 100644
--- a/src/app/api/coach/reminder-suggestions/route.ts
+++ b/src/app/api/coach/reminder-suggestions/route.ts
@@ -38,10 +38,7 @@ import {
DEFAULT_REMINDER_SUGGESTION_PREFS,
} from "@/lib/validations/coach-prefs";
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
-import {
- CADENCE_CATALOG,
- isCadenceId,
-} from "@/lib/ai/coach/suggest-reminder";
+import { CADENCE_CATALOG, isCadenceId } from "@/lib/ai/coach/suggest-reminder";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
diff --git a/src/app/api/consent/ai/__tests__/route.test.ts b/src/app/api/consent/ai/__tests__/route.test.ts
index 8c24521b2..cfb9c52c3 100644
--- a/src/app/api/consent/ai/__tests__/route.test.ts
+++ b/src/app/api/consent/ai/__tests__/route.test.ts
@@ -36,7 +36,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
-
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
diff --git a/src/app/api/consent/ai/latest/__tests__/route.test.ts b/src/app/api/consent/ai/latest/__tests__/route.test.ts
index 4431592ad..b21a15eeb 100644
--- a/src/app/api/consent/ai/latest/__tests__/route.test.ts
+++ b/src/app/api/consent/ai/latest/__tests__/route.test.ts
@@ -440,9 +440,7 @@ describe("append-only invariant", () => {
it("GET surfaces multi-issue validation errors (≥ 1)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
- new NextRequest(
- "http://localhost/api/consent/ai/latest?kind=junk",
- ),
+ new NextRequest("http://localhost/api/consent/ai/latest?kind=junk"),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
@@ -467,10 +465,9 @@ describe("append-only invariant", () => {
it("DELETE surfaces multi-issue validation errors (≥ 1)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await DELETE(
- new NextRequest(
- "http://localhost/api/consent/ai/latest?kind=junk",
- { method: "DELETE" },
- ),
+ new NextRequest("http://localhost/api/consent/ai/latest?kind=junk", {
+ method: "DELETE",
+ }),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts
index 4a2f8a8cd..a316f5adb 100644
--- a/src/app/api/consent/ai/latest/route.ts
+++ b/src/app/api/consent/ai/latest/route.ts
@@ -16,11 +16,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import {
- apiError,
- apiSuccess,
- returnAllZodIssues,
-} from "@/lib/api-response";
+import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -76,7 +72,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Map each enum value to either the serialised row or `null` so the
// client always sees the full keyspace and never has to guess
// whether a missing key means "not granted" or "schema gap".
- const result: Record | null> = {
+ const result: Record<
+ ConsentKind,
+ ReturnType | null
+ > = {
ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null,
ai_insights_only: byKind.ai_insights_only
? serialiseReceipt(byKind.ai_insights_only)
diff --git a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts
index 5c6e1f287..b093d3a64 100644
--- a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts
+++ b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts
@@ -53,7 +53,12 @@ import { apiError } from "@/lib/api-response";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "t", role: "USER" as const, gender: "FEMALE" },
+ user: {
+ id: "user-1",
+ username: "t",
+ role: "USER" as const,
+ gender: "FEMALE",
+ },
};
function jsonReq(url: string, method: string, body: unknown) {
@@ -92,7 +97,10 @@ describe("GET /api/cycle/symptoms/custom", () => {
describe("POST /api/cycle/symptoms/custom", () => {
it("creates a custom symptom, stores the label encrypted, returns it", async () => {
db.cycleSymptom.count.mockResolvedValue(2);
- db.cycleSymptom.create.mockResolvedValue({ key: "custom:abc", icon: "Brain" });
+ db.cycleSymptom.create.mockResolvedValue({
+ key: "custom:abc",
+ icon: "Brain",
+ });
const res = await POST(
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
label: "Dizziness",
@@ -122,7 +130,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
it("rejects over the per-user cap with 422", async () => {
db.cycleSymptom.count.mockResolvedValue(50);
const res = await POST(
- jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
+ jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
+ label: "X",
+ }),
);
expect(res.status).toBe(422);
expect(db.cycleSymptom.create).not.toHaveBeenCalled();
@@ -134,7 +144,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
response: apiError("Cycle tracking is not enabled", 403),
});
const res = await POST(
- jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
+ jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
+ label: "X",
+ }),
);
expect(res.status).toBe(403);
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
@@ -170,9 +182,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: false });
db.cycleSymptom.count.mockResolvedValue(50);
const res = await PATCH(
- jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
- isActive: true,
- }),
+ jsonReq(
+ "http://localhost/api/cycle/symptoms/custom/custom:abc",
+ "PATCH",
+ {
+ isActive: true,
+ },
+ ),
params("custom:abc"),
);
expect(res.status).toBe(422);
@@ -188,9 +204,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
labelEncrypted: "enc:Renamed",
});
const res = await PATCH(
- jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
- label: "Renamed",
- }),
+ jsonReq(
+ "http://localhost/api/cycle/symptoms/custom/custom:abc",
+ "PATCH",
+ {
+ label: "Renamed",
+ },
+ ),
params("custom:abc"),
);
expect(res.status).toBe(200);
@@ -224,6 +244,8 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
params("custom:abc"),
);
expect(purge.status).toBe(200);
- expect(db.cycleSymptom.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
+ expect(db.cycleSymptom.delete).toHaveBeenCalledWith({
+ where: { id: "id1" },
+ });
});
});
diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts
index bade4ac73..5d5654052 100644
--- a/src/app/api/dashboard/snapshot/route.ts
+++ b/src/app/api/dashboard/snapshot/route.ts
@@ -107,7 +107,9 @@ export const GET = apiHandler(async () => {
// Only surface timings on the cache-miss path (the hit path skips the
// whole builder and leaves `timings` empty).
if (Object.keys(timings).length > 0) {
- annotate({ meta: { ...timings, snapshot_extras_present: body.extras !== null } });
+ annotate({
+ meta: { ...timings, snapshot_extras_present: body.extras !== null },
+ });
}
const response = apiSuccess(body);
diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts
index 3b90d33f4..62c5997eb 100644
--- a/src/app/api/dashboard/summary/__tests__/route.test.ts
+++ b/src/app/api/dashboard/summary/__tests__/route.test.ts
@@ -325,7 +325,10 @@ describe("GET /api/dashboard/summary", () => {
};
};
const bp = body.data.metrics.find((m) => m.id === "bp");
- expect(bp, "bp tile must be emitted for stale-but-valid history").toBeDefined();
+ expect(
+ bp,
+ "bp tile must be emitted for stale-but-valid history",
+ ).toBeDefined();
expect(bp?.latestValue).toBe(132);
expect(bp?.secondaryValue).toBe(84);
expect(bp?.allTimeCount).toBe(28);
@@ -421,13 +424,55 @@ describe("GET /api/dashboard/summary", () => {
{ type: "PULSE", value: 71, measured_at: days(30) },
] as never)
.mockResolvedValueOnce([
- { type: "PULSE", bucket_start: days(36), mean: 68, count: 3, sum_value: null },
- { type: "PULSE", bucket_start: days(35), mean: 70, count: 3, sum_value: null },
- { type: "PULSE", bucket_start: days(34), mean: 69, count: 3, sum_value: null },
- { type: "PULSE", bucket_start: days(33), mean: 71, count: 3, sum_value: null },
- { type: "PULSE", bucket_start: days(32), mean: 73, count: 3, sum_value: null },
- { type: "PULSE", bucket_start: days(31), mean: 72, count: 3, sum_value: null },
- { type: "PULSE", bucket_start: days(30), mean: 71, count: 3, sum_value: null },
+ {
+ type: "PULSE",
+ bucket_start: days(36),
+ mean: 68,
+ count: 3,
+ sum_value: null,
+ },
+ {
+ type: "PULSE",
+ bucket_start: days(35),
+ mean: 70,
+ count: 3,
+ sum_value: null,
+ },
+ {
+ type: "PULSE",
+ bucket_start: days(34),
+ mean: 69,
+ count: 3,
+ sum_value: null,
+ },
+ {
+ type: "PULSE",
+ bucket_start: days(33),
+ mean: 71,
+ count: 3,
+ sum_value: null,
+ },
+ {
+ type: "PULSE",
+ bucket_start: days(32),
+ mean: 73,
+ count: 3,
+ sum_value: null,
+ },
+ {
+ type: "PULSE",
+ bucket_start: days(31),
+ mean: 72,
+ count: 3,
+ sum_value: null,
+ },
+ {
+ type: "PULSE",
+ bucket_start: days(30),
+ mean: 71,
+ count: 3,
+ sum_value: null,
+ },
] as never)
.mockResolvedValueOnce([] as never);
@@ -498,10 +543,26 @@ describe("GET /api/dashboard/summary", () => {
] as never);
// Raw per-stage rows for the night (the sleep findMany read).
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
- { value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
- { value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
- { value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
- { value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
+ {
+ value: 480,
+ measuredAt: new Date("2026-06-03T23:00:00.000Z"),
+ sleepStage: "IN_BED",
+ },
+ {
+ value: 240,
+ measuredAt: new Date("2026-06-04T00:00:00.000Z"),
+ sleepStage: "CORE",
+ },
+ {
+ value: 90,
+ measuredAt: new Date("2026-06-04T02:00:00.000Z"),
+ sleepStage: "DEEP",
+ },
+ {
+ value: 80,
+ measuredAt: new Date("2026-06-04T04:00:00.000Z"),
+ sleepStage: "REM",
+ },
{ value: 20, measuredAt: wake, sleepStage: "AWAKE" },
] as never);
const res = await callGet(makeReq());
diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts
index d9241ce6a..4ef07e50d 100644
--- a/src/app/api/dashboard/summary/route.ts
+++ b/src/app/api/dashboard/summary/route.ts
@@ -334,9 +334,9 @@ export const GET = apiHandler(async () => {
// existing `invalidateUserMeasurements` + the v1.4.38-extended
// `invalidateUserMedications` hooks.
const body = await cached(
- caches.analytics as ServerCache
- >>,
+ caches.analytics as ServerCache<
+ Awaited>
+ >,
`${user.id}|dashboard-summary`,
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
annotate,
@@ -350,9 +350,11 @@ interface SummaryBuilderContext {
locale: Locale;
}
-function buildContext(
- user: { displayName: string | null; username: string; locale: string | null },
-): SummaryBuilderContext {
+function buildContext(user: {
+ displayName: string | null;
+ username: string;
+ locale: string | null;
+}): SummaryBuilderContext {
const greetingName = user.displayName ?? user.username;
// v1.5.x — accept every shipped locale (de / en / es / fr / it / pl)
// so the greeting + streak label resolve against the user's
@@ -395,7 +397,10 @@ async function buildDashboardSummary(
// perf-verify can attribute a regression to a specific sub-query
// without re-instrumenting the route.
const timings: Record = {};
- const time = async (label: string, builder: () => Promise): Promise => {
+ const time = async (
+ label: string,
+ builder: () => Promise,
+ ): Promise => {
const t0 = Date.now();
const result = await builder();
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
@@ -424,8 +429,10 @@ async function buildDashboardSummary(
measurementStreakDays,
sleepStageRows,
] = await Promise.all([
- time("latestEver", () =>
- prisma.$queryRaw`
+ time(
+ "latestEver",
+ () =>
+ prisma.$queryRaw`
SELECT DISTINCT ON (m."type")
m."type" AS type,
m."value"::double precision AS value,
@@ -436,8 +443,10 @@ async function buildDashboardSummary(
ORDER BY m."type", m."measured_at" DESC
`,
),
- time("sparkline", () =>
- prisma.$queryRaw`
+ time(
+ "sparkline",
+ () =>
+ prisma.$queryRaw`
SELECT type, bucket_start, mean, count, sum_value
FROM (
SELECT
@@ -497,8 +506,10 @@ async function buildDashboardSummary(
// conversion is folded server-side so the day boundary matches
// `userDayKey`'s output (it formats the timestamp in the user's
// tz before slicing YYYY-MM-DD).
- time("streakDays", () =>
- prisma.$queryRaw`
+ time(
+ "streakDays",
+ () =>
+ prisma.$queryRaw`
SELECT DISTINCT
to_char(
(m."measured_at" AT TIME ZONE ${userTz}),
diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts
index 775c2d510..656f39d5c 100644
--- a/src/app/api/dashboard/widgets/__tests__/route.test.ts
+++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts
@@ -37,8 +37,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
- const actual =
- await importOriginal();
+ const actual = await importOriginal();
return {
...actual,
annotate: vi.fn(),
@@ -188,11 +187,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "dashboard.widgets.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "dashboard.widgets.validation-failed",
+ );
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record }).meta!;
@@ -206,7 +207,7 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
- expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
+ expect(meta.received_shape_excerpt as string).toContain('"version":2');
// zod_issues is the same sanitised array surfaced under details.issues.
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
@@ -230,11 +231,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
const res = await callPut(makeReq({ version: 99, widgets }));
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "dashboard.widgets.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "dashboard.widgets.validation-failed",
+ );
const meta = (annotated![0] as { meta?: Record }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
@@ -268,11 +271,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "dashboard.widgets.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "dashboard.widgets.validation-failed",
+ );
const meta = (annotated![0] as { meta?: Record }).meta!;
const excerpt = meta.received_shape_excerpt as string;
@@ -337,7 +342,8 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
// The persisted blob never carries the unknown id.
expect(prisma.user.update).toHaveBeenCalledTimes(1);
- const updateArg = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
+ const updateArg = vi.mocked(prisma.user.update).mock
+ .calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
};
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
@@ -372,11 +378,13 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
// annotation fires regardless, before the Zod parse.
expect(res.status).toBe(422);
- const dropAnnotate = vi.mocked(annotate).mock.calls.find(
- (c) =>
- (c[0] as { action?: { name?: string } }).action?.name ===
- "dashboard.widgets.unknown-id-dropped",
- );
+ const dropAnnotate = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (c) =>
+ (c[0] as { action?: { name?: string } }).action?.name ===
+ "dashboard.widgets.unknown-id-dropped",
+ );
expect(dropAnnotate, "unknown-id-dropped annotate call").toBeTruthy();
const meta = (dropAnnotate![0] as { meta?: Record }).meta!;
expect(meta.dropped_count).toBe(200);
@@ -542,11 +550,13 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
expect(res.status).toBe(200);
// No id was dropped — the unknown-id annotation must NOT fire.
- const dropAnnotate = vi.mocked(annotate).mock.calls.find(
- (c) =>
- (c[0] as { action?: { name?: string } }).action?.name ===
- "dashboard.widgets.unknown-id-dropped",
- );
+ const dropAnnotate = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (c) =>
+ (c[0] as { action?: { name?: string } }).action?.name ===
+ "dashboard.widgets.unknown-id-dropped",
+ );
expect(dropAnnotate).toBeUndefined();
expect(prisma.user.update).toHaveBeenCalledTimes(1);
@@ -557,7 +567,9 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
(w) => w.id,
);
- expect(persistedIds.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
+ expect(persistedIds.sort()).toEqual(
+ [...DASHBOARD_WIDGET_CATALOGUE_IDS].sort(),
+ );
// The response body echoes the full persisted layout.
const body = (await res.json()) as {
data: { widgets: Array<{ id: string }> };
diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts
index 7795a07b2..591eae953 100644
--- a/src/app/api/dashboard/widgets/route.ts
+++ b/src/app/api/dashboard/widgets/route.ts
@@ -271,7 +271,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
- if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) {
+ if (
+ mergedChartOverlayPrefs === undefined ||
+ mergedHeroVisible === undefined
+ ) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts
index 243248566..a34d1c03b 100644
--- a/src/app/api/export/__tests__/soft-delete-filter.test.ts
+++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts
@@ -104,7 +104,9 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null } as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ heightCm: null,
+ } as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -140,9 +142,7 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
it("/api/gamification/achievements scopes the measurement read to deletedAt: null", async () => {
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
- const { GET } = await import(
- "../../gamification/achievements/route"
- );
+ const { GET } = await import("../../gamification/achievements/route");
await GET(mkReq("http://localhost/api/gamification/achievements"));
const calls = vi.mocked(prisma.measurement.findMany).mock.calls;
// The achievement aggregator reads recent measurements via
diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts
index a236ec3ca..f0cea3ba2 100644
--- a/src/app/api/export/health-record/route.ts
+++ b/src/app/api/export/health-record/route.ts
@@ -104,9 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const locale: Locale =
selection.locale ??
cookieLocale ??
- parseLocaleFromAcceptLanguage(
- request.headers.get("accept-language") ?? "",
- );
+ parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
// German-region locale. The WHO ATC coding is unaffected either way.
@@ -207,12 +205,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
- const bundle = buildFhirDocumentBundle(
- data,
- { insuranceNumber },
- undefined,
- { germanAtc },
- );
+ const bundle = buildFhirDocumentBundle(data, { insuranceNumber }, undefined, {
+ germanAtc,
+ });
const readme = t("doctorReport.packageReadme");
const zipped = zipSync(
{
diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts
index 32f3a86cb..f870ef7cf 100644
--- a/src/app/api/export/measurements/route.ts
+++ b/src/app/api/export/measurements/route.ts
@@ -46,8 +46,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
? "raw"
: "night";
- const [measurements, userTz, sourcePriorityJson, profile] =
- await Promise.all([
+ const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
+ [
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
@@ -60,7 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: { id: user.id },
select: { glucoseUnit: true },
}),
- ]);
+ ],
+ );
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
const csv = toCSV(
diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts
index bc8fbf169..78b000b4e 100644
--- a/src/app/api/fhir/__tests__/route.test.ts
+++ b/src/app/api/fhir/__tests__/route.test.ts
@@ -17,8 +17,7 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
- const actual =
- await importOriginal();
+ const actual = await importOriginal();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("@/lib/fhir/resources", () => ({
diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts
index 44160d2ec..bb694f3ed 100644
--- a/src/app/api/fitbit/credentials/__tests__/route.test.ts
+++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts
@@ -61,8 +61,9 @@ describe("/api/fitbit/credentials", () => {
(
prisma.fitbitConnection.delete as ReturnType
).mockResolvedValue({});
- const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)())
- .data as { deleted: boolean };
+ 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" },
diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
index 9d88c4648..902ce62a5 100644
--- a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
@@ -102,7 +102,7 @@ describe("GET /api/gamification/achievements — achievements module gate", () =
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
- response: apiError("Module \"achievements\" is not enabled", 403, {
+ response: apiError('Module "achievements" is not enabled', 403, {
errorCode: "module.disabled",
module: "achievements",
}),
@@ -216,8 +216,7 @@ describe("GET /api/gamification/achievements — per-module badge skipping", ()
// A mood badge would unlock at >=1 entry; with mood OFF the createMany
// must carry no mood achievement row.
await badgesFor({ mood: false });
- const createCalls = vi.mocked(prisma.userAchievement.createMany).mock
- .calls;
+ const createCalls = vi.mocked(prisma.userAchievement.createMany).mock.calls;
for (const [arg] of createCalls) {
const rows = (arg as { data: Array<{ achievementId: string }> }).data;
expect(rows.some((r) => r.achievementId.includes("mood"))).toBe(false);
diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts
index e8f38d487..f835587ad 100644
--- a/src/app/api/illness/episodes/[id]/resolve/route.ts
+++ b/src/app/api/illness/episodes/[id]/resolve/route.ts
@@ -44,7 +44,11 @@ export const PATCH = apiHandler(
onsetAt: true,
},
});
- if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
+ if (
+ !existing ||
+ existing.userId !== user.id ||
+ existing.deletedAt !== null
+ ) {
return apiError("Episode not found", 404);
}
if (existing.lifecycle === "CHRONIC_ONGOING") {
diff --git a/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts b/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts
index a58531548..b7ad8a4e7 100644
--- a/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts
+++ b/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts
@@ -75,10 +75,9 @@ function liveRow(overrides: Record = {}) {
}
function req(): NextRequest {
- return new NextRequest(
- "http://localhost/api/illness/episodes/ep-1/restore",
- { method: "POST" },
- );
+ return new NextRequest("http://localhost/api/illness/episodes/ep-1/restore", {
+ method: "POST",
+ });
}
beforeEach(() => {
diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts
index 3bf355ca8..abeaa82e5 100644
--- a/src/app/api/illness/episodes/[id]/route.ts
+++ b/src/app/api/illness/episodes/[id]/route.ts
@@ -71,7 +71,11 @@ export const PATCH = apiHandler(
resolvedAt: true,
},
});
- if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
+ if (
+ !existing ||
+ existing.userId !== user.id ||
+ existing.deletedAt !== null
+ ) {
return apiError("Episode not found", 404);
}
diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts
index 938ebf3b4..1723f5f94 100644
--- a/src/app/api/illness/insights/route.ts
+++ b/src/app/api/illness/insights/route.ts
@@ -41,7 +41,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
const parsed = illnessInsightsQuerySchema.safeParse({
- windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined,
+ windowDays:
+ new URL(request.url).searchParams.get("windowDays") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -79,7 +80,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
limit(async (): Promise<[string, number | null]> => {
const derived = await computeEpisodeCorrelation(
user.id,
- { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
+ {
+ id: r.id,
+ onsetAt: r.onsetAt,
+ resolvedAt: r.resolvedAt,
+ lifecycle: r.lifecycle,
+ },
tz,
now,
);
diff --git a/src/app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts b/src/app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts
index 08fda943d..b0435ae42 100644
--- a/src/app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts
+++ b/src/app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts
@@ -63,8 +63,13 @@ function makeRow(overrides: Partial> = {}) {
exportedAt: null,
startedAt: NOW,
completedAt: null,
- progress: { currentPhase: "parsing", recordsRead: 100, rowsUpserted: 0,
- percent: null, elapsedMs: 1500 },
+ progress: {
+ currentPhase: "parsing",
+ recordsRead: 100,
+ rowsUpserted: 0,
+ percent: null,
+ elapsedMs: 1500,
+ },
result: null,
...overrides,
};
@@ -78,10 +83,15 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
it("returns the row envelope to the owning user", async () => {
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(makeRow());
- const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export/ij-1/status",
+ );
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(200);
- const body = (await res.json()) as { data: { status: string }; error: null };
+ const body = (await res.json()) as {
+ data: { status: string };
+ error: null;
+ };
expect(body.error).toBeNull();
expect(body.data.status).toBe("parsing");
});
@@ -89,7 +99,9 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
it("returns 404 to a non-owner non-admin user", async () => {
vi.mocked(getSession).mockResolvedValue(OTHER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(makeRow());
- const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export/ij-1/status",
+ );
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(404);
});
@@ -99,7 +111,9 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
mocks.importJobFindUnique.mockResolvedValue(
makeRow({ triggeredByAdminId: "admin-1" }),
);
- const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export/ij-1/status",
+ );
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(200);
});
@@ -107,14 +121,18 @@ describe("GET /api/import/apple-health-export/[jobId]/status", () => {
it("returns 404 when the job row is missing", async () => {
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(null);
- const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export/ij-1/status",
+ );
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(404);
});
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
- const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export/ij-1/status",
+ );
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(401);
});
diff --git a/src/app/api/import/apple-health-export/__tests__/route.test.ts b/src/app/api/import/apple-health-export/__tests__/route.test.ts
index fa4be13bf..226922c24 100644
--- a/src/app/api/import/apple-health-export/__tests__/route.test.ts
+++ b/src/app/api/import/apple-health-export/__tests__/route.test.ts
@@ -74,11 +74,14 @@ beforeEach(() => {
describe("POST /api/import/apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
- const req = new NextRequest("http://localhost/api/import/apple-health-export", {
- method: "POST",
- headers: { "content-type": "multipart/form-data; boundary=---x" },
- body: "stub",
- });
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export",
+ {
+ method: "POST",
+ headers: { "content-type": "multipart/form-data; boundary=---x" },
+ body: "stub",
+ },
+ );
const res = await POST(req);
expect(res.status).toBe(401);
});
@@ -89,11 +92,14 @@ describe("POST /api/import/apple-health-export", () => {
"content-type": "multipart/form-data; boundary=---x",
"content-length": String(2 * 1024 * 1024 * 1024),
});
- const req = new NextRequest("http://localhost/api/import/apple-health-export", {
- method: "POST",
- headers,
- body: "stub",
- });
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export",
+ {
+ method: "POST",
+ headers,
+ body: "stub",
+ },
+ );
const res = await POST(req);
expect(res.status).toBe(413);
});
@@ -101,11 +107,14 @@ describe("POST /api/import/apple-health-export", () => {
it("returns 429 when the rate-limit window is exhausted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
- const req = new NextRequest("http://localhost/api/import/apple-health-export", {
- method: "POST",
- headers: { "content-type": "multipart/form-data; boundary=---x" },
- body: "stub",
- });
+ const req = new NextRequest(
+ "http://localhost/api/import/apple-health-export",
+ {
+ method: "POST",
+ headers: { "content-type": "multipart/form-data; boundary=---x" },
+ body: "stub",
+ },
+ );
const res = await POST(req);
expect(res.status).toBe(429);
});
diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts
index a522a3a08..2acd36808 100644
--- a/src/app/api/import/csv/__tests__/route.test.ts
+++ b/src/app/api/import/csv/__tests__/route.test.ts
@@ -174,9 +174,7 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
);
const res = await POST(
- csvRequest(
- [HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n"),
- ),
+ csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(0);
diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts
index f8c7f1b56..a645495b0 100644
--- a/src/app/api/import/csv/route.ts
+++ b/src/app/api/import/csv/route.ts
@@ -125,7 +125,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
unit: m.unit,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
- glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
+ glucoseContext:
+ (m.glucoseContext as GlucoseContext | undefined) ?? null,
},
create: {
userId,
@@ -136,7 +137,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
externalId: m.externalId,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
- glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
+ glucoseContext:
+ (m.glucoseContext as GlucoseContext | undefined) ?? null,
},
select: { createdAt: true, updatedAt: true },
});
@@ -159,7 +161,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
source: "IMPORT",
measuredAt: m.measuredAt,
notes: m.notes ?? null,
- glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
+ glucoseContext:
+ (m.glucoseContext as GlucoseContext | undefined) ?? null,
},
});
inserted++;
@@ -232,7 +235,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const outcome = writeOutcome.get(r.line);
if (outcome === "duplicate") {
- return { line: r.line, status: "skipped" as const, reason: "duplicate" };
+ return {
+ line: r.line,
+ status: "skipped" as const,
+ reason: "duplicate",
+ };
}
if (outcome === "updated") {
return { line: r.line, status: "updated" as const };
diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts
index d33c3457a..1b79f4226 100644
--- a/src/app/api/ingest/medication/__tests__/route.test.ts
+++ b/src/app/api/ingest/medication/__tests__/route.test.ts
@@ -99,9 +99,7 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
// `medicationName=""` (min-1 violation) + `takenAt="not-iso"`
// forces two issues. idempotencyKey is omitted so we also catch
// the required-field check.
- const res = await POST(
- postReq({ medicationName: "", takenAt: "not-iso" }),
- );
+ const res = await POST(postReq({ medicationName: "", takenAt: "not-iso" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -311,9 +309,8 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
// ── v1.16.10 — inventory consumption seam ───────────────────────────
it("consumes inventory exactly once on the landed row", async () => {
- const { consumeForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake } =
+ await import("@/lib/medications/inventory/consumption");
wireHappyPath();
const res = await POST(
postReq({
@@ -334,9 +331,8 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
});
it("an idempotency replay returns the original row without consuming", async () => {
- const { consumeForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake } =
+ await import("@/lib/medications/inventory/consumption");
wireHappyPath();
// The replay probe finds the original event.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts
index 178b312e0..3b26c4286 100644
--- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts
+++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts
@@ -57,14 +57,17 @@ import { apiError } from "@/lib/api-response";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
+ user: {
+ id: "user-1",
+ username: "testuser",
+ role: "USER" as const,
+ locale: "en",
+ },
};
const callGet = GET as unknown as (req: NextRequest) => Promise;
function makeReq(): NextRequest {
- return new NextRequest(
- "http://localhost/api/insights/blood-pressure-status",
- );
+ return new NextRequest("http://localhost/api/insights/blood-pressure-status");
}
beforeEach(() => {
diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts
index 9ab9c6793..1212d20e0 100644
--- a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts
+++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts
@@ -70,7 +70,10 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
fetchConversationWithMessages: vi.fn(),
- listConversations: vi.fn(async () => ({ conversations: [], nextCursor: null })),
+ listConversations: vi.fn(async () => ({
+ conversations: [],
+ nextCursor: null,
+ })),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
@@ -82,12 +85,16 @@ vi.mock("@/lib/ai/coach/budget", () => ({
recordSpend: vi.fn(),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
-vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn() }));
+vi.mock("@/lib/ai/coach/system-prompt", () => ({
+ getCoachSystemPrompt: vi.fn(),
+}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
-vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel: vi.fn() }));
+vi.mock("@/lib/ai/coach/keyvalues", () => ({
+ parseKeyValuesSentinel: vi.fn(),
+}));
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 93311440c..88d04749b 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -46,6 +46,7 @@ import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import type { CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import {
coachChatRequestSchema,
@@ -61,8 +62,8 @@ import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import {
buildDateKey,
- enforceBudget,
- recordSpend,
+ reserveBudget,
+ reconcileSpend,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
@@ -214,7 +215,10 @@ async function handleChatRequest(request: NextRequest): Promise {
return apiError("Too many Coach requests, please wait a moment", 429);
}
- await enforceBudget(userId);
+ // v1.18.7 (SENIOR-DEV HIGH) — the daily token cap is enforced atomically
+ // by reserving budget BEFORE the provider call (see the reservation right
+ // before `runRawCompletionWithFallback`), not by a read-then-write here.
+ // The old read-before-call gate let concurrent requests all pass the cap.
const locale = await resolveServerLocale({
request,
@@ -430,6 +434,27 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
await assertConsentForChain({ userId, chain, surface: "coach" });
+ // v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
+ // the provider call. The reservation increments the day's total by the
+ // per-call ceiling (`maxTokens`) in one upsert and returns the new total;
+ // concurrent requests serialise on the row so they cannot all pass the cap.
+ // Over-cap → 429 refusal frame, reservation already refunded. The actual
+ // token count is reconciled against this reservation after the call,
+ // including on empty / sentinel replies (their tokens were still burned).
+ const reqDateKey = buildDateKey();
+ const reservation = await reserveBudget(
+ userId,
+ AI_BUDGETS.coach.maxTokens,
+ reqDateKey,
+ );
+ if (!reservation.allowed) {
+ annotate({
+ action: { name: "coach.budget.exceeded" },
+ meta: { totalAfter: reservation.totalAfter },
+ });
+ return streamProviderError({ code: "coach.budget.exceeded" });
+ }
+
let result: CompletionResult;
let workingProviderType: string;
try {
@@ -439,13 +464,18 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
params: {
systemPrompt,
userPrompt,
- temperature: 0.4,
- maxTokens: 600,
+ temperature: AI_BUDGETS.coach.temperature,
+ maxTokens: AI_BUDGETS.coach.maxTokens,
},
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
} catch (err) {
+ // The provider chain failed outright — no tokens were billed, so refund
+ // the full reservation before surfacing the error frame.
+ await reconcileSpend(userId, reservation.reserved, 0, reqDateKey).catch(
+ () => {},
+ );
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "insights.coach.providerFailed" },
@@ -483,6 +513,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
throw err;
}
+ // v1.18.7 (SENIOR-DEV MEDIUM) — the provider call returned, so its tokens
+ // were billed regardless of reply quality. Reconcile the reservation
+ // against the actual count NOW, before any empty/sentinel short-circuit, so
+ // an empty or sentinel-only reply still records its burned cost (the old
+ // post-hoc `recordSpend` ran only on the happy path, undercounting these).
+ await reconcileSpend(
+ userId,
+ reservation.reserved,
+ result.tokensUsed ?? 0,
+ reqDateKey,
+ ).catch(() => {
+ // Ledger reconcile is best-effort; a failure leaves the conservative
+ // reservation in place (never an undercount) and never breaks the turn.
+ });
+
const rawReply = (result.content ?? "").trim();
if (!rawReply) {
return streamProviderError({ code: "coach.provider.empty" });
@@ -526,7 +571,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
prisma,
userId,
cadence: suggestParse.cadence,
- prefs: coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
+ prefs:
+ coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
});
if (decision.surface) {
const cadence = suggestParse.cadence;
@@ -568,9 +614,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
}
const enrichedProvenance: typeof snapshot.provenance = {
...snapshot.provenance,
- ...(sentinel.keyValues.length > 0
- ? { keyValues: sentinel.keyValues }
- : {}),
+ ...(sentinel.keyValues.length > 0 ? { keyValues: sentinel.keyValues } : {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
};
if (sentinel.malformed) {
@@ -610,13 +654,10 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
promptVersion: PROMPT_VERSION,
});
- // Bump the day's spend ledger AFTER persistence so a retried request
- // doesn't double-count when the persistence layer rolled back.
- await recordSpend({
- userId,
- tokens: result.tokensUsed ?? 0,
- dateKey: buildDateKey(),
- });
+ // v1.18.7 — the day's spend was already reconciled against the
+ // reservation immediately after the provider returned (above), so there is
+ // no post-persistence ledger bump here. The reservation guarantees the
+ // tokens are counted even if persistence or streaming fails afterwards.
annotate({
action: { name: "insights.coach.replied" },
diff --git a/src/app/api/insights/coach/facts/__tests__/route.test.ts b/src/app/api/insights/coach/facts/__tests__/route.test.ts
index 6c7cc9f73..3b448c2e2 100644
--- a/src/app/api/insights/coach/facts/__tests__/route.test.ts
+++ b/src/app/api/insights/coach/facts/__tests__/route.test.ts
@@ -58,8 +58,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
- const actual =
- await importOriginal();
+ const actual = await importOriginal();
return {
...actual,
annotate: vi.fn(),
@@ -167,10 +166,7 @@ describe("GET /api/insights/coach/facts", () => {
await callGet();
const orderBy = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
?.orderBy;
- expect(orderBy).toEqual([
- { confidence: "desc" },
- { createdAt: "desc" },
- ]);
+ expect(orderBy).toEqual([{ confidence: "desc" }, { createdAt: "desc" }]);
});
it("skips an undecryptable row rather than 500ing the whole list", async () => {
diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts
index 18daa616f..97ea186cd 100644
--- a/src/app/api/insights/comprehensive/__tests__/route.test.ts
+++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts
@@ -35,12 +35,14 @@ vi.mock("@/lib/db", () => ({
// real recompute against the mocked Prisma. The route fires the
// warm-up as fire-and-forget; the return value is irrelevant.
vi.mock("@/lib/rollups/mood-rollups", async () => {
- const actual = await vi.importActual(
- "@/lib/rollups/mood-rollups",
- );
+ const actual = await vi.importActual<
+ typeof import("@/lib/rollups/mood-rollups")
+ >("@/lib/rollups/mood-rollups");
return {
...actual,
- ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
+ ensureUserMoodRollupsFresh: vi
+ .fn()
+ .mockResolvedValue({ recomputed: false }),
};
});
@@ -118,9 +120,9 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
});
// Default to assistant-on so the gate doesn't 403 every test.
- (
- prisma.appSettings.findUnique as ReturnType
- ).mockResolvedValue(null);
+ (prisma.appSettings.findUnique as ReturnType).mockResolvedValue(
+ null,
+ );
(prisma.user.findUnique as ReturnType).mockResolvedValue({
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
@@ -146,15 +148,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
it("emits every legacy envelope key for an empty user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue({
- summaries: {},
- bpRawRows: { sys: [], dia: [] },
- dailyByType: {},
- firstMeasurementAt: null,
- totalMeasurements: 0,
- });
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ {
+ summaries: {},
+ bpRawRows: { sys: [], dia: [] },
+ dailyByType: {},
+ firstMeasurementAt: null,
+ totalMeasurements: 0,
+ },
+ );
const res = await callGet(makeReq());
expect(res.status).toBe(200);
@@ -202,31 +204,31 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue({
- summaries: {
- WEIGHT: {
- count: 1,
- latest: 81.0,
- min: 81.0,
- max: 81.0,
- mean: 81.0,
- avg7: 81.0,
- avg30: 81.0,
- slope7: null,
- slope30: null,
- slope90: null,
- anomalyCount: 0,
- avg30LastMonth: null,
- avg30LastYear: null,
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ {
+ summaries: {
+ WEIGHT: {
+ count: 1,
+ latest: 81.0,
+ min: 81.0,
+ max: 81.0,
+ mean: 81.0,
+ avg7: 81.0,
+ avg30: 81.0,
+ slope7: null,
+ slope30: null,
+ slope90: null,
+ anomalyCount: 0,
+ avg30LastMonth: null,
+ avg30LastYear: null,
+ },
},
+ bpRawRows: { sys: [], dia: [] },
+ dailyByType: {},
+ firstMeasurementAt: new Date(),
+ totalMeasurements: 1,
},
- bpRawRows: { sys: [], dia: [] },
- dailyByType: {},
- firstMeasurementAt: new Date(),
- totalMeasurements: 1,
- });
+ );
const res = await callGet(makeReq());
const body = (await res.json()) as {
@@ -240,49 +242,49 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
it("pairs sys + dia raw rows with 5-min tolerance for bpPctInTarget", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const at = new Date("2026-05-10T08:00:00Z");
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue({
- summaries: {
- BLOOD_PRESSURE_SYS: {
- count: 1,
- latest: 125,
- min: 125,
- max: 125,
- mean: 125,
- avg7: 125,
- avg30: 125,
- slope7: null,
- slope30: null,
- slope90: null,
- anomalyCount: 0,
- avg30LastMonth: null,
- avg30LastYear: null,
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ {
+ summaries: {
+ BLOOD_PRESSURE_SYS: {
+ count: 1,
+ latest: 125,
+ min: 125,
+ max: 125,
+ mean: 125,
+ avg7: 125,
+ avg30: 125,
+ slope7: null,
+ slope30: null,
+ slope90: null,
+ anomalyCount: 0,
+ avg30LastMonth: null,
+ avg30LastYear: null,
+ },
+ BLOOD_PRESSURE_DIA: {
+ count: 1,
+ latest: 75,
+ min: 75,
+ max: 75,
+ mean: 75,
+ avg7: 75,
+ avg30: 75,
+ slope7: null,
+ slope30: null,
+ slope90: null,
+ anomalyCount: 0,
+ avg30LastMonth: null,
+ avg30LastYear: null,
+ },
},
- BLOOD_PRESSURE_DIA: {
- count: 1,
- latest: 75,
- min: 75,
- max: 75,
- mean: 75,
- avg7: 75,
- avg30: 75,
- slope7: null,
- slope30: null,
- slope90: null,
- anomalyCount: 0,
- avg30LastMonth: null,
- avg30LastYear: null,
+ bpRawRows: {
+ sys: [{ measuredAt: at, value: 125 }],
+ dia: [{ measuredAt: at, value: 75 }],
},
+ dailyByType: {},
+ firstMeasurementAt: at,
+ totalMeasurements: 2,
},
- bpRawRows: {
- sys: [{ measuredAt: at, value: 125 }],
- dia: [{ measuredAt: at, value: 75 }],
- },
- dailyByType: {},
- firstMeasurementAt: at,
- totalMeasurements: 2,
- });
+ );
const res = await callGet(makeReq());
const body = (await res.json()) as {
@@ -305,15 +307,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
// mean DataPoints, matching the rollup-tier convention shipped by
// `/api/mood/analytics` post-v1.4.39.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue({
- summaries: {},
- bpRawRows: { sys: [], dia: [] },
- dailyByType: {},
- firstMeasurementAt: new Date(),
- totalMeasurements: 0,
- });
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ {
+ summaries: {},
+ bpRawRows: { sys: [], dia: [] },
+ dailyByType: {},
+ firstMeasurementAt: new Date(),
+ totalMeasurements: 0,
+ },
+ );
(
prisma.moodEntryRollup.findMany as ReturnType
).mockResolvedValue([
@@ -364,15 +366,15 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
// supposed to fire fire-and-forget so the next request lands on
// the rollup tier.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue({
- summaries: {},
- bpRawRows: { sys: [], dia: [] },
- dailyByType: {},
- firstMeasurementAt: new Date(),
- totalMeasurements: 0,
- });
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ {
+ summaries: {},
+ bpRawRows: { sys: [], dia: [] },
+ dailyByType: {},
+ firstMeasurementAt: new Date(),
+ totalMeasurements: 0,
+ },
+ );
(
prisma.moodEntryRollup.findMany as ReturnType
).mockResolvedValue([]);
@@ -411,18 +413,21 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
"2026-05-04",
"2026-05-05",
];
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue({
- summaries: {},
- bpRawRows: { sys: [], dia: [] },
- dailyByType: {
- WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
- BLOOD_PRESSURE_SYS: days.map((d, i) => ({ day: d, value: 120 + i * 2 })),
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ {
+ summaries: {},
+ bpRawRows: { sys: [], dia: [] },
+ dailyByType: {
+ WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
+ BLOOD_PRESSURE_SYS: days.map((d, i) => ({
+ day: d,
+ value: 120 + i * 2,
+ })),
+ },
+ firstMeasurementAt: new Date(),
+ totalMeasurements: 10,
},
- firstMeasurementAt: new Date(),
- totalMeasurements: 10,
- });
+ );
const res = await callGet(makeReq());
const body = (await res.json()) as {
@@ -454,9 +459,9 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
it("marks a cold (inline-computed) read revalidating: false", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue(EMPTY_AGGREGATE);
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ EMPTY_AGGREGATE,
+ );
const res = await callGet(makeReq());
expect(res.status).toBe(200);
@@ -466,9 +471,9 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
it("marks a stale-served read revalidating: true and a converged read false again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- (
- buildComprehensiveAggregate as ReturnType
- ).mockResolvedValue(EMPTY_AGGREGATE);
+ (buildComprehensiveAggregate as ReturnType).mockResolvedValue(
+ EMPTY_AGGREGATE,
+ );
// Prime the cache entry (cold compute).
await callGet(makeReq());
diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts
index f0d999a04..d59471136 100644
--- a/src/app/api/insights/comprehensive/route.ts
+++ b/src/app/api/insights/comprehensive/route.ts
@@ -74,9 +74,9 @@ export const GET = apiHandler(async () => {
// the response, never inside the cached body, so a warmed entry
// doesn't freeze the flag.
const { value: body, outcome } = await cachedSwrWithMeta(
- caches.analytics as ServerCache
- >>,
+ caches.analytics as ServerCache<
+ Awaited>
+ >,
`${user.id}|comprehensive`,
() => buildComprehensiveResponse(user),
annotate,
@@ -241,10 +241,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// small semantic shift to bound the raw-row footprint).
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
- const weightBpPairs: PairedPoint[] = joinDailyByDay(
- weightDaily,
- sysDaily,
- );
+ const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
const weightBpCorrelation = pearsonCorrelation(weightBpPairs);
const scatterData = weightBpPairs.map((p) => ({
weight: p.a,
diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts
index 04677474c..76a9c6868 100644
--- a/src/app/api/insights/correlations/__tests__/route.test.ts
+++ b/src/app/api/insights/correlations/__tests__/route.test.ts
@@ -5,7 +5,9 @@ vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
- user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) },
+ user: {
+ findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }),
+ },
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
},
diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts
index 3ff9b0da6..dea015144 100644
--- a/src/app/api/insights/correlations/route.ts
+++ b/src/app/api/insights/correlations/route.ts
@@ -117,7 +117,10 @@ export const GET = apiHandler(async () => {
}),
]);
- const measurementsByType = new Map>();
+ const measurementsByType = new Map<
+ string,
+ Array<{ value: number; at: Date }>
+ >();
for (const m of measurements) {
const list = measurementsByType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
@@ -134,12 +137,16 @@ export const GET = apiHandler(async () => {
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
const points =
- key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz);
+ key === "MOOD"
+ ? moodDaily
+ : toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "behaviour", points });
}
for (const key of DISCOVERY_OUTCOMES) {
const points =
- key === "MOOD" ? moodDaily : toDailyMeans(measurementsByType.get(key) ?? [], tz);
+ key === "MOOD"
+ ? moodDaily
+ : toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "outcome", points });
}
diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts
index 8d75f48d5..d787c2475 100644
--- a/src/app/api/insights/derived/__tests__/route.test.ts
+++ b/src/app/api/insights/derived/__tests__/route.test.ts
@@ -59,10 +59,28 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
...actual,
computeDerivedMetric: vi.fn(async () => ({
status: "ok",
- value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 },
- coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
+ value: {
+ type: "RESTING_HEART_RATE",
+ center: 55,
+ low: 48,
+ high: 62,
+ spread: 7,
+ sampleDays: 30,
+ k: 3,
+ },
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 1,
+ historyDays: 30,
+ missing: [],
+ },
confidence: { score: 100, band: "high" },
- provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
+ provenance: {
+ inputs: ["RESTING_HEART_RATE"],
+ source: "DAY",
+ windowDays: 30,
+ computedAt: "2026-06-02T07:00:00+02:00",
+ },
})),
};
});
@@ -74,7 +92,12 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
+ user: {
+ id: "user-1",
+ username: "testuser",
+ role: "USER" as const,
+ locale: "en",
+ },
};
const callGet = GET as unknown as (req: NextRequest) => Promise;
@@ -152,14 +175,29 @@ describe("GET /api/insights/derived", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(computeDerivedMetric).mockResolvedValueOnce({
status: "insufficient",
- coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] },
- provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 0,
+ historyDays: 0,
+ missing: ["RESTING_HEART_RATE"],
+ },
+ provenance: {
+ inputs: ["RESTING_HEART_RATE"],
+ source: "none",
+ windowDays: 30,
+ computedAt: "2026-06-02T07:00:00+02:00",
+ },
reason: "no_readings_in_window",
} as never);
const res = await callGet(makeReq("VITALS_BASELINE"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
- data: { status: string; value: unknown; confidence: unknown; reason: string } | null;
+ data: {
+ status: string;
+ value: unknown;
+ confidence: unknown;
+ reason: string;
+ } | null;
};
expect(body.data?.status).toBe("insufficient");
expect(body.data?.value).toBeNull();
diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts
index 306322109..c5b8711d7 100644
--- a/src/app/api/insights/glp1-timeline/route.ts
+++ b/src/app/api/insights/glp1-timeline/route.ts
@@ -103,7 +103,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const moods = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows.
- where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } },
+ where: {
+ userId: user.id,
+ deletedAt: null,
+ moodLoggedAt: { gte: ninetyDaysAgo },
+ },
select: { moodLoggedAt: true, tags: true },
orderBy: { moodLoggedAt: "desc" },
});
diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts
index e0086226e..9e96a9718 100644
--- a/src/app/api/insights/layout/__tests__/route.test.ts
+++ b/src/app/api/insights/layout/__tests__/route.test.ts
@@ -36,8 +36,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
- const actual =
- await importOriginal();
+ const actual = await importOriginal();
return {
...actual,
annotate: vi.fn(),
@@ -240,7 +239,8 @@ describe("PUT /api/insights/layout — happy path", () => {
expect(returnedIds).not.toContain("medikamente");
// The persisted blob is canonical too.
- const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
+ const updateCall = vi.mocked(prisma.user.update).mock
+ .calls[0]?.[0] as unknown as {
data: { insightsLayoutJson: { tiles: Array<{ id: string }> } };
};
const persistedIds = updateCall.data.insightsLayoutJson.tiles.map(
@@ -291,9 +291,9 @@ describe("PUT /api/insights/layout — accepts version 1 (iOS contract)", () =>
data: { insightsLayoutJson: { version: number; sections: unknown[] } };
};
expect(updateCall.data.insightsLayoutJson.version).toBe(2);
- expect(
- Array.isArray(updateCall.data.insightsLayoutJson.sections),
- ).toBe(true);
+ expect(Array.isArray(updateCall.data.insightsLayoutJson.sections)).toBe(
+ true,
+ );
});
});
@@ -396,9 +396,9 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => {
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
- expect(
- body.details.issues.some((i) => i.path.startsWith("tiles")),
- ).toBe(true);
+ expect(body.details.issues.some((i) => i.path.startsWith("tiles"))).toBe(
+ true,
+ );
});
});
@@ -436,9 +436,9 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
- expect(
- body.details.issues.some((i) => i.path.startsWith("tiles")),
- ).toBe(true);
+ expect(body.details.issues.some((i) => i.path.startsWith("tiles"))).toBe(
+ true,
+ );
});
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
@@ -488,11 +488,13 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "insights.layout.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "insights.layout.validation-failed",
+ );
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record }).meta!;
@@ -524,11 +526,13 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "insights.layout.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "insights.layout.validation-failed",
+ );
const meta = (annotated![0] as { meta?: Record }).meta!;
const excerpt = meta.received_shape_excerpt as string;
@@ -578,7 +582,9 @@ describe("PUT /api/insights/layout — v2 sections", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as {
- data: { sections: Array<{ id: string; visible: boolean; order: number }> };
+ data: {
+ sections: Array<{ id: string; visible: boolean; order: number }>;
+ };
};
// Saved sections surface in saved order, dense 0-based.
expect(body.data.sections[0]).toEqual({
@@ -651,9 +657,9 @@ describe("PUT /api/insights/layout — v2 sections", () => {
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
- expect(
- body.details.issues.some((i) => i.path.startsWith("sections")),
- ).toBe(true);
+ expect(body.details.issues.some((i) => i.path.startsWith("sections"))).toBe(
+ true,
+ );
});
});
diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts
index 208be55a6..f922dc5de 100644
--- a/src/app/api/insights/metric-status/__tests__/route.test.ts
+++ b/src/app/api/insights/metric-status/__tests__/route.test.ts
@@ -58,7 +58,12 @@ import { generateMetricStatus } from "@/lib/insights/metric-status";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
+ user: {
+ id: "user-1",
+ username: "testuser",
+ role: "USER" as const,
+ locale: "en",
+ },
};
const callGet = GET as unknown as (req: NextRequest) => Promise;
diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts
index d27e6a20f..be45da6b1 100644
--- a/src/app/api/insights/metric-status/route.ts
+++ b/src/app/api/insights/metric-status/route.ts
@@ -19,10 +19,7 @@
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
-import {
- apiSuccess,
- returnAllZodIssues,
-} from "@/lib/api-response";
+import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
diff --git a/src/app/api/insights/mood-status/__tests__/route.test.ts b/src/app/api/insights/mood-status/__tests__/route.test.ts
index eaf859da7..87bdda898 100644
--- a/src/app/api/insights/mood-status/__tests__/route.test.ts
+++ b/src/app/api/insights/mood-status/__tests__/route.test.ts
@@ -58,9 +58,7 @@ const SESSION_OK = {
const callGet = GET as unknown as (req: NextRequest) => Promise;
function makeReq(): NextRequest {
- return new NextRequest(
- new URL("http://localhost/api/insights/mood-status"),
- );
+ return new NextRequest(new URL("http://localhost/api/insights/mood-status"));
}
/** Persisted module-preferences blob for the gated user row. */
@@ -81,9 +79,7 @@ beforeEach(() => {
describe("GET /api/insights/mood-status — module gate", () => {
it("200s when the mood module is enabled (default-on, no blob)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(
- userRow(null) as never,
- );
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { text: string } | null };
diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts
index 266dfdf04..43d781c72 100644
--- a/src/app/api/insights/narrative/route.ts
+++ b/src/app/api/insights/narrative/route.ts
@@ -20,7 +20,10 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
-import { PERIOD_DAYS, type NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
+import {
+ PERIOD_DAYS,
+ type NarrativePeriod,
+} from "@/lib/insights/narrative/period-narrative";
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
export const dynamic = "force-dynamic";
diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts
index 9e5c76c98..68f17dac8 100644
--- a/src/app/api/insights/pregenerate/__tests__/route.test.ts
+++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts
@@ -57,15 +57,19 @@ import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
+ user: {
+ id: "user-1",
+ username: "testuser",
+ role: "USER" as const,
+ locale: "en",
+ },
};
const callPost = POST as unknown as (req: NextRequest) => Promise;
function makeReq(): NextRequest {
- return new NextRequest(
- new URL("http://localhost/api/insights/pregenerate"),
- { method: "POST" },
- );
+ return new NextRequest(new URL("http://localhost/api/insights/pregenerate"), {
+ method: "POST",
+ });
}
beforeEach(() => {
diff --git a/src/app/api/insights/rhythm-events/__tests__/route.test.ts b/src/app/api/insights/rhythm-events/__tests__/route.test.ts
index e2d0860b0..65f5c094e 100644
--- a/src/app/api/insights/rhythm-events/__tests__/route.test.ts
+++ b/src/app/api/insights/rhythm-events/__tests__/route.test.ts
@@ -51,12 +51,19 @@ import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
+ user: {
+ id: "user-1",
+ username: "testuser",
+ role: "USER" as const,
+ locale: "en",
+ },
};
const callGet = GET as unknown as (req: NextRequest) => Promise;
function makeReq(): NextRequest {
- return new NextRequest(new URL("http://localhost/api/insights/rhythm-events"));
+ return new NextRequest(
+ new URL("http://localhost/api/insights/rhythm-events"),
+ );
}
beforeEach(() => {
diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts
index ca98cc69d..12557614a 100644
--- a/src/app/api/insights/rhythm-events/route.ts
+++ b/src/app/api/insights/rhythm-events/route.ts
@@ -39,7 +39,9 @@ export const dynamic = "force-dynamic";
// these a year; the cap is a defensive bound, not an expected ceiling.
const MAX_EVENTS = 200;
-const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[];
+const EVENT_TYPE_LIST = Array.from(
+ EVENT_MEASUREMENT_TYPES,
+) as MeasurementType[];
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts
index c65c67674..1d198ef27 100644
--- a/src/app/api/insights/targets/__tests__/route.test.ts
+++ b/src/app/api/insights/targets/__tests__/route.test.ts
@@ -69,12 +69,14 @@ vi.mock("next/headers", () => ({
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget
// on the route so its return value is irrelevant for parity.
vi.mock("@/lib/rollups/mood-rollups", async () => {
- const actual = await vi.importActual(
- "@/lib/rollups/mood-rollups",
- );
+ const actual = await vi.importActual<
+ typeof import("@/lib/rollups/mood-rollups")
+ >("@/lib/rollups/mood-rollups");
return {
...actual,
- ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
+ ensureUserMoodRollupsFresh: vi
+ .fn()
+ .mockResolvedValue({ recomputed: false }),
};
});
@@ -160,11 +162,61 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
(
prisma.moodEntryRollup.findMany as ReturnType
).mockResolvedValue([
- { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
- { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
- { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() },
- { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
- { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() },
+ {
+ userId: SESSION_USER.id,
+ granularity: "DAY",
+ bucketStart: day(0),
+ count: 1,
+ mean: 4,
+ minScore: 4,
+ maxScore: 4,
+ sd: null,
+ computedAt: new Date(),
+ },
+ {
+ userId: SESSION_USER.id,
+ granularity: "DAY",
+ bucketStart: day(1),
+ count: 1,
+ mean: 4,
+ minScore: 4,
+ maxScore: 4,
+ sd: null,
+ computedAt: new Date(),
+ },
+ {
+ userId: SESSION_USER.id,
+ granularity: "DAY",
+ bucketStart: day(2),
+ count: 1,
+ mean: 5,
+ minScore: 5,
+ maxScore: 5,
+ sd: null,
+ computedAt: new Date(),
+ },
+ {
+ userId: SESSION_USER.id,
+ granularity: "DAY",
+ bucketStart: day(3),
+ count: 1,
+ mean: 4,
+ minScore: 4,
+ maxScore: 4,
+ sd: null,
+ computedAt: new Date(),
+ },
+ {
+ userId: SESSION_USER.id,
+ granularity: "DAY",
+ bucketStart: day(4),
+ count: 1,
+ mean: 4.5,
+ minScore: 4,
+ maxScore: 5,
+ sd: 0.5,
+ computedAt: new Date(),
+ },
]);
// Latest score read — one bounded row.
(prisma.moodEntry.findFirst as ReturnType).mockResolvedValue({
diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts
index 91df8f408..53d411dab 100644
--- a/src/app/api/integrations/healthkit/__tests__/route.test.ts
+++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
},
- toJson: (v: T) => v,
+ toJson: (v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts
index 2f378f5d3..a95987e3a 100644
--- a/src/app/api/integrations/status/__tests__/route.test.ts
+++ b/src/app/api/integrations/status/__tests__/route.test.ts
@@ -85,9 +85,7 @@ describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
const entries = await fetchEntries();
const keys = entries.map((e) => e.integration);
- expect(keys).toEqual(
- expect.arrayContaining(["polar", "oura"]),
- );
+ expect(keys).toEqual(expect.arrayContaining(["polar", "oura"]));
const polar = entries.find((e) => e.integration === "polar")!;
expect(polar.connected).toBe(true);
diff --git a/src/app/api/integrations/withings/resume/route.ts b/src/app/api/integrations/withings/resume/route.ts
index 0065add4b..6168d20d0 100644
--- a/src/app/api/integrations/withings/resume/route.ts
+++ b/src/app/api/integrations/withings/resume/route.ts
@@ -34,11 +34,7 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.resume" } });
- const rl = await checkRateLimit(
- `withings-resume:${user.id}`,
- 5,
- 60_000,
- );
+ const rl = await checkRateLimit(`withings-resume:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many resume requests", 429, {
errorCode: "rate_limited_self",
diff --git a/src/app/api/internal/web-vitals/__tests__/route.test.ts b/src/app/api/internal/web-vitals/__tests__/route.test.ts
index 47418b0bb..81a792abf 100644
--- a/src/app/api/internal/web-vitals/__tests__/route.test.ts
+++ b/src/app/api/internal/web-vitals/__tests__/route.test.ts
@@ -117,9 +117,7 @@ describe("POST /api/internal/web-vitals", () => {
remaining: 0,
resetAt: Date.now() + 60_000,
});
- const res = await POST(
- postRequest({ name: "CLS", value: 0.1, id: "x" }),
- );
+ const res = await POST(postRequest({ name: "CLS", value: 0.1, id: "x" }));
expect(res.status).toBe(429);
});
diff --git a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts
index 78778d154..2020c27b6 100644
--- a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts
+++ b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts
@@ -61,9 +61,12 @@ const ROW = {
};
function makeRequest(): NextRequest {
- return new NextRequest("http://localhost/api/measurement-reminders/r1/complete", {
- method: "POST",
- });
+ return new NextRequest(
+ "http://localhost/api/measurement-reminders/r1/complete",
+ {
+ method: "POST",
+ },
+ );
}
const params = { params: Promise.resolve({ id: "r1" }) };
@@ -102,7 +105,10 @@ describe("POST /api/measurement-reminders/[id]/complete", () => {
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
// Forward-only guard inside the primitive returns satisfied=false.
- satisfyReminderMock.mockResolvedValue({ satisfied: false, nextDueAt: null });
+ satisfyReminderMock.mockResolvedValue({
+ satisfied: false,
+ nextDueAt: null,
+ });
findUniqueOrThrowMock.mockResolvedValue({
...ROW,
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts
index 0a27715ba..7a05da314 100644
--- a/src/app/api/measurement-reminders/route.ts
+++ b/src/app/api/measurement-reminders/route.ts
@@ -42,7 +42,10 @@ export const GET = apiHandler(async () => {
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" }],
+ orderBy: [
+ { nextDueAt: { sort: "asc", nulls: "last" } },
+ { createdAt: "asc" },
+ ],
});
annotate({
@@ -89,8 +92,7 @@ async function postReminder(request: NextRequest): Promise {
const timezone = await resolveTimezone(user.id);
const now = new Date();
- const anchorDate =
- data.anchorDate != null ? new Date(data.anchorDate) : null;
+ 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).
diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts
index 030d3e3b9..7d7f96a26 100644
--- a/src/app/api/measurements/[id]/__tests__/route.test.ts
+++ b/src/app/api/measurements/[id]/__tests__/route.test.ts
@@ -107,10 +107,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
});
it("writes a measurements.update.validation-failed audit row", async () => {
- const res = await PUT(
- putReq({ value: -1, measuredAt: "junk" }),
- ROUTE_CTX,
- );
+ const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
@@ -125,10 +122,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
- const res = await PUT(
- putReq({ value: -1, measuredAt: "junk" }),
- ROUTE_CTX,
- );
+ const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX);
expect(res.status).toBe(422);
});
});
diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts
index f1d2d1655..3d193d221 100644
--- a/src/app/api/measurements/__tests__/group-by-day.test.ts
+++ b/src/app/api/measurements/__tests__/group-by-day.test.ts
@@ -217,9 +217,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
},
] as never);
- const res = await GET(
- getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"),
- );
+ const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"));
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
@@ -255,9 +253,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
// hour of `today` on fall-back.
it("returns a 23-hour window on the Berlin spring-forward day (2025-03-30)", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
- const res = await GET(
- getRequest("type=ACTIVITY_STEPS&dayKey=2025-03-30"),
- );
+ const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2025-03-30"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
@@ -271,9 +267,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
it("returns a 25-hour window on the Berlin fall-back day (2025-10-26)", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
- const res = await GET(
- getRequest("type=ACTIVITY_STEPS&dayKey=2025-10-26"),
- );
+ const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2025-10-26"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0];
const measuredAt = (call as { where: { measuredAt?: unknown } }).where
@@ -335,17 +329,13 @@ describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
// 2, so the drill-down would point at the wrong day. The validator
// refine must reject it at parse time.
it("rejects impossible calendar dates on dayKey (2026-02-30)", async () => {
- const res = await GET(
- getRequest("type=ACTIVITY_STEPS&dayKey=2026-02-30"),
- );
+ const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-02-30"));
expect(res.status).toBe(422);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
it("rejects impossible month on dayKey (2026-13-01)", async () => {
- const res = await GET(
- getRequest("type=ACTIVITY_STEPS&dayKey=2026-13-01"),
- );
+ const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=2026-13-01"));
expect(res.status).toBe(422);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
diff --git a/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts b/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts
index db6645dce..076466f92 100644
--- a/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts
+++ b/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts
@@ -72,7 +72,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
{
code: "P2002",
clientVersion: "x",
- meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
+ meta: {
+ target: ["userId", "type", "measuredAt", "source", "sleepStage"],
+ },
},
);
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
@@ -83,7 +85,10 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
);
expect(res.status).toBe(409);
- const json = (await res.json()) as { error: string; meta?: { errorCode?: string } };
+ const json = (await res.json()) as {
+ error: string;
+ meta?: { errorCode?: string };
+ };
expect(json.error).toMatch(/already exists|duplicate/i);
expect(json.meta?.errorCode).toBe("measurement.duplicate_timestamp");
});
@@ -108,10 +113,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
);
vi.mocked(prisma.measurement.update).mockRejectedValue(otherError);
- const res = await PUT(
- putRequest("m-3", { value: 76 }),
- { params: Promise.resolve({ id: "m-3" }) },
- );
+ const res = await PUT(putRequest("m-3", { value: 76 }), {
+ params: Promise.resolve({ id: "m-3" }),
+ });
// apiHandler converts uncaught errors to 500.
expect(res.status).toBe(500);
@@ -143,7 +147,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
{
code: "P2002",
clientVersion: "x",
- meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
+ meta: {
+ target: ["userId", "type", "measuredAt", "source", "sleepStage"],
+ },
},
);
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
@@ -186,10 +192,9 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
unit: "kg",
} as never);
- const res = await PUT(
- putRequest("m-4", { value: 76 }),
- { params: Promise.resolve({ id: "m-4" }) },
- );
+ const res = await PUT(putRequest("m-4", { value: 76 }), {
+ params: Promise.resolve({ id: "m-4" }),
+ });
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { id: string; value: number } };
diff --git a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts
index 75da4312a..8f0245114 100644
--- a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts
+++ b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts
@@ -324,9 +324,7 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
// see a correct chart on their first render.
- vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
- [] as never,
- );
+ vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
type: "WEIGHT",
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts
index bba3ddc05..b4e721248 100644
--- a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts
+++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts
@@ -106,7 +106,9 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces THREE simultaneous validation errors", async () => {
// bad sortBy + bad sortDir + bad aggregate.
- const res = await GET(getReq("sortBy=junk&sortDir=upside&aggregate=hourly"));
+ const res = await GET(
+ getReq("sortBy=junk&sortDir=upside&aggregate=hourly"),
+ );
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
@@ -138,9 +140,7 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
describe("POST /api/measurements — single — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Missing `type` (required enum) + missing `value` (required number).
- const res = await POST(
- postReq({ measuredAt: "not-an-iso-string" }),
- );
+ const res = await POST(postReq({ measuredAt: "not-an-iso-string" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -235,7 +235,9 @@ describe("POST /api/measurements — batch — 422 multi-issue (v1.4.43 W6)", ()
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
- expect(call.data.action).toBe("measurements.create.batch.validation-failed");
+ expect(call.data.action).toBe(
+ "measurements.create.batch.validation-failed",
+ );
});
it("does not block the 422 when the audit-row write rejects", async () => {
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index 849d78c5a..0974fab9b 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -162,7 +162,10 @@ type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
// the strict insert-only contract.
const STATS_EXTERNAL_ID_PREFIX = "stats:";
function isStatsExternalId(externalId: string | null | undefined): boolean {
- return typeof externalId === "string" && externalId.startsWith(STATS_EXTERNAL_ID_PREFIX);
+ return (
+ typeof externalId === "string" &&
+ externalId.startsWith(STATS_EXTERNAL_ID_PREFIX)
+ );
}
interface EntryResult {
index: number;
@@ -462,7 +465,8 @@ async function postBatch(request: NextRequest): Promise {
// v1.8.6 W6 — scope the overwrite to the row's own source
// so a `stats:*` re-post only touches the matching
// (type, source, externalId) row.
- source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
+ source: p.row
+ .source as Prisma.MeasurementCreateManyInput["source"],
type: p.row.type as MeasurementType,
externalId: p.row.externalId as string,
},
diff --git a/src/app/api/measurements/series-batch/__tests__/route.test.ts b/src/app/api/measurements/series-batch/__tests__/route.test.ts
index 9e465acfb..9e34cea5d 100644
--- a/src/app/api/measurements/series-batch/__tests__/route.test.ts
+++ b/src/app/api/measurements/series-batch/__tests__/route.test.ts
@@ -20,8 +20,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/logging/context", async (importOriginal) => {
- const actual =
- await importOriginal();
+ const actual = await importOriginal();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts
index 8636f7356..7e8c4d0d4 100644
--- a/src/app/api/measurements/series/__tests__/route.test.ts
+++ b/src/app/api/measurements/series/__tests__/route.test.ts
@@ -32,8 +32,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
- const actual =
- await importOriginal();
+ const actual = await importOriginal();
return {
...actual,
annotate: vi.fn(),
@@ -141,11 +140,36 @@ describe("GET /api/measurements/series", () => {
// total in hours, not a point per stage.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
- { id: "s1", value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
- { id: "s2", value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
- { id: "s3", value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
- { id: "s4", value: 60, measuredAt: new Date("2026-06-04T05:00:00.000Z"), sleepStage: "AWAKE" },
- { id: "s5", value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
+ {
+ id: "s1",
+ value: 240,
+ measuredAt: new Date("2026-06-04T00:00:00.000Z"),
+ sleepStage: "CORE",
+ },
+ {
+ id: "s2",
+ value: 90,
+ measuredAt: new Date("2026-06-04T02:00:00.000Z"),
+ sleepStage: "DEEP",
+ },
+ {
+ id: "s3",
+ value: 80,
+ measuredAt: new Date("2026-06-04T04:00:00.000Z"),
+ sleepStage: "REM",
+ },
+ {
+ id: "s4",
+ value: 60,
+ measuredAt: new Date("2026-06-04T05:00:00.000Z"),
+ sleepStage: "AWAKE",
+ },
+ {
+ id: "s5",
+ value: 480,
+ measuredAt: new Date("2026-06-03T23:00:00.000Z"),
+ sleepStage: "IN_BED",
+ },
] as never);
const res = await GET(req("kind=sleep&days=30"));
expect(res.status).toBe(200);
@@ -362,11 +386,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos"));
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "measurements.series.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "measurements.series.validation-failed",
+ );
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record }).meta!;
@@ -377,7 +403,7 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
- expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\"");
+ expect(meta.received_shape_excerpt as string).toContain('"kind":"garbage"');
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
@@ -392,11 +418,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const long = "x".repeat(500);
const res = await GET(req(`kind=garbage&days=0&junk=${long}`));
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "measurements.series.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "measurements.series.validation-failed",
+ );
const meta = (annotated![0] as { meta?: Record }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
@@ -420,11 +448,13 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
);
expect(res.status).toBe(422);
- const annotated = vi.mocked(annotate).mock.calls.find(
- (call) =>
- (call[0] as { action?: { name?: string } })?.action?.name ===
- "measurements.series.validation-failed",
- );
+ const annotated = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (call) =>
+ (call[0] as { action?: { name?: string } })?.action?.name ===
+ "measurements.series.validation-failed",
+ );
const meta = (annotated![0] as { meta?: Record }).meta!;
const excerpt = meta.received_shape_excerpt as string;
diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts
index 916f9a82e..505bbc6a6 100644
--- a/src/app/api/measurements/series/route.ts
+++ b/src/app/api/measurements/series/route.ts
@@ -23,6 +23,7 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
+import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
@@ -151,7 +152,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// query map is routed through `redactSensitiveFields` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
- const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
+ const payloadDiagnostic = buildPayloadDiagnostic(
+ redactSensitiveFields(rawQuery),
+ );
annotate({
action: { name: "measurements.series.validation-failed" },
meta: {
@@ -259,11 +262,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
at: n.measuredAt.toISOString(),
value: Math.round((n.asleepMinutes / 60) * 100) / 100,
secondary: null,
- sleepStages:
- Object.keys(stageHours).length > 0 ? stageHours : null,
+ sleepStages: Object.keys(stageHours).length > 0 ? stageHours : null,
};
});
} else if (kind === "bloodPressure") {
+ // Exclude non-physiological rows (systolic < 40 / diastolic < 20) from the
+ // series AND the implicit "latest" (the last point). BP is two rows paired
+ // client-side, so a corrupt row that slipped in before the input floor was
+ // enforced — a seed-era systolic-0, iOS #33 — must never surface as the
+ // latest reading. Floors mirror VALUE_RANGES (the input validator's min);
+ // this is a read-side selection guard, not a tightening of the write floor.
const [sys, dia] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -271,6 +279,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since },
deletedAt: null,
+ value: { gte: VALUE_RANGES.BLOOD_PRESSURE_SYS.min },
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
@@ -281,6 +290,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
type: "BLOOD_PRESSURE_DIA",
measuredAt: { gte: since },
deletedAt: null,
+ value: { gte: VALUE_RANGES.BLOOD_PRESSURE_DIA.min },
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts
index 4c79e1b06..c08ecb86f 100644
--- a/src/app/api/medications/[id]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/__tests__/route.test.ts
@@ -170,10 +170,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
// Re-stub deps the outer beforeEach's resetAllMocks cleared.
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
- const res = await PUT(
- putReq({ endsOn: "2026-12-31" }),
- ROUTE_CTX,
- );
+ const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX);
expect(res.status).toBe(200);
const call = lastUpdateCall();
expect(call.data.endsOn).toBeInstanceOf(Date);
@@ -251,9 +248,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
- vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(
- undefined,
- );
+ vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
PREVIOUS_ROW,
] as never);
@@ -379,9 +374,7 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
skipped: false,
autoMissed: false,
});
- expect(
- (arg.where.scheduledFor as { gte: Date }).gte,
- ).toBeInstanceOf(Date);
+ expect((arg.where.scheduledFor as { gte: Date }).gte).toBeInstanceOf(Date);
expect(arg.data.deletedAt).toBeInstanceOf(Date);
expect(arg.data.syncVersion).toEqual({ increment: 1 });
});
@@ -394,8 +387,8 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
{ scheduledFor: new Date("2026-06-10T18:00:00Z") },
{ scheduledFor: new Date("2026-06-11T06:00:00Z") },
] as never);
- vi.mocked(dayKeyForScheduledFor).mockImplementation(
- (scheduledFor: Date) => scheduledFor.toISOString().slice(0, 10),
+ vi.mocked(dayKeyForScheduledFor).mockImplementation((scheduledFor: Date) =>
+ scheduledFor.toISOString().slice(0, 10),
);
const res = await PUT(
@@ -642,17 +635,15 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
asNeeded: true,
schedules: [],
} as never);
- const res = await PUT(
- putReq({ asNeeded: true, schedules: [] }),
- ROUTE_CTX,
- );
+ const res = await PUT(putReq({ asNeeded: true, schedules: [] }), ROUTE_CTX);
expect(res.status).toBe(200);
// Wholesale replace ran: delete-all, recreate none.
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
- const updateData = vi.mocked(prisma.medication.update).mock
- .calls[0][0] as { data: Record };
+ const updateData = vi.mocked(prisma.medication.update).mock.calls[0][0] as {
+ data: Record;
+ };
expect(updateData.data.asNeeded).toBe(true);
expect(updateData.data.schedules).toEqual({ create: [] });
});
@@ -695,8 +686,9 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
ROUTE_CTX,
);
expect(res.status).toBe(200);
- const updateData = vi.mocked(prisma.medication.update).mock
- .calls[0][0] as { data: Record };
+ const updateData = vi.mocked(prisma.medication.update).mock.calls[0][0] as {
+ data: Record;
+ };
expect(updateData.data.asNeeded).toBe(false);
});
diff --git a/src/app/api/medications/[id]/cadence/__tests__/route.test.ts b/src/app/api/medications/[id]/cadence/__tests__/route.test.ts
index d734e5f30..fefddabde 100644
--- a/src/app/api/medications/[id]/cadence/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/cadence/__tests__/route.test.ts
@@ -111,9 +111,7 @@ describe("GET /api/medications/[id]/cadence", () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
- new NextRequest(
- "http://localhost/api/medications/med-1/cadence?days=14",
- ),
+ new NextRequest("http://localhost/api/medications/med-1/cadence?days=14"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts
index fb06a98a3..13c3967ff 100644
--- a/src/app/api/medications/[id]/cadence/route.ts
+++ b/src/app/api/medications/[id]/cadence/route.ts
@@ -20,11 +20,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
-import {
- apiError,
- apiSuccess,
- returnAllZodIssues,
-} from "@/lib/api-response";
+import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
buildCadenceTimeline,
computeNextDose,
@@ -81,7 +77,9 @@ export const GET = apiHandler(
// Pull intake events covering the window + a small buffer so a dose
// logged at the edge can still pair with its slot.
const bufferMs = 24 * 60 * 60 * 1000;
- const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs);
+ const from = new Date(
+ asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs,
+ );
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: id,
diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts
index e89faabaa..4b6de773f 100644
--- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts
@@ -82,7 +82,12 @@ const TZ = "UTC";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const, timezone: TZ },
+ user: {
+ id: "user-1",
+ username: "testuser",
+ role: "USER" as const,
+ timezone: TZ,
+ },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
@@ -340,31 +345,29 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
});
it("35-day-interval med → steps the windows up past 7 / 30 days", async () => {
- vi.mocked(prisma.medication.findUnique).mockResolvedValue(
- {
- id: "med-1",
- userId: "user-1",
- createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
- startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
- endsOn: null,
- oneShot: false,
- schedules: [
- {
- id: "sched-1",
- windowStart: "10:00",
- windowEnd: "11:00",
- timesOfDay: ["10:00"],
- daysOfWeek: null,
- rrule: null,
- rollingIntervalDays: 35,
- reminderGraceMinutes: null,
- scheduleType: "SCHEDULED",
- cyclicOnWeeks: null,
- cyclicOffWeeks: null,
- },
- ],
- } as never,
- );
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue({
+ id: "med-1",
+ userId: "user-1",
+ createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
+ startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
+ endsOn: null,
+ oneShot: false,
+ schedules: [
+ {
+ id: "sched-1",
+ windowStart: "10:00",
+ windowEnd: "11:00",
+ timesOfDay: ["10:00"],
+ daysOfWeek: null,
+ rrule: null,
+ rollingIntervalDays: 35,
+ reminderGraceMinutes: null,
+ scheduleType: "SCHEDULED",
+ cyclicOnWeeks: null,
+ cyclicOffWeeks: null,
+ },
+ ],
+ } as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts
index ba7f2ef57..e6394b5e2 100644
--- a/src/app/api/medications/[id]/compliance/route.ts
+++ b/src/app/api/medications/[id]/compliance/route.ts
@@ -7,11 +7,7 @@ import {
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
-import {
- cachedSwr,
- caches,
- type ServerCache,
-} from "@/lib/cache/server-cache";
+import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
type RouteParams = { params: Promise<{ id: string }> };
diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts
index c011c8f72..20640c271 100644
--- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts
@@ -117,7 +117,9 @@ beforeEach(() => {
},
],
} as never);
- vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+ [] as never,
+ );
});
describe("GET /api/medications/[id]/dose-history", () => {
diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts
index 071bc28c1..1f01100bb 100644
--- a/src/app/api/medications/[id]/dose-history/route.ts
+++ b/src/app/api/medications/[id]/dose-history/route.ts
@@ -20,11 +20,7 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
-import {
- apiError,
- apiSuccess,
- returnAllZodIssues,
-} from "@/lib/api-response";
+import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts
index 6c2393d1d..5e6bbad29 100644
--- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts
@@ -17,7 +17,9 @@ vi.mock("@/lib/db", () => ({
// v1.17.0 — the GET reads the caller's notificationPrefs to resolve
// the reorder-lead-aware low-stock trigger. Default null = the
// documented defaults (alert on, 7-day floor, 10-day lead).
- user: { findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }) },
+ user: {
+ findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }),
+ },
medicationDoseChange: { create: vi.fn() },
medicationInventoryEvent: { create: vi.fn() },
},
diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts
index b6523afd9..ab3de40cb 100644
--- a/src/app/api/medications/[id]/glp1/route.ts
+++ b/src/app/api/medications/[id]/glp1/route.ts
@@ -38,6 +38,7 @@ import {
resolveLowStockRunwayDays,
resolveReorderLeadDays,
} from "@/lib/validations/notification-prefs";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -285,6 +286,11 @@ export const POST = apiHandler(
meta: { medication_id: id, doseValue, doseUnit },
});
+ // A dose change shifts the daily-dose estimate the list payload's
+ // runway derivation uses. Hard-evict the medications + compliance
+ // buckets so the card reflects the new runway on the next read.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess({ doseChange: created }, 201);
}
@@ -324,6 +330,11 @@ export const POST = apiHandler(
meta: { medication_id: id, delta },
});
+ // A legacy ledger delta changes the stock the list payload reports
+ // for a medication with no per-item containers. Hard-evict so the
+ // card reflects it on the next read.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess({ inventory: created }, 201);
},
);
diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
index e5f6bab07..45e17af7f 100644
--- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
@@ -269,7 +269,12 @@ import { localHmAsUtc } from "@/lib/tz/local-day";
const TZ = "Europe/Berlin";
const SESSION_TZ = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "tester", role: "USER" as const, timezone: TZ },
+ user: {
+ id: "user-1",
+ username: "tester",
+ role: "USER" as const,
+ timezone: TZ,
+ },
};
const ATTR_DAY = new Date("2026-06-05T12:00:00Z");
function at(h: number, m: number): Date {
@@ -541,9 +546,7 @@ describe("PUT — v1.15.18 band re-attribution", () => {
const body = (await res.json()) as {
meta?: { errorCode?: string };
};
- expect(body.meta?.errorCode).toBe(
- "medications.intake.force_slot.occupied",
- );
+ expect(body.meta?.errorCode).toBe("medications.intake.force_slot.occupied");
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
@@ -588,9 +591,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("an edit into taken consumes on the row (no slot move)", async () => {
- const { consumeForIntake, restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake, restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
// PUT lookup — pending row anchored on the 07:00 slot.
.mockResolvedValueOnce({
@@ -610,7 +612,10 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
skipped: false,
} as never);
- const res = await PUT(putReq({ takenAt: at(7, 5).toISOString() }), ROUTE_CTX);
+ const res = await PUT(
+ putReq({ takenAt: at(7, 5).toISOString() }),
+ ROUTE_CTX,
+ );
expect(res.status).toBe(200);
expect(consumeForIntake).toHaveBeenCalledTimes(1);
expect(consumeForIntake).toHaveBeenCalledWith(
@@ -624,9 +629,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("an in-place time correction on an already-taken row never consumes (pre-stamp legacy rows must not retro-charge)", async () => {
- const { consumeForIntake, restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake, restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
// PUT lookup — a row already taken BEFORE the edit, stamp NULL
// (pre-v1.16.10: its stock moved through the legacy hook at take
@@ -658,9 +662,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("a taken → skipped edit restores the row's stamp and never consumes", async () => {
- const { consumeForIntake, restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake, restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
.mockResolvedValueOnce({
id: "e1",
@@ -688,9 +691,8 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
});
it("a slot-moving taken edit refunds the old row before tombstoning and consumes the converged row — net one", async () => {
- const { consumeForIntake, restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake, restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findFirst)
.mockResolvedValueOnce({
id: "e1",
@@ -730,17 +732,16 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
expect.objectContaining({ eventId: "e2" }),
);
// The refund runs BEFORE the tombstone write.
- const restoreOrder = vi.mocked(restoreForIntake).mock
+ const restoreOrder =
+ vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
+ const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update).mock
.invocationCallOrder[0];
- const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
- .mock.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(tombstoneOrder);
});
it("DELETE refunds the row's stamp before the tombstone", async () => {
- const { consumeForIntake, restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake, restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
id: "e1",
userId: "user-1",
@@ -768,10 +769,10 @@ describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
expect.objectContaining({ userId: "user-1", eventId: "e1" }),
);
expect(consumeForIntake).not.toHaveBeenCalled();
- const restoreOrder = vi.mocked(restoreForIntake).mock
+ const restoreOrder =
+ vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
+ const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update).mock
.invocationCallOrder[0];
- const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
- .mock.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(tombstoneOrder);
});
});
diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts
index e2cb4676e..b678fe833 100644
--- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts
@@ -226,7 +226,10 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
}
it("GET surfaces TWO simultaneous validation errors", async () => {
- const res = await GET(makeRequest("status=junk&sortBy=garbage"), ROUTE_PARAMS);
+ const res = await GET(
+ makeRequest("status=junk&sortBy=garbage"),
+ ROUTE_PARAMS,
+ );
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
@@ -284,7 +287,9 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
expect(prisma.auditLog.create).toHaveBeenCalled();
const actions = vi
.mocked(prisma.auditLog.create)
- .mock.calls.map((c) => (c[0] as { data: { action: string } }).data.action);
+ .mock.calls.map(
+ (c) => (c[0] as { data: { action: string } }).data.action,
+ );
expect(actions).toContain("medications.intake.list.validation-failed");
expect(actions).toContain("medications.intake.create.validation-failed");
});
@@ -413,9 +418,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
});
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
- const { consumeForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake } =
+ await import("@/lib/medications/inventory/consumption");
// Resolver load → scheduled med.
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
SCHEDULED_MED as never,
@@ -463,9 +467,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
});
it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => {
- const { consumeForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake } =
+ await import("@/lib/medications/inventory/consumption");
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
SCHEDULED_MED as never,
);
@@ -626,9 +629,8 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica
// Inventory consumption fires exactly as for a scheduled medication —
// the seam is medication-level (eventId + intakeAt), no schedule input.
- const { consumeForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake } =
+ await import("@/lib/medications/inventory/consumption");
expect(consumeForIntake).toHaveBeenCalledTimes(1);
expect(consumeForIntake).toHaveBeenCalledWith(
expect.objectContaining({
@@ -674,9 +676,8 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica
const res = await POST(postReq({ skipped: true }), ROUTE_PARAMS);
expect(res.status).toBe(201);
- const { consumeForIntake, restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake, restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
expect(consumeForIntake).not.toHaveBeenCalled();
expect(restoreForIntake).toHaveBeenCalledTimes(1);
});
diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts
index 2a93e23ee..bcbf33582 100644
--- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts
@@ -100,11 +100,14 @@ const SESSION_OK = {
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function postWithBody(body: unknown): NextRequest {
- return new NextRequest("http://localhost/api/medications/med-1/intake/bulk-delete", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
+ return new NextRequest(
+ "http://localhost/api/medications/med-1/intake/bulk-delete",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
}
beforeEach(() => {
@@ -119,7 +122,9 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
- vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+ [] as never,
+ );
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
@@ -142,10 +147,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
new Response(null, { status: 404 }) as never,
);
- const res = await POST(
- postWithBody({ eventIds: ["evt-1"] }),
- ROUTE_PARAMS,
- );
+ const res = await POST(postWithBody({ eventIds: ["evt-1"] }), ROUTE_PARAMS);
expect(res.status).toBe(404);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
@@ -226,10 +228,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
remaining: 0,
resetAt: Date.now() + 60_000,
});
- const res = await POST(
- postWithBody({ eventIds: ["evt-a"] }),
- ROUTE_PARAMS,
- );
+ const res = await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
@@ -248,9 +247,8 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => {
it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => {
- const { restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
@@ -275,10 +273,10 @@ describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory r
expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }),
);
// The refund runs BEFORE the soft-delete sweep.
- const restoreOrder = vi.mocked(restoreForIntake).mock
+ const restoreOrder =
+ vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
+ const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.invocationCallOrder[0];
- const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany)
- .mock.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(sweepOrder);
});
});
diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts
index 351f3c821..3123799c9 100644
--- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts
@@ -59,9 +59,12 @@ const SESSION_OK = {
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function deleteReq(): NextRequest {
- return new NextRequest("http://localhost/api/medications/med-1/intake/purge", {
- method: "DELETE",
- });
+ return new NextRequest(
+ "http://localhost/api/medications/med-1/intake/purge",
+ {
+ method: "DELETE",
+ },
+ );
}
beforeEach(() => {
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
index 94f1aa934..68bc43f27 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
@@ -30,7 +30,7 @@ vi.mock("@/lib/medications/inventory/service", () => ({
// v1.16.12 — the route serialises its Decimal unit columns to numbers
// on the way out; a passthrough keeps these update-logic assertions
// focused on the Prisma call, not the response shape.
- serializeInventoryItem: (item: T) => item,
+ serializeInventoryItem: (item: T) => item,
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
@@ -63,14 +63,11 @@ const SESSION_OK = {
};
function patchReq(body: unknown): NextRequest {
- return new NextRequest(
- "http://localhost/api/medications/m1/inventory/i1",
- {
- method: "PATCH",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
+ return new NextRequest("http://localhost/api/medications/m1/inventory/i1", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
}
const ROUTE_CTX = {
@@ -166,7 +163,9 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc
const res = await PATCH(patchReq({ unitsRemaining: 0 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
- .calls[0][0] as unknown as { data: { unitsRemaining: number; state: string } };
+ .calls[0][0] as unknown as {
+ data: { unitsRemaining: number; state: string };
+ };
expect(update.data.unitsRemaining).toBe(0);
expect(update.data.state).toBe("USED_UP");
});
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
index 99ce9346d..2125b3c8f 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
@@ -36,6 +36,7 @@ import {
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
@@ -107,7 +108,10 @@ export const PATCH = apiHandler(
// state-machine re-run below owns every state consequence (0 ⇒
// USED_UP, a raise out of 0 re-evaluates against the expiry
// clocks).
- nextUnitsRemaining = Math.min(unitsRemaining, Number(existing.unitsTotal));
+ nextUnitsRemaining = Math.min(
+ unitsRemaining,
+ Number(existing.unitsTotal),
+ );
}
if (markAsUsedUp === true) {
@@ -170,6 +174,12 @@ export const PATCH = apiHandler(
meta: { medication_id: id, prev: existing.state, next: updated.state },
});
+ // A stock correction / first-use / used-up flip changes the
+ // dose-derived stock the medications-list payload carries, which the
+ // card and table render. Hard-evict so the supply shows on the next
+ // read instead of after the `cachedSwr` stale window.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess(serializeInventoryItem(updated));
},
);
@@ -204,6 +214,11 @@ export const DELETE = apiHandler(
meta: { medication_id: id },
});
+ // Removing a container changes the dose-derived stock the
+ // medications-list payload carries. Hard-evict so the card / table
+ // reflect the drop on the very next read.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess({ id: itemId, deleted: true });
},
);
diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
index d27144f42..a0b0ea611 100644
--- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
@@ -31,6 +31,10 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserMedications: vi.fn(),
+}));
+
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -49,6 +53,7 @@ import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -173,6 +178,13 @@ describe("POST /api/medications/[id]/inventory", () => {
expiresAt: new Date(printed),
}),
});
+ // Staleness regression: the dose-derived stock the medications-list
+ // payload carries must hard-evict on a registration so the card / table
+ // reflect the new supply on the very next read (not after the SWR
+ // stale window). Mark-stale would serve the pre-write stock.
+ expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
+ evict: true,
+ });
});
it("returns 429 when the rate limit is exceeded", async () => {
@@ -271,6 +283,11 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
state: "USED_UP",
unitsRemaining: 0,
});
+ // Staleness regression: a stock-affecting correction must hard-evict
+ // the medications-list bucket so the card reflects the change next read.
+ expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
+ evict: true,
+ });
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
@@ -343,15 +360,23 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
} as never);
const res = await DELETE(
- new NextRequest("http://localhost/api/medications/med-1/inventory/inv-1", {
- method: "DELETE",
- }),
+ new NextRequest(
+ "http://localhost/api/medications/med-1/inventory/inv-1",
+ {
+ method: "DELETE",
+ },
+ ),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
+ // Staleness regression: removing a container drops the dose-derived
+ // stock the list payload reports — hard-evict so it reflects next read.
+ expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
+ evict: true,
+ });
});
});
diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts
index 79bbc9b95..68978f3c1 100644
--- a/src/app/api/medications/[id]/inventory/route.ts
+++ b/src/app/api/medications/[id]/inventory/route.ts
@@ -33,6 +33,7 @@ import {
serializeInventoryItem,
} from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
type RouteParams = { params: Promise<{ id: string }> };
@@ -134,6 +135,14 @@ export const POST = apiHandler(
meta: { medication_id: id },
});
+ // A registered container changes the dose-derived stock the
+ // medications-list payload carries (`stockUnitsRemaining` /
+ // `stockDosesRemaining`), which the card and table render. Hard-evict
+ // the per-user medications + compliance buckets so the supply shows on
+ // the very next read — a mark-stale would let the `cachedSwr` list
+ // serve the pre-write stock for the rest of the stale window.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess(serializeInventoryItem(created), 201);
},
);
diff --git a/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts b/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts
index 4e9046dd7..f33f686b2 100644
--- a/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts
@@ -109,7 +109,8 @@ describe("PUT /api/medications/[id]/phase-config — F-1 H-4 field-by-field upse
};
await PUT(putReq(body), ROUTE_PARAMS);
- const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock.calls[0][0];
+ const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock
+ .calls[0][0];
// create payload contains exactly the eight enumerated phase fields
// plus the `medicationId` route binding — nothing else.
expect(Object.keys(callArg.create).sort()).toEqual([
@@ -150,8 +151,13 @@ describe("PUT /api/medications/[id]/phase-config — F-1 H-4 field-by-field upse
maliciousField: "owned",
};
await PUT(putReq(body), ROUTE_PARAMS);
- const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock.calls[0][0];
- expect((callArg.create as Record).maliciousField).toBeUndefined();
- expect((callArg.update as Record).maliciousField).toBeUndefined();
+ const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock
+ .calls[0][0];
+ expect(
+ (callArg.create as Record).maliciousField,
+ ).toBeUndefined();
+ expect(
+ (callArg.update as Record).maliciousField,
+ ).toBeUndefined();
});
});
diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts
index 1edbdcd69..c7fe4ecb3 100644
--- a/src/app/api/medications/[id]/route.ts
+++ b/src/app/api/medications/[id]/route.ts
@@ -369,7 +369,9 @@ export const PUT = apiHandler(
// Invariant 3 — dual-write timesOfDay from windowStart.
const effectiveTimesOfDay =
- s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart];
+ s.timesOfDay && s.timesOfDay.length > 0
+ ? s.timesOfDay
+ : [s.windowStart];
// Invariant 4 — window / times consistency. See
// `reconcileScheduleWindow`: a times-only edit that echoes the
@@ -571,11 +573,18 @@ export const PUT = apiHandler(
// key). Best-effort like every other rollup write-hook: a failure
// is annotated, never blocks the schedule replace.
const staleDayKeys = new Set(
- tombstoned.map((row) => dayKeyForScheduledFor(row.scheduledFor, userTz)),
+ tombstoned.map((row) =>
+ dayKeyForScheduledFor(row.scheduledFor, userTz),
+ ),
);
for (const dayKey of staleDayKeys) {
try {
- await recomputeMedicationComplianceForDay(user.id, id, dayKey, userTz);
+ await recomputeMedicationComplianceForDay(
+ user.id,
+ id,
+ dayKey,
+ userTz,
+ );
} catch (err) {
annotate({
meta: {
diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
index bc9011a47..823f07ed6 100644
--- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
+++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts
@@ -147,10 +147,10 @@ export const PATCH = apiHandler(
// `createdAt`). The edited era's own recorded end counts — an
// ARCHIVED era adjacent to the live plan may keep its boundary,
// but no correction may extend into tracked live history.
- const liveBoundary = [target.validUntil, ...others.map((r) => r.validUntil)].reduce(
- (latest, v) => (v > latest ? v : latest),
- med.createdAt,
- );
+ const liveBoundary = [
+ target.validUntil,
+ ...others.map((r) => r.validUntil),
+ ].reduce((latest, v) => (v > latest ? v : latest), med.createdAt);
if (validUntil.getTime() > liveBoundary.getTime()) {
return {
ok: false as const,
@@ -290,10 +290,7 @@ export const DELETE = apiHandler(
}
if (revision.source !== "MANUAL") {
- return apiError(
- "Only manually added schedule eras can be deleted",
- 409,
- );
+ return apiError("Only manually added schedule eras can be deleted", 409);
}
// Deleting a correction restores the archived original it had
diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts
index adcf90a9a..93986e9e1 100644
--- a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts
@@ -325,8 +325,8 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
const res = await POST(postReq(VALID_BODY), ROUTE_CTX);
expect(res.status).toBe(201);
- const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
- .mock.calls[0][0] as unknown as {
+ const createArgs = vi.mocked(prisma.medicationScheduleRevision.create).mock
+ .calls[0][0] as unknown as {
data: {
medicationId: string;
source: string;
@@ -374,8 +374,8 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
ROUTE_CTX,
);
expect(res.status).toBe(201);
- const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
- .mock.calls[0][0] as unknown as {
+ const createArgs = vi.mocked(prisma.medicationScheduleRevision.create).mock
+ .calls[0][0] as unknown as {
data: { payload: Array<{ timesOfDay: string[] }> };
};
// The Zod transform dedupes + sorts — a double-tapped chip must
@@ -386,9 +386,7 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
it("404s when the revision belongs to another medication", async () => {
- vi.mocked(
- prisma.medicationScheduleRevision.findUnique,
- ).mockResolvedValue({
+ vi.mocked(prisma.medicationScheduleRevision.findUnique).mockResolvedValue({
id: "rev-x",
medicationId: "other-med",
source: "MANUAL",
@@ -399,9 +397,7 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
});
it("409s for a write-path archive", async () => {
- vi.mocked(
- prisma.medicationScheduleRevision.findUnique,
- ).mockResolvedValue({
+ vi.mocked(prisma.medicationScheduleRevision.findUnique).mockResolvedValue({
id: "rev-a",
medicationId: "m1",
source: "ARCHIVED",
@@ -412,9 +408,7 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
});
it("deletes a MANUAL era, restores a superseded original, and enqueues the rollup backfill", async () => {
- vi.mocked(
- prisma.medicationScheduleRevision.findUnique,
- ).mockResolvedValue({
+ vi.mocked(prisma.medicationScheduleRevision.findUnique).mockResolvedValue({
id: "rev-m",
medicationId: "m1",
source: "MANUAL",
@@ -422,9 +416,9 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue(
{} as never,
);
- vi.mocked(
- prisma.medicationScheduleRevision.updateMany,
- ).mockResolvedValue({ count: 0 } as never);
+ vi.mocked(prisma.medicationScheduleRevision.updateMany).mockResolvedValue({
+ count: 0,
+ } as never);
const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m"));
expect(res.status).toBe(200);
const json = await res.json();
@@ -452,9 +446,7 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
};
function mockTarget(overrides: Record = {}) {
- vi.mocked(
- prisma.medicationScheduleRevision.findUnique,
- ).mockResolvedValue({
+ vi.mocked(prisma.medicationScheduleRevision.findUnique).mockResolvedValue({
id: "rev-t",
medicationId: "m1",
source: "MANUAL",
@@ -466,20 +458,14 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
it("404s when the revision belongs to another medication", async () => {
mockTarget({ medicationId: "other-med" });
- const res = await PATCH(
- patchReq("rev-t", PATCH_BODY),
- deleteCtx("rev-t"),
- );
+ const res = await PATCH(patchReq("rev-t", PATCH_BODY), deleteCtx("rev-t"));
expect(res.status).toBe(404);
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
});
it("409s when the era has already been corrected", async () => {
mockTarget({ source: "ARCHIVED", supersededByRevisionId: "rev-fix" });
- const res = await PATCH(
- patchReq("rev-t", PATCH_BODY),
- deleteCtx("rev-t"),
- );
+ const res = await PATCH(patchReq("rev-t", PATCH_BODY), deleteCtx("rev-t"));
expect(res.status).toBe(409);
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
@@ -494,7 +480,10 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
},
] as never);
const res = await PATCH(
- patchReq("rev-t", { ...PATCH_BODY, validUntil: "2025-07-01T00:00:00.000Z" }),
+ patchReq("rev-t", {
+ ...PATCH_BODY,
+ validUntil: "2025-07-01T00:00:00.000Z",
+ }),
deleteCtx("rev-t"),
);
expect(res.status).toBe(422);
@@ -528,17 +517,14 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
source: "MANUAL",
} as never);
- const res = await PATCH(
- patchReq("rev-t", PATCH_BODY),
- deleteCtx("rev-t"),
- );
+ const res = await PATCH(patchReq("rev-t", PATCH_BODY), deleteCtx("rev-t"));
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.id).toBe("rev-t");
expect(json.data.entries[0].timesOfDay).toEqual(["08:00", "20:00"]);
- const updateArgs = vi.mocked(prisma.medicationScheduleRevision.update)
- .mock.calls[0][0] as unknown as {
+ const updateArgs = vi.mocked(prisma.medicationScheduleRevision.update).mock
+ .calls[0][0] as unknown as {
where: { id: string };
data: { payload: Array<{ timesOfDay: string[]; rrule: string }> };
};
@@ -567,10 +553,7 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
{} as never,
);
- const res = await PATCH(
- patchReq("rev-t", PATCH_BODY),
- deleteCtx("rev-t"),
- );
+ const res = await PATCH(patchReq("rev-t", PATCH_BODY), deleteCtx("rev-t"));
expect(res.status).toBe(200);
const json = await res.json();
// The response carries the correction, which now takes the
@@ -578,8 +561,8 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
expect(json.data.id).toBe("rev-fix");
expect(json.data.source).toBe("MANUAL");
- const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
- .mock.calls[0][0] as unknown as {
+ const createArgs = vi.mocked(prisma.medicationScheduleRevision.create).mock
+ .calls[0][0] as unknown as {
data: { source: string; payload: Array<{ timesOfDay: string[] }> };
};
expect(createArgs.data.source).toBe("MANUAL");
diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts
index c78500192..ee5a1ec3c 100644
--- a/src/app/api/medications/[id]/schedule-revisions/route.ts
+++ b/src/app/api/medications/[id]/schedule-revisions/route.ts
@@ -65,7 +65,8 @@ function summariseEntries(payload: unknown): Array<{
: [],
label: typeof e.label === "string" ? e.label : null,
dose: typeof e.dose === "string" ? e.dose : null,
- scheduleType: typeof e.scheduleType === "string" ? e.scheduleType : "SCHEDULED",
+ scheduleType:
+ typeof e.scheduleType === "string" ? e.scheduleType : "SCHEDULED",
};
});
}
diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts
index 015b19e08..edff25b8c 100644
--- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts
@@ -142,7 +142,8 @@ describe("GET /api/medications/[id]/side-effects", () => {
{ params: Promise.resolve({ id: "med-1" }) },
);
- const call = vi.mocked(prisma.medicationSideEffect.findMany).mock.calls[0][0];
+ const call = vi.mocked(prisma.medicationSideEffect.findMany).mock
+ .calls[0][0];
expect(call).toMatchObject({
where: {
userId: "user-1",
diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts
index af28f18f0..ebae86353 100644
--- a/src/app/api/medications/__tests__/route.test.ts
+++ b/src/app/api/medications/__tests__/route.test.ts
@@ -94,9 +94,9 @@ beforeEach(() => {
// Default happy-path stub: return the data shape the caller passed in
// so test assertions can inspect what the route actually forwarded
// to Prisma without re-implementing the create-returning DB.
- vi.mocked(prisma.medication.create).mockImplementation((async (
- args: { data: Record },
- ) => ({
+ vi.mocked(prisma.medication.create).mockImplementation((async (args: {
+ data: Record;
+ }) => ({
id: "med-1",
userId: "user-1",
...args.data,
@@ -463,9 +463,7 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
details: { issues: Array<{ message: string }> };
};
expect(
- body.details.issues.some((i) =>
- i.message.includes("mutually exclusive"),
- ),
+ body.details.issues.some((i) => i.message.includes("mutually exclusive")),
).toBe(true);
});
@@ -494,8 +492,8 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
const body = (await res.json()) as {
details: { issues: Array<{ path: string; message: string }> };
};
- expect(
- body.details.issues.some((i) => i.path.includes("schedules")),
- ).toBe(true);
+ expect(body.details.issues.some((i) => i.path.includes("schedules"))).toBe(
+ true,
+ );
});
});
diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts
index d4d66f175..6ee1eebfa 100644
--- a/src/app/api/medications/compliance/route.ts
+++ b/src/app/api/medications/compliance/route.ts
@@ -9,11 +9,7 @@ import {
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
-import {
- cachedSwr,
- caches,
- type ServerCache,
-} from "@/lib/cache/server-cache";
+import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
/**
diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts
index 34006be4a..09d7fbcf3 100644
--- a/src/app/api/medications/extract/__tests__/route.test.ts
+++ b/src/app/api/medications/extract/__tests__/route.test.ts
@@ -52,10 +52,9 @@ vi.mock("@/lib/ai/consent-guard", () => ({
}));
vi.mock("@/lib/ai/provider-runner", async () => {
- const actual =
- await vi.importActual(
- "@/lib/ai/provider-runner",
- );
+ const actual = await vi.importActual<
+ typeof import("@/lib/ai/provider-runner")
+ >("@/lib/ai/provider-runner");
return {
...actual,
runRawCompletionWithFallback: vi.fn(),
@@ -65,17 +64,17 @@ vi.mock("@/lib/ai/provider-runner", async () => {
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
- eventStorage: { getStore: () => undefined, run: (_s: unknown, f: () => unknown) => f() },
+ eventStorage: {
+ getStore: () => undefined,
+ run: (_s: unknown, f: () => unknown) => f(),
+ },
}));
import { POST } from "../route";
import { requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { enforceBudget } from "@/lib/ai/coach/budget";
-import {
- resolveProvider,
- resolveProviderChain,
-} from "@/lib/ai/provider";
+import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
@@ -136,9 +135,7 @@ describe("POST /api/medications/extract — rate limit", () => {
remaining: 0,
resetAt: Date.now() + 60_000,
});
- const res = await POST(
- postReq({ text: "Mounjaro 5mg weekly" }) as never,
- );
+ const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/Too many requests/i);
@@ -233,9 +230,7 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
type: "none",
} as never);
- const res = await POST(
- postReq({ text: "Mounjaro 5mg weekly" }) as never,
- );
+ const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
expect(res.status).toBe(503);
});
@@ -252,9 +247,7 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
fallbackHops: [],
});
- const res = await POST(
- postReq({ text: "Mounjaro 5mg weekly" }) as never,
- );
+ const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
expect(res.status).toBe(502);
});
});
diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts
index ae320ccb6..b26b14a46 100644
--- a/src/app/api/medications/intake/__tests__/route.test.ts
+++ b/src/app/api/medications/intake/__tests__/route.test.ts
@@ -466,7 +466,9 @@ describe("POST /api/medications/intake", () => {
scheduleRevisions: [],
} as never);
vi.mocked(prisma.$transaction).mockImplementation(async (arg: unknown) =>
- Array.isArray(arg) ? Promise.all(arg) : (arg as (c: unknown) => unknown)(prisma),
+ Array.isArray(arg)
+ ? Promise.all(arg)
+ : (arg as (c: unknown) => unknown)(prisma),
);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
id: "e1",
@@ -561,10 +563,10 @@ describe("POST /api/medications/intake", () => {
expect.objectContaining({ eventId: "e2" }),
);
// The refund runs BEFORE the tombstone write on the source row.
- const restoreOrder = vi.mocked(restoreForIntake).mock
+ const restoreOrder =
+ vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
+ const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update).mock
.invocationCallOrder[0];
- const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
- .mock.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(tombstoneOrder);
});
});
diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts
index 2490e4d98..0a348d87b 100644
--- a/src/app/api/medications/intake/route.ts
+++ b/src/app/api/medications/intake/route.ts
@@ -695,7 +695,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// outstanding, so it neither clears the reminder nor changes the badge.
if (status === "taken" || status === "skipped") {
void (async () => {
- const badgeCount = await countOutstandingDosesToday(user.id, userTzForHook);
+ const badgeCount = await countOutstandingDosesToday(
+ user.id,
+ userTzForHook,
+ );
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: existing.medicationId,
diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts
index a71a017a0..83383eb48 100644
--- a/src/app/api/medications/route.ts
+++ b/src/app/api/medications/route.ts
@@ -63,90 +63,90 @@ async function buildMedicationsList(
usableStock,
inventoryCounts,
] = await Promise.all([
- prisma.medication.findMany({
- where: { userId },
- include: { schedules: true },
- orderBy: { createdAt: "desc" },
- }),
- prisma.medicationIntakeEvent.groupBy({
- by: ["medicationId"],
- // v1.7.0 sync — exclude tombstoned rows from the last-taken map.
- where: {
- userId,
- deletedAt: null,
- skipped: false,
- takenAt: { not: null },
- },
- _max: { takenAt: true },
- }),
- prisma.medicationIntakeEvent.groupBy({
- by: ["medicationId"],
- // v1.7.0 sync — exclude tombstoned rows from the today-count map.
- // v1.16.9 — count only ACTIONED rows (taken or skipped). The
- // dashboard projector mints pending rows for every slot of the
- // day, so counting all rows made `todayEventCount` cover every
- // passed dose after any dashboard visit — and the cards'
- // overdue-pill suppression (`todayEventCount < passedDoseCount`)
- // went dark nondeterministically for genuinely overdue doses.
- where: {
- userId,
- deletedAt: null,
- scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
- OR: [{ takenAt: { not: null } }, { skipped: true }],
- },
- _count: { id: true },
- }),
- prisma.medicationIntakeEvent.findMany({
- where: {
- userId,
- deletedAt: null,
- scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
- OR: [
- { takenAt: { not: null } },
- { skipped: true },
- { autoMissed: true },
- ],
- },
- // v1.16.9 — `takenAt` rides along so the ad-hoc shape
- // (`scheduledFor === takenAt`) is detectable: such a row must not
- // ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
- // genuinely-due 20:00 dose).
- select: { medicationId: true, scheduledFor: true, takenAt: true },
- }),
- // v1.16.4 — current-era floor per medication: the newest revision's
- // `validUntil` is where the LIVE schedule rows became valid. The
- // open-overdue search mints from the live rows, so it must not reach
- // past this boundary into a previous era's cadence.
- prisma.medicationScheduleRevision.groupBy({
- by: ["medicationId"],
- // Superseded rows are audit records — a correction may have
- // shortened the era, so the boundary reads only active rows.
- where: { medication: { userId }, supersededByRevisionId: null },
- _max: { validUntil: true },
- }),
- // v1.16.10 — usable stock per medication (one batched aggregate,
- // not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE
- // containers with units left — the same usable-container filter
- // the GLP-1 details endpoint applies. Feeds the list payload's
- // `stockUnitsRemaining` / `stockDosesRemaining` for the table view.
- prisma.medicationInventoryItem.groupBy({
- by: ["medicationId"],
- where: {
- userId,
- state: { in: ["ACTIVE", "IN_USE"] },
- unitsRemaining: { gt: 0 },
- },
- _sum: { unitsRemaining: true },
- }),
- // …and the any-state item count, so a medication whose containers
- // are all used up / expired reads as stock 0 (tracking is ON, the
- // supply ran out) instead of null (tracking off).
- prisma.medicationInventoryItem.groupBy({
- by: ["medicationId"],
- where: { userId },
- _count: { id: true },
- }),
- ]);
+ prisma.medication.findMany({
+ where: { userId },
+ include: { schedules: true },
+ orderBy: { createdAt: "desc" },
+ }),
+ prisma.medicationIntakeEvent.groupBy({
+ by: ["medicationId"],
+ // v1.7.0 sync — exclude tombstoned rows from the last-taken map.
+ where: {
+ userId,
+ deletedAt: null,
+ skipped: false,
+ takenAt: { not: null },
+ },
+ _max: { takenAt: true },
+ }),
+ prisma.medicationIntakeEvent.groupBy({
+ by: ["medicationId"],
+ // v1.7.0 sync — exclude tombstoned rows from the today-count map.
+ // v1.16.9 — count only ACTIONED rows (taken or skipped). The
+ // dashboard projector mints pending rows for every slot of the
+ // day, so counting all rows made `todayEventCount` cover every
+ // passed dose after any dashboard visit — and the cards'
+ // overdue-pill suppression (`todayEventCount < passedDoseCount`)
+ // went dark nondeterministically for genuinely overdue doses.
+ where: {
+ userId,
+ deletedAt: null,
+ scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
+ OR: [{ takenAt: { not: null } }, { skipped: true }],
+ },
+ _count: { id: true },
+ }),
+ prisma.medicationIntakeEvent.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
+ OR: [
+ { takenAt: { not: null } },
+ { skipped: true },
+ { autoMissed: true },
+ ],
+ },
+ // v1.16.9 — `takenAt` rides along so the ad-hoc shape
+ // (`scheduledFor === takenAt`) is detectable: such a row must not
+ // ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
+ // genuinely-due 20:00 dose).
+ select: { medicationId: true, scheduledFor: true, takenAt: true },
+ }),
+ // v1.16.4 — current-era floor per medication: the newest revision's
+ // `validUntil` is where the LIVE schedule rows became valid. The
+ // open-overdue search mints from the live rows, so it must not reach
+ // past this boundary into a previous era's cadence.
+ prisma.medicationScheduleRevision.groupBy({
+ by: ["medicationId"],
+ // Superseded rows are audit records — a correction may have
+ // shortened the era, so the boundary reads only active rows.
+ where: { medication: { userId }, supersededByRevisionId: null },
+ _max: { validUntil: true },
+ }),
+ // v1.16.10 — usable stock per medication (one batched aggregate,
+ // not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE
+ // containers with units left — the same usable-container filter
+ // the GLP-1 details endpoint applies. Feeds the list payload's
+ // `stockUnitsRemaining` / `stockDosesRemaining` for the table view.
+ prisma.medicationInventoryItem.groupBy({
+ by: ["medicationId"],
+ where: {
+ userId,
+ state: { in: ["ACTIVE", "IN_USE"] },
+ unitsRemaining: { gt: 0 },
+ },
+ _sum: { unitsRemaining: true },
+ }),
+ // …and the any-state item count, so a medication whose containers
+ // are all used up / expired reads as stock 0 (tracking is ON, the
+ // supply ran out) instead of null (tracking off).
+ prisma.medicationInventoryItem.groupBy({
+ by: ["medicationId"],
+ where: { userId },
+ _count: { id: true },
+ }),
+ ]);
const resolvedSlotsByMedId = new Map();
for (const e of resolvedEvents) {
@@ -158,7 +158,8 @@ async function buildMedicationsList(
const eraStartByMedId = new Map();
for (const f of eraFloors) {
- if (f._max.validUntil) eraStartByMedId.set(f.medicationId, f._max.validUntil);
+ if (f._max.validUntil)
+ eraStartByMedId.set(f.medicationId, f._max.validUntil);
}
const lastTakenAtByMedicationId = Object.fromEntries(
@@ -354,10 +355,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const s = scheduleInputs[0];
- if (
- s &&
- (s.rrule !== undefined || s.rollingIntervalDays !== undefined)
- ) {
+ if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) {
return apiError(
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
422,
diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts
index ab8bd8466..0f3f4e5c3 100644
--- a/src/app/api/meta/capabilities/__tests__/route.test.ts
+++ b/src/app/api/meta/capabilities/__tests__/route.test.ts
@@ -143,9 +143,11 @@ describe("GET /api/meta/capabilities — drift guards", () => {
it("computedScores are the three persisted wellness scores", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
- expect([...body.data.ingest.computedScores].sort()).toEqual(
- ["RECOVERY_SCORE", "STRAIN_SCORE", "STRESS_SCORE"],
- );
+ expect([...body.data.ingest.computedScores].sort()).toEqual([
+ "RECOVERY_SCORE",
+ "STRAIN_SCORE",
+ "STRESS_SCORE",
+ ]);
});
it("fhir constants mirror the FHIR builder source", async () => {
@@ -163,9 +165,7 @@ describe("GET /api/meta/capabilities — drift guards", () => {
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.fhir.restBaseUrl).toBe("/api/fhir");
expect(body.data.fhir.readScope).toBe(FHIR_READ_SCOPE);
- expect(body.data.fhir.resourceTypes).toEqual([
- ...FHIR_REST_RESOURCE_TYPES,
- ]);
+ expect(body.data.fhir.resourceTypes).toEqual([...FHIR_REST_RESOURCE_TYPES]);
expect(body.data.fhir.operations).toEqual([FHIR_EVERYTHING_OPERATION]);
expect(body.data.fhir.searchParams).toEqual([...FHIR_SEARCH_PARAMS]);
});
diff --git a/src/app/api/monitoring/umami-script/__tests__/route.test.ts b/src/app/api/monitoring/umami-script/__tests__/route.test.ts
index 641758698..bb89f5ee6 100644
--- a/src/app/api/monitoring/umami-script/__tests__/route.test.ts
+++ b/src/app/api/monitoring/umami-script/__tests__/route.test.ts
@@ -75,9 +75,9 @@ describe("GET /api/monitoring/umami-script — fail-soft observability", () => {
call[0]?.action?.name === "monitoring.umami_script.fetch_failed",
);
expect(failureCall).toBeTruthy();
- expect(
- (failureCall?.[0]?.meta as { reason: string }).reason,
- ).toHaveLength(200);
+ expect((failureCall?.[0]?.meta as { reason: string }).reason).toHaveLength(
+ 200,
+ );
});
it("serves the upstream script untouched on a healthy fetch (no failure action)", async () => {
diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts
index 4e0a06e1b..14693ce12 100644
--- a/src/app/api/mood-entries/__tests__/route.test.ts
+++ b/src/app/api/mood-entries/__tests__/route.test.ts
@@ -32,8 +32,8 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: { create: vi.fn() },
- $transaction: vi.fn(
- async (fn: (tx: typeof txClient) => unknown) => fn(txClient),
+ $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) =>
+ fn(txClient),
),
},
}));
@@ -111,8 +111,8 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// Re-establish the interactive-transaction passthrough after the reset.
- vi.mocked(prisma.$transaction).mockImplementation(
- async (fn: unknown) => (fn as (tx: typeof txClient) => unknown)(txClient),
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) =>
+ (fn as (tx: typeof txClient) => unknown)(txClient),
);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
vi.mocked(createTagLinks).mockResolvedValue(undefined);
@@ -148,9 +148,7 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces THREE simultaneous validation errors", async () => {
// Bad mood + bad sortBy + bad sortDir.
- const res = await GET(
- getReq("mood=junk&sortBy=garbage&sortDir=upside"),
- );
+ const res = await GET(getReq("mood=junk&sortBy=garbage&sortDir=upside"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
@@ -338,9 +336,8 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", ()
const { auditLog } = await import("@/lib/auth/audit");
expect(auditLog).not.toHaveBeenCalled();
- const { recomputeMoodBucketsForEntry } = await import(
- "@/lib/rollups/mood-rollups"
- );
+ const { recomputeMoodBucketsForEntry } =
+ await import("@/lib/rollups/mood-rollups");
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
});
});
@@ -393,7 +390,10 @@ describe("POST /api/mood-entries — rated factors (v1.12.0)", () => {
);
expect(res.status).toBe(201);
const out = (await res.json()) as {
- data: { tagKeys: string[]; ratedFactors: { key: string; rating: number }[] };
+ data: {
+ tagKeys: string[];
+ ratedFactors: { key: string; rating: number }[];
+ };
};
expect(out.data.tagKeys).toEqual(["happy"]);
expect(out.data.ratedFactors).toEqual([{ key: "factor_work", rating: 4 }]);
diff --git a/src/app/api/mood/analytics/__tests__/route.test.ts b/src/app/api/mood/analytics/__tests__/route.test.ts
index 07dc06101..8df698045 100644
--- a/src/app/api/mood/analytics/__tests__/route.test.ts
+++ b/src/app/api/mood/analytics/__tests__/route.test.ts
@@ -60,12 +60,14 @@ vi.mock("next/headers", () => ({
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-
// forget on the route so its return value is irrelevant for parity.
vi.mock("@/lib/rollups/mood-rollups", async () => {
- const actual = await vi.importActual(
- "@/lib/rollups/mood-rollups",
- );
+ const actual = await vi.importActual<
+ typeof import("@/lib/rollups/mood-rollups")
+ >("@/lib/rollups/mood-rollups");
return {
...actual,
- ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
+ ensureUserMoodRollupsFresh: vi
+ .fn()
+ .mockResolvedValue({ recomputed: false }),
};
});
diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts
index e9d3a3ded..24e5e363e 100644
--- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts
+++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts
@@ -24,12 +24,20 @@ vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
-vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
-vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
- cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
}));
import { POST } from "../custom/route";
@@ -59,19 +67,40 @@ describe("POST /api/mood/tags/custom", () => {
it("creates a custom tag and returns it (custom:true, decrypted label)", async () => {
db.moodTag.count.mockResolvedValue(3);
db.moodTag.create.mockResolvedValue({
- key: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false,
+ key: "custom:abc",
+ icon: "Heart",
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
});
- const res = await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "Date night", icon: "Heart" }));
+ const res = await POST(
+ jsonReq("http://localhost/api/mood/tags/custom", "POST", {
+ label: "Date night",
+ icon: "Heart",
+ }),
+ );
expect(res.status).toBe(201);
- const body = (await res.json()) as { data: { custom: boolean; label: string; labelKey: null; key: string } };
- expect(body.data).toMatchObject({ custom: true, label: "Date night", labelKey: null, key: "custom:abc" });
+ const body = (await res.json()) as {
+ data: { custom: boolean; label: string; labelKey: null; key: string };
+ };
+ expect(body.data).toMatchObject({
+ custom: true,
+ label: "Date night",
+ labelKey: null,
+ key: "custom:abc",
+ });
// Label is stored encrypted, never plaintext.
- expect(db.moodTag.create.mock.calls[0][0].data.labelEncrypted).toBe("enc:Date night");
+ expect(db.moodTag.create.mock.calls[0][0].data.labelEncrypted).toBe(
+ "enc:Date night",
+ );
});
it("rejects over the per-user cap with 422", async () => {
db.moodTag.count.mockResolvedValue(50);
- const res = await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }));
+ const res = await POST(
+ jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }),
+ );
expect(res.status).toBe(422);
expect(db.moodTag.create).not.toHaveBeenCalled();
});
@@ -79,21 +108,38 @@ describe("POST /api/mood/tags/custom", () => {
it("defaults to the seeded custom category without a categoryKey (v1.13 contract)", async () => {
db.moodTag.count.mockResolvedValue(0);
db.moodTag.create.mockResolvedValue({
- key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false,
+ key: "custom:x",
+ icon: null,
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
});
- await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }));
+ await POST(
+ jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }),
+ );
expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled();
- expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("mtc_custom");
+ expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe(
+ "mtc_custom",
+ );
});
it("resolves categoryKey to a seeded OR own group (v1.17.0) and 422s an unknown one", async () => {
db.moodTag.count.mockResolvedValue(0);
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
db.moodTag.create.mockResolvedValue({
- key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false,
+ key: "custom:x",
+ icon: null,
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
});
const ok = await POST(
- jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:g1" }),
+ jsonReq("http://localhost/api/mood/tags/custom", "POST", {
+ label: "X",
+ categoryKey: "customcat:g1",
+ }),
);
expect(ok.status).toBe(201);
expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("cg1");
@@ -107,7 +153,10 @@ describe("POST /api/mood/tags/custom", () => {
db.moodTagCategory.findFirst.mockResolvedValue(null);
const bad = await POST(
- jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:theirs" }),
+ jsonReq("http://localhost/api/mood/tags/custom", "POST", {
+ label: "X",
+ categoryKey: "customcat:theirs",
+ }),
);
expect(bad.status).toBe(422);
});
@@ -119,14 +168,19 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
it("404s when the key is not the caller's own custom tag", async () => {
db.moodTag.findFirst.mockResolvedValue(null);
const res = await PATCH(
- jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { isActive: false }),
+ jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
+ isActive: false,
+ }),
params("custom:x"),
);
expect(res.status).toBe(404);
});
it("404s a non-custom key without touching the DB", async () => {
- const res = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/happy"), params("happy"));
+ const res = await DELETE(
+ new NextRequest("http://localhost/api/mood/tags/custom/happy"),
+ params("happy"),
+ );
expect(res.status).toBe(404);
expect(db.moodTag.findFirst).not.toHaveBeenCalled();
});
@@ -135,22 +189,34 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
db.moodTag.update.mockResolvedValue({
- key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5,
- inverse: false, isActive: true, labelEncrypted: "enc:X",
+ key: "custom:x",
+ icon: null,
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
+ isActive: true,
+ labelEncrypted: "enc:X",
});
const res = await PATCH(
- jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "customcat:g1" }),
+ jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
+ categoryKey: "customcat:g1",
+ }),
params("custom:x"),
);
expect(res.status).toBe(200);
- expect(db.moodTag.update.mock.calls[0][0].data).toEqual({ categoryId: "cg1" });
+ expect(db.moodTag.update.mock.calls[0][0].data).toEqual({
+ categoryId: "cg1",
+ });
});
it("422s a move to an unknown / foreign group without writing", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
db.moodTagCategory.findFirst.mockResolvedValue(null);
const res = await PATCH(
- jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "ghost" }),
+ jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
+ categoryKey: "ghost",
+ }),
params("custom:x"),
);
expect(res.status).toBe(422);
@@ -159,15 +225,26 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
- const soft = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"), params("custom:abc"));
+ const soft = await DELETE(
+ new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"),
+ params("custom:abc"),
+ );
expect(soft.status).toBe(200);
- expect(db.moodTag.update).toHaveBeenCalledWith({ where: { id: "id1" }, data: { isActive: false } });
+ expect(db.moodTag.update).toHaveBeenCalledWith({
+ where: { id: "id1" },
+ data: { isActive: false },
+ });
expect(db.moodTag.delete).not.toHaveBeenCalled();
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
- const purge = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc?purge=true"), params("custom:abc"));
+ const purge = await DELETE(
+ new NextRequest(
+ "http://localhost/api/mood/tags/custom/custom:abc?purge=true",
+ ),
+ params("custom:abc"),
+ );
expect(purge.status).toBe(200);
expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
});
@@ -176,7 +253,9 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
describe("PUT /api/mood/tags/:key/hidden", () => {
it("rejects a custom key with 400 (custom tags hide via isActive)", async () => {
const res = await PUT(
- jsonReq("http://localhost/api/mood/tags/custom:abc/hidden", "PUT", { hidden: true }),
+ jsonReq("http://localhost/api/mood/tags/custom:abc/hidden", "PUT", {
+ hidden: true,
+ }),
{ params: Promise.resolve({ key: "custom:abc" }) },
);
expect(res.status).toBe(400);
@@ -185,7 +264,9 @@ describe("PUT /api/mood/tags/:key/hidden", () => {
it("upserts a hide row for a catalogue tag", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "t_sad" });
const res = await PUT(
- jsonReq("http://localhost/api/mood/tags/sad/hidden", "PUT", { hidden: true }),
+ jsonReq("http://localhost/api/mood/tags/sad/hidden", "PUT", {
+ hidden: true,
+ }),
{ params: Promise.resolve({ key: "sad" }) },
);
expect(res.status).toBe(200);
diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts
index b4441dc31..a66d250c8 100644
--- a/src/app/api/mood/tags/__tests__/groups-crud.test.ts
+++ b/src/app/api/mood/tags/__tests__/groups-crud.test.ts
@@ -29,19 +29,27 @@ const db = vi.hoisted(() => {
vi.mock("@/lib/db", () => ({
prisma: db,
- toJson: (v: T) => v,
+ toJson: (v: T) => v,
}));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
-vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
-vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
- cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
}));
import { POST } from "../groups/route";
@@ -115,7 +123,9 @@ describe("POST /api/mood/tags/groups", () => {
describe("PATCH /api/mood/tags/groups/:key", () => {
it("404s a seeded category key without touching the DB", async () => {
const res = await PATCH(
- jsonReq("http://localhost/api/mood/tags/groups/feelings", "PATCH", { label: "x" }),
+ jsonReq("http://localhost/api/mood/tags/groups/feelings", "PATCH", {
+ label: "x",
+ }),
params("feelings"),
);
expect(res.status).toBe(404);
@@ -125,7 +135,9 @@ describe("PATCH /api/mood/tags/groups/:key", () => {
it("404s when the key is not the caller's own group", async () => {
db.moodTagCategory.findFirst.mockResolvedValue(null);
const res = await PATCH(
- jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "x" }),
+ jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", {
+ label: "x",
+ }),
params("customcat:x"),
);
expect(res.status).toBe(404);
@@ -140,11 +152,15 @@ describe("PATCH /api/mood/tags/groups/:key", () => {
labelEncrypted: "enc:Neu",
});
const res = await PATCH(
- jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "Neu" }),
+ jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", {
+ label: "Neu",
+ }),
params("customcat:x"),
);
expect(res.status).toBe(200);
- const body = (await res.json()) as { data: { label: string; isActive: boolean } };
+ const body = (await res.json()) as {
+ data: { label: string; isActive: boolean };
+ };
expect(body.data).toMatchObject({ label: "Neu", isActive: true });
expect(db.moodTagCategory.update.mock.calls[0][0].data).toEqual({
labelEncrypted: "enc:Neu",
@@ -200,7 +216,9 @@ describe("DELETE /api/mood/tags/groups/:key", () => {
it("hard-deletes the emptied group row on ?purge=true", async () => {
const res = await DELETE(
- new NextRequest("http://localhost/api/mood/tags/groups/customcat:x?purge=true"),
+ new NextRequest(
+ "http://localhost/api/mood/tags/groups/customcat:x?purge=true",
+ ),
params("customcat:x"),
);
expect(res.status).toBe(200);
diff --git a/src/app/api/mood/tags/__tests__/layout.test.ts b/src/app/api/mood/tags/__tests__/layout.test.ts
index 044b1dacb..e99571dde 100644
--- a/src/app/api/mood/tags/__tests__/layout.test.ts
+++ b/src/app/api/mood/tags/__tests__/layout.test.ts
@@ -13,15 +13,23 @@ const db = vi.hoisted(() => ({
vi.mock("@/lib/db", () => ({
prisma: db,
- toJson: (v: T) => v,
+ toJson: (v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
-vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
-vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
- cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
}));
import { GET, PUT } from "../layout/route";
@@ -77,7 +85,11 @@ describe("GET /api/mood/tags/layout", () => {
const body = (await res.json()) as {
data: { groupOrder: string[]; placements: Record };
};
- expect(body.data.groupOrder).toEqual(["customcat:g1", "feelings", "custom"]);
+ expect(body.data.groupOrder).toEqual([
+ "customcat:g1",
+ "feelings",
+ "custom",
+ ]);
expect(body.data.placements).toEqual({ "customcat:g1": ["happy"] });
});
});
@@ -101,7 +113,11 @@ describe("PUT /api/mood/tags/layout", () => {
const body = (await res.json()) as {
data: { groupOrder: string[]; placements: Record };
};
- expect(body.data.groupOrder).toEqual(["custom", "feelings", "customcat:g1"]);
+ expect(body.data.groupOrder).toEqual([
+ "custom",
+ "feelings",
+ "customcat:g1",
+ ]);
expect(body.data.placements).toEqual({ feelings: ["sad"] });
});
diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts
index 96c11e5e8..568ac916a 100644
--- a/src/app/api/mood/tags/__tests__/route.test.ts
+++ b/src/app/api/mood/tags/__tests__/route.test.ts
@@ -58,8 +58,22 @@ const SESSION_OK = {
};
const CATEGORIES = [
- { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile", userId: null, labelEncrypted: null },
- { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag", userId: null, labelEncrypted: null },
+ {
+ id: "c1",
+ key: "feelings",
+ labelKey: "mood.tagCategory.feelings",
+ icon: "Smile",
+ userId: null,
+ labelEncrypted: null,
+ },
+ {
+ id: "mtc_custom",
+ key: "custom",
+ labelKey: "mood.tagCategory.custom",
+ icon: "Tag",
+ userId: null,
+ labelEncrypted: null,
+ },
];
const OWN_GROUP = {
@@ -72,9 +86,48 @@ const OWN_GROUP = {
};
const TAGS = [
- { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null },
- { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null },
- { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: "user-1", labelEncrypted: "enc:Migräne" },
+ {
+ id: "t_happy",
+ categoryId: "c1",
+ key: "happy",
+ labelKey: "mood.tag.happy",
+ icon: "Smile",
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
+ isActive: true,
+ userId: null,
+ labelEncrypted: null,
+ },
+ {
+ id: "t_sad",
+ categoryId: "c1",
+ key: "sad",
+ labelKey: "mood.tag.sad",
+ icon: "Frown",
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
+ isActive: true,
+ userId: null,
+ labelEncrypted: null,
+ },
+ {
+ id: "t_custom",
+ categoryId: "mtc_custom",
+ key: "custom:abc",
+ labelKey: "custom:abc",
+ icon: "Heart",
+ kind: "BINARY",
+ scaleMin: 1,
+ scaleMax: 5,
+ inverse: false,
+ isActive: true,
+ userId: "user-1",
+ labelEncrypted: "enc:Migräne",
+ },
];
const ARCHIVED_TAG = {
@@ -188,7 +241,14 @@ describe("GET /api/mood/tags — groups + layout + archived + usage (v1.17.0)",
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
tagFindMany.mockResolvedValue([
...TAGS,
- { ...TAGS[2], id: "t_c2", categoryId: "cg1", key: "custom:ing1", labelKey: "custom:ing1", labelEncrypted: "enc:Sitzung" },
+ {
+ ...TAGS[2],
+ id: "t_c2",
+ categoryId: "cg1",
+ key: "custom:ing1",
+ labelKey: "custom:ing1",
+ labelEncrypted: "enc:Sitzung",
+ },
]);
const res = await GET(req());
const body = (await res.json()) as Body;
diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts
index cc94d9483..bdc07e508 100644
--- a/src/app/api/mood/tags/groups/[key]/route.ts
+++ b/src/app/api/mood/tags/groups/[key]/route.ts
@@ -125,7 +125,9 @@ export const DELETE = apiHandler(
if (layout.groupOrder !== undefined || layout.placements !== undefined) {
await tx.user.update({
where: { id: user.id },
- data: { moodTagLayoutJson: toJson(stripGroupFromLayout(layout, key)) },
+ data: {
+ moodTagLayoutJson: toJson(stripGroupFromLayout(layout, key)),
+ },
});
}
diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts
index fee4b79d4..3848c2dbc 100644
--- a/src/app/api/mood/tags/layout/route.ts
+++ b/src/app/api/mood/tags/layout/route.ts
@@ -1,11 +1,7 @@
import { NextRequest } from "next/server";
import { prisma, toJson } from "@/lib/db";
-import {
- apiSuccess,
- returnAllZodIssues,
- safeJson,
-} from "@/lib/api-response";
+import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts
index d5b784515..4f2d8bc2d 100644
--- a/src/app/api/mood/tags/route.ts
+++ b/src/app/api/mood/tags/route.ts
@@ -165,9 +165,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
...(includeHidden && !isCustom
? { hidden: hiddenIds.has(t.id) }
: {}),
- ...(includeArchived && isCustom
- ? { archived: !t.isActive }
- : {}),
+ ...(includeArchived && isCustom ? { archived: !t.isActive } : {}),
...(includeUsage
? { usageCount: usageByTagId.get(t.id) ?? 0 }
: {}),
diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts
index fa81b4160..811a09f72 100644
--- a/src/app/api/nightscout/connect/__tests__/route.test.ts
+++ b/src/app/api/nightscout/connect/__tests__/route.test.ts
@@ -23,9 +23,8 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() }));
vi.mock("@/lib/nightscout/client", async (importOriginal) => {
- const actual = await importOriginal<
- typeof import("@/lib/nightscout/client")
- >();
+ const actual =
+ await importOriginal();
return { ...actual, fetchSgvEntries: fetchSgvEntriesMock };
});
diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts
index 5c49350ee..39af2550a 100644
--- a/src/app/api/nightscout/connect/route.ts
+++ b/src/app/api/nightscout/connect/route.ts
@@ -10,10 +10,7 @@ import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markReconnected } from "@/lib/integrations/status";
import { SafeFetchError } from "@/lib/safe-fetch";
-import {
- fetchSgvEntries,
- NightscoutApiError,
-} from "@/lib/nightscout/client";
+import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client";
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
/**
diff --git a/src/app/api/nightscout/disconnect/route.ts b/src/app/api/nightscout/disconnect/route.ts
index 3a9adff87..6a91eb1ce 100644
--- a/src/app/api/nightscout/disconnect/route.ts
+++ b/src/app/api/nightscout/disconnect/route.ts
@@ -18,7 +18,11 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "nightscout.disconnect" } });
- const rl = await checkRateLimit(`nightscout-disconnect:${user.id}`, 20, 60_000);
+ const rl = await checkRateLimit(
+ `nightscout-disconnect:${user.id}`,
+ 20,
+ 60_000,
+ );
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
diff --git a/src/app/api/nightscout/test/route.ts b/src/app/api/nightscout/test/route.ts
index 854e5ea32..97f2ac4d0 100644
--- a/src/app/api/nightscout/test/route.ts
+++ b/src/app/api/nightscout/test/route.ts
@@ -69,7 +69,10 @@ export const POST = apiHandler(async () => {
if (e instanceof NightscoutApiError) {
const cat = categoriseStatus(e.status);
annotate({
- meta: { nightscout_test_status: e.status, nightscout_test_code: cat.code },
+ meta: {
+ nightscout_test_status: e.status,
+ nightscout_test_code: cat.code,
+ },
});
return apiError(cat.message, 502, { errorCode: cat.code });
}
@@ -82,7 +85,9 @@ export const POST = apiHandler(async () => {
: "connection_failed";
annotate({ meta: { nightscout_test_code: code } });
return apiError(
- isTimeout ? "Nightscout request timed out" : "Nightscout connection failed",
+ isTimeout
+ ? "Nightscout request timed out"
+ : "Nightscout connection failed",
502,
{ errorCode: code },
);
diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts
index 0ef6ee0eb..b797b0e50 100644
--- a/src/app/api/onboarding/step/route.ts
+++ b/src/app/api/onboarding/step/route.ts
@@ -198,7 +198,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// 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 } },
+ where: {
+ id: user.id,
+ dashboardWidgetsJson: { equals: Prisma.JsonNull },
+ },
data: { dashboardWidgetsJson: toJson(seededLayout) },
});
goalsSeeded = seeded.count === 1;
diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts
index 980df247c..acd65851c 100644
--- a/src/app/api/oura/callback/__tests__/route.test.ts
+++ b/src/app/api/oura/callback/__tests__/route.test.ts
@@ -19,12 +19,14 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() }));
-const { exchangeMock, getCredsMock, matchMock, verifyMock } = vi.hoisted(() => ({
- exchangeMock: vi.fn(),
- getCredsMock: vi.fn(),
- matchMock: vi.fn(),
- verifyMock: vi.fn(),
-}));
+const { exchangeMock, getCredsMock, matchMock, verifyMock } = vi.hoisted(
+ () => ({
+ exchangeMock: vi.fn(),
+ getCredsMock: vi.fn(),
+ matchMock: vi.fn(),
+ verifyMock: vi.fn(),
+ }),
+);
vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock }));
vi.mock("@/lib/oura/credentials", () => ({
@@ -46,7 +48,11 @@ const userUpdate = prisma.user.update as ReturnType;
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
-function makeReq(opts: { code?: string; state?: string; cookie?: string }): NextRequest {
+function makeReq(opts: {
+ code?: string;
+ state?: string;
+ cookie?: string;
+}): NextRequest {
const params = new URLSearchParams();
if (opts.code) params.set("code", opts.code);
if (opts.state) params.set("state", opts.state);
@@ -54,7 +60,9 @@ function makeReq(opts: { code?: string; state?: string; cookie?: string }): Next
url: `https://app.example/api/oura/callback?${params}`,
cookies: {
get: (name: string) =>
- name === "oura_state" && opts.cookie ? { value: opts.cookie } : undefined,
+ name === "oura_state" && opts.cookie
+ ? { value: opts.cookie }
+ : undefined,
},
} as unknown as NextRequest;
}
@@ -101,7 +109,9 @@ describe("GET /api/oura/callback", () => {
});
it("exchanges the code, persists encrypted tokens, and audits on success", async () => {
- const res = await GET(makeReq({ code: "auth-code", state: "S", cookie: "S" }));
+ const res = await GET(
+ makeReq({ code: "auth-code", state: "S", cookie: "S" }),
+ );
expect(exchangeMock).toHaveBeenCalledWith("auth-code", {
clientId: "c",
clientSecret: "s",
diff --git a/src/app/api/oura/disconnect/__tests__/route.test.ts b/src/app/api/oura/disconnect/__tests__/route.test.ts
index fb7802709..a83c0a3cf 100644
--- a/src/app/api/oura/disconnect/__tests__/route.test.ts
+++ b/src/app/api/oura/disconnect/__tests__/route.test.ts
@@ -47,7 +47,11 @@ describe("POST /api/oura/disconnect", () => {
});
it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => {
- rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 });
+ rateLimitMock.mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: 0,
+ });
const res = await post();
expect(res.status).toBe(429);
// The user lookup never runs once the bucket is exhausted.
diff --git a/src/app/api/oura/test/route.ts b/src/app/api/oura/test/route.ts
index 7e659115f..ef1d30d32 100644
--- a/src/app/api/oura/test/route.ts
+++ b/src/app/api/oura/test/route.ts
@@ -15,7 +15,10 @@ const OURA_PERSONAL_INFO_URL =
"https://api.ouraring.com/v2/usercollection/personal_info";
const TIMEOUT_MS = 5_000;
-function categoriseHttpStatus(status: number): { code: string; message: string } {
+function categoriseHttpStatus(status: number): {
+ code: string;
+ message: string;
+} {
if (status === 401 || status === 403) {
return { code: "credentials_rejected", message: "Oura rejected the token" };
}
diff --git a/src/app/api/personal-records/__tests__/route.test.ts b/src/app/api/personal-records/__tests__/route.test.ts
index 2fb56b4f3..60f6b4932 100644
--- a/src/app/api/personal-records/__tests__/route.test.ts
+++ b/src/app/api/personal-records/__tests__/route.test.ts
@@ -97,7 +97,9 @@ describe("GET /api/personal-records", () => {
it("honours an explicit ?limit param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- const res = await GET(req("http://localhost/api/personal-records?limit=25"));
+ const res = await GET(
+ req("http://localhost/api/personal-records?limit=25"),
+ );
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
@@ -121,7 +123,9 @@ describe("GET /api/personal-records", () => {
it("falls back to default on garbage ?limit (defence-in-depth)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- const res = await GET(req("http://localhost/api/personal-records?limit=abc"));
+ const res = await GET(
+ req("http://localhost/api/personal-records?limit=abc"),
+ );
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts
index 53ed9d194..e8a8a09f7 100644
--- a/src/app/api/polar/callback/route.ts
+++ b/src/app/api/polar/callback/route.ts
@@ -79,7 +79,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Guard against an off-spec token body without `x_user_id`: persisting the
// string "undefined" as the member id would break every later data read
// (`/v3/users/undefined/...`) with no clear cause.
- if (typeof tokens.x_user_id !== "number" || !Number.isFinite(tokens.x_user_id)) {
+ if (
+ typeof tokens.x_user_id !== "number" ||
+ !Number.isFinite(tokens.x_user_id)
+ ) {
return ERR("token");
}
const polarUserId = String(tokens.x_user_id);
diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts
index f92c68f35..3b9d8b5f2 100644
--- a/src/app/api/polar/connect/route.ts
+++ b/src/app/api/polar/connect/route.ts
@@ -48,10 +48,7 @@ export const GET = apiHandler(async () => {
const creds = await getPolarClientCredentials(user.id);
if (!creds) {
- return apiError(
- "Polar integration is not configured on this server.",
- 400,
- );
+ return apiError("Polar integration is not configured on this server.", 400);
}
const state = mintSignedState("polar", user.id);
diff --git a/src/app/api/polar/disconnect/__tests__/route.test.ts b/src/app/api/polar/disconnect/__tests__/route.test.ts
index db7fb375e..3084241de 100644
--- a/src/app/api/polar/disconnect/__tests__/route.test.ts
+++ b/src/app/api/polar/disconnect/__tests__/route.test.ts
@@ -47,7 +47,11 @@ describe("POST /api/polar/disconnect", () => {
});
it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => {
- rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 });
+ rateLimitMock.mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: 0,
+ });
const res = await post();
expect(res.status).toBe(429);
// The user lookup never runs once the bucket is exhausted.
diff --git a/src/app/api/polar/test/route.ts b/src/app/api/polar/test/route.ts
index ebd809626..344b2d0d8 100644
--- a/src/app/api/polar/test/route.ts
+++ b/src/app/api/polar/test/route.ts
@@ -14,9 +14,15 @@ export const dynamic = "force-dynamic";
const POLAR_USERS_BASE = "https://www.polaraccesslink.com/v3/users";
const TIMEOUT_MS = 5_000;
-function categoriseHttpStatus(status: number): { code: string; message: string } {
+function categoriseHttpStatus(status: number): {
+ code: string;
+ message: string;
+} {
if (status === 401 || status === 403) {
- return { code: "credentials_rejected", message: "Polar rejected the token" };
+ return {
+ code: "credentials_rejected",
+ message: "Polar rejected the token",
+ };
}
if (status === 429) {
return { code: "rate_limited", message: "Polar rate-limited the request" };
@@ -40,7 +46,9 @@ export const POST = apiHandler(async () => {
const connection = await getPolarConnection(user.id);
if (!connection) {
- return apiError("Polar not connected", 422, { errorCode: "not_configured" });
+ return apiError("Polar not connected", 422, {
+ errorCode: "not_configured",
+ });
}
const start = performance.now();
diff --git a/src/app/api/settings/email/route.ts b/src/app/api/settings/email/route.ts
index f75ca2da2..53568e04b 100644
--- a/src/app/api/settings/email/route.ts
+++ b/src/app/api/settings/email/route.ts
@@ -59,7 +59,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { recipient, enabled } = parsed.data;
if (enabled && !recipient) {
- return apiError("A recipient address is required when email is enabled", 422);
+ return apiError(
+ "A recipient address is required when email is enabled",
+ 422,
+ );
}
const config = JSON.stringify({ recipient: recipient || "" });
diff --git a/src/app/api/settings/email/test/route.ts b/src/app/api/settings/email/test/route.ts
index 33d6bb522..4e40c8b9a 100644
--- a/src/app/api/settings/email/test/route.ts
+++ b/src/app/api/settings/email/test/route.ts
@@ -41,14 +41,18 @@ export const POST = apiHandler(async () => {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: "HealthLog Test",
- message: "HealthLog: Connection successful! Email notifications are active.",
+ message:
+ "HealthLog: Connection successful! Email notifications are active.",
});
if (!result.ok) {
return apiError("Failed to send test email", 500);
}
- annotate({ action: { name: "settings.email.test" }, meta: { success: true } });
+ annotate({
+ action: { name: "settings.email.test" },
+ meta: { success: true },
+ });
return apiSuccess({ sent: true });
});
diff --git a/src/app/api/settings/telegram/test/route.ts b/src/app/api/settings/telegram/test/route.ts
index 64eb2bac3..7f87e79d5 100644
--- a/src/app/api/settings/telegram/test/route.ts
+++ b/src/app/api/settings/telegram/test/route.ts
@@ -9,8 +9,14 @@ import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
function isLocale(value: string | null | undefined): value is Locale {
- return value === "de" || value === "en" || value === "fr" ||
- value === "es" || value === "it" || value === "pl";
+ return (
+ value === "de" ||
+ value === "en" ||
+ value === "fr" ||
+ value === "es" ||
+ value === "it" ||
+ value === "pl"
+ );
}
/**
diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts
index 884a648e0..96fce255b 100644
--- a/src/app/api/settings/webhook/test/route.ts
+++ b/src/app/api/settings/webhook/test/route.ts
@@ -36,7 +36,8 @@ export const POST = apiHandler(async () => {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: "HealthLog Test",
- message: "HealthLog: Connection successful! Webhook notifications are active.",
+ message:
+ "HealthLog: Connection successful! Webhook notifications are active.",
});
if (!result.ok) {
diff --git a/src/app/api/share-links/[id]/route.ts b/src/app/api/share-links/[id]/route.ts
index c21710b61..4dc5a6460 100644
--- a/src/app/api/share-links/[id]/route.ts
+++ b/src/app/api/share-links/[id]/route.ts
@@ -17,13 +17,13 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
export const DELETE = apiHandler(
- async (
- request: NextRequest,
- ctx: { params: Promise<{ id: string }> },
- ) => {
+ async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user } = await requireAuth();
const { id } = await ctx.params;
- annotate({ action: { name: "share-link.revoke" }, meta: { shareLinkId: id } });
+ annotate({
+ action: { name: "share-link.revoke" },
+ meta: { shareLinkId: id },
+ });
const rl = await checkRateLimit(
`share-link:${user.id}`,
diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts
index 78f4f4271..db045fab0 100644
--- a/src/app/api/share-links/__tests__/route.test.ts
+++ b/src/app/api/share-links/__tests__/route.test.ts
@@ -75,6 +75,7 @@ function storedRow(overrides: Record = {}) {
rangeEnd: null,
resourceTypes: ["Observation"],
allowFhirApi: true,
+ passphraseHash: "hash(STORED)",
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
revokedAt: null,
@@ -119,6 +120,44 @@ describe("POST /api/share-links — create", () => {
expect(createArg.data.userId).toBe("user-1");
});
+ it("returns the raw passphrase once and persists ONLY its hash", async () => {
+ vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
+ storedRow({ passphraseHash: "hash(STORED)" }) as never,
+ );
+
+ const res = await POST(postReq(validBody()));
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as {
+ data: {
+ passphrase: string;
+ token: string;
+ shareUrl: string;
+ qrUrl: string;
+ protected: boolean;
+ };
+ };
+
+ // The raw passphrase is returned exactly once in the grouped form.
+ expect(body.data.passphrase).toMatch(
+ /^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/,
+ );
+
+ // Stored as a hash (the mock wraps the NORMALISED bare form), never raw.
+ const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
+ .calls[0][0];
+ const bare = body.data.passphrase.replace(/-/g, "");
+ expect(createArg.data.passphraseHash).toBe(`hash(${bare})`);
+ expect(createArg.data.passphraseHash).not.toContain(body.data.passphrase);
+
+ // The QR payload carries the passphrase ONLY in the URL fragment (`#k=`),
+ // never the path or query — and the bare share URL never carries it.
+ expect(body.data.qrUrl).toContain(`#k=${body.data.passphrase}`);
+ expect(body.data.shareUrl).not.toContain(body.data.passphrase);
+ expect(body.data.shareUrl).toContain(`/c/${body.data.token}`);
+ // Summary reports the link as passphrase-protected.
+ expect(body.data.protected).toBe(true);
+ });
+
it("rejects an expiry beyond the cap (422)", async () => {
const tooFar = new Date(
Date.now() + 200 * 24 * 60 * 60 * 1000,
@@ -174,7 +213,8 @@ describe("DELETE /api/share-links/[id] — revoke", () => {
params: Promise.resolve({ id: "link-1" }),
});
expect(res.status).toBe(200);
- const arg = vi.mocked(prisma.clinicianShareLink.updateMany).mock.calls[0][0];
+ const arg = vi.mocked(prisma.clinicianShareLink.updateMany).mock
+ .calls[0][0];
expect(arg.where).toEqual({
id: "link-1",
userId: "user-1",
diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts
index 4b0839681..1fa5d539c 100644
--- a/src/app/api/share-links/route.ts
+++ b/src/app/api/share-links/route.ts
@@ -29,8 +29,31 @@ import {
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
+import {
+ generatePassphrase,
+ hashPassphrase,
+ normalisePassphrase,
+ PASSPHRASE_FRAGMENT_KEY,
+} from "@/lib/clinician-share/passphrase";
import type { Prisma } from "@/generated/prisma/client";
+/**
+ * The absolute origin to build the public share URL from. Prefers the
+ * forwarded host/proto a reverse proxy sets (the documented self-hosting path
+ * fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's
+ * own origin. The QR payload carries the passphrase in the FRAGMENT only.
+ */
+function resolveOrigin(request: NextRequest): string {
+ const proto =
+ request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
+ new URL(request.url).protocol.replace(":", "");
+ const host =
+ request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
+ request.headers.get("host") ||
+ new URL(request.url).host;
+ return `${proto}://${host}`;
+}
+
/** Project a stored row to the safe owner-facing shape (never the token). */
function toSummary(row: {
id: string;
@@ -39,6 +62,7 @@ function toSummary(row: {
rangeEnd: Date | null;
resourceTypes: string[];
allowFhirApi: boolean;
+ passphraseHash: string | null;
expiresAt: Date;
createdAt: Date;
revokedAt: Date | null;
@@ -52,14 +76,16 @@ function toSummary(row: {
rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null,
resourceTypes: row.resourceTypes,
allowFhirApi: row.allowFhirApi,
+ // v1.18.7 — surface only WHETHER a passphrase guards the link, never the
+ // hash itself. Legacy (null-hash) links read `false` and stay ungated.
+ protected: row.passphraseHash !== null,
expiresAt: row.expiresAt.toISOString(),
createdAt: row.createdAt.toISOString(),
revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
lastAccessAt: row.lastAccessAt ? row.lastAccessAt.toISOString() : null,
accessCount: row.accessCount,
// Status the UI can render without re-deriving expiry/revocation.
- active:
- row.revokedAt === null && row.expiresAt.getTime() > Date.now(),
+ active: row.revokedAt === null && row.expiresAt.getTime() > Date.now(),
};
}
@@ -67,11 +93,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "share-link.create" } });
- const rl = await checkRateLimit(
- `share-link:${user.id}`,
- 20,
- 60 * 60 * 1000,
- );
+ const rl = await checkRateLimit(`share-link:${user.id}`, 20, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 20 share-link operations per hour", 429);
}
@@ -91,12 +113,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
const tokenHash = hashToken(rawToken);
+ // v1.18.7 — always mint a passphrase second factor; store only its HMAC
+ // hash. The raw passphrase is returned exactly once below. `normalisePassphrase`
+ // is the canonical store form so the grouped (`XXXX-XXXX-…`) display and the
+ // bare fragment value collide to one hash.
+ const rawPassphrase = generatePassphrase();
+ const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!);
+
// Field-by-field build — no mass assignment. Scope columns are written
// exactly once here and never updated.
const created = await prisma.clinicianShareLink.create({
data: {
userId: user.id,
tokenHash,
+ passphraseHash,
label: input.label,
rangeStart: new Date(input.rangeStart),
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
@@ -112,6 +142,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
+ passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
@@ -131,8 +162,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
- // The raw token is returned exactly once; it is unrecoverable thereafter.
- return apiSuccess({ ...toSummary(created), token: rawToken }, 201);
+ // The raw token AND raw passphrase are returned exactly once; both are
+ // unrecoverable thereafter. The QR/deep-link payload carries the passphrase
+ // in the URL FRAGMENT (`#k=`) — never the path or query — so it is never
+ // sent to the server, kept out of logs, referrers, and the access record.
+ const origin = resolveOrigin(request);
+ const shareUrl = `${origin}/c/${rawToken}`;
+ const qrUrl = `${shareUrl}#${PASSPHRASE_FRAGMENT_KEY}=${rawPassphrase}`;
+ return apiSuccess(
+ {
+ ...toSummary(created),
+ token: rawToken,
+ passphrase: rawPassphrase,
+ shareUrl,
+ qrUrl,
+ },
+ 201,
+ );
});
export const GET = apiHandler(async () => {
@@ -149,6 +195,7 @@ export const GET = apiHandler(async () => {
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
+ passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts
index 519e795d5..c8d43feab 100644
--- a/src/app/api/sleep/night/__tests__/route.test.ts
+++ b/src/app/api/sleep/night/__tests__/route.test.ts
@@ -33,8 +33,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
- const actual =
- await importOriginal();
+ const actual = await importOriginal();
return { ...actual, annotate: vi.fn() };
});
@@ -60,7 +59,12 @@ function req(query: string): NextRequest {
return new NextRequest(`http://localhost/api/sleep/night?${query}`);
}
-function stage(iso: string, s: string, minutes: number, source = "APPLE_HEALTH") {
+function stage(
+ iso: string,
+ s: string,
+ minutes: number,
+ source = "APPLE_HEALTH",
+) {
return {
value: minutes,
measuredAt: new Date(iso),
diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts
index 72dddd448..d6a68886a 100644
--- a/src/app/api/sync/changes/route.ts
+++ b/src/app/api/sync/changes/route.ts
@@ -236,70 +236,70 @@ export const GET = apiHandler(async (request: NextRequest) => {
// without a count query.
const [measurementRows, moodRows, intakeRows, cycleDayRows, cycleRows] =
await Promise.all([
- prisma.measurement.findMany({
- where: { userId: user.id, ...keysetFilter(cursor.measurements) },
- orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
- take: limit + 1,
- select: {
- id: true,
- externalId: true,
- type: true,
- value: true,
- unit: true,
- measuredAt: true,
- source: true,
- notes: true,
- syncVersion: true,
- deletedAt: true,
- updatedAt: true,
- },
- }),
- prisma.moodEntry.findMany({
- where: { userId: user.id, ...keysetFilter(cursor.mood) },
- orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
- take: limit + 1,
- select: {
- id: true,
- date: true,
- mood: true,
- score: true,
- tags: true,
- note: true,
- moodLoggedAt: true,
- source: true,
- syncVersion: true,
- deletedAt: true,
- updatedAt: true,
- },
- }),
- prisma.medicationIntakeEvent.findMany({
- where: { userId: user.id, ...keysetFilter(cursor.intakes) },
- orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
- take: limit + 1,
- select: {
- id: true,
- medicationId: true,
- scheduledFor: true,
- takenAt: true,
- skipped: true,
- source: true,
- syncVersion: true,
- deletedAt: true,
- updatedAt: true,
- },
- }),
- prisma.cycleDayLog.findMany({
- where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
- orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
- take: limit + 1,
- include: dayLogSymptomInclude,
- }),
- prisma.menstrualCycle.findMany({
- where: { userId: user.id, ...keysetFilter(cursor.cycles) },
- orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
- take: limit + 1,
- }),
- ]);
+ prisma.measurement.findMany({
+ where: { userId: user.id, ...keysetFilter(cursor.measurements) },
+ orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
+ take: limit + 1,
+ select: {
+ id: true,
+ externalId: true,
+ type: true,
+ value: true,
+ unit: true,
+ measuredAt: true,
+ source: true,
+ notes: true,
+ syncVersion: true,
+ deletedAt: true,
+ updatedAt: true,
+ },
+ }),
+ prisma.moodEntry.findMany({
+ where: { userId: user.id, ...keysetFilter(cursor.mood) },
+ orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
+ take: limit + 1,
+ select: {
+ id: true,
+ date: true,
+ mood: true,
+ score: true,
+ tags: true,
+ note: true,
+ moodLoggedAt: true,
+ source: true,
+ syncVersion: true,
+ deletedAt: true,
+ updatedAt: true,
+ },
+ }),
+ prisma.medicationIntakeEvent.findMany({
+ where: { userId: user.id, ...keysetFilter(cursor.intakes) },
+ orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
+ take: limit + 1,
+ select: {
+ id: true,
+ medicationId: true,
+ scheduledFor: true,
+ takenAt: true,
+ skipped: true,
+ source: true,
+ syncVersion: true,
+ deletedAt: true,
+ updatedAt: true,
+ },
+ }),
+ prisma.cycleDayLog.findMany({
+ where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
+ orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
+ take: limit + 1,
+ include: dayLogSymptomInclude,
+ }),
+ prisma.menstrualCycle.findMany({
+ where: { userId: user.id, ...keysetFilter(cursor.cycles) },
+ orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
+ take: limit + 1,
+ }),
+ ]);
const nextCursor: SyncCursor = { ...cursor };
let hasMore = false;
diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts
index 69b863a78..301322e33 100644
--- a/src/app/api/telegram/webhook/__tests__/route.test.ts
+++ b/src/app/api/telegram/webhook/__tests__/route.test.ts
@@ -264,9 +264,8 @@ describe("Telegram webhook — callback dispatch", () => {
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
// v1.16.10 — the confirmed take consumes inventory units (after the
// intake transaction committed, on the created row).
- const { consumeForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake } =
+ await import("@/lib/medications/inventory/consumption");
expect(consumeForIntake).toHaveBeenCalledTimes(1);
expect(consumeForIntake).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", medicationId: "med-1" }),
@@ -345,9 +344,8 @@ describe("Telegram webhook — callback dispatch", () => {
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
// v1.16.10 — an explicit skip refunds a previously-consumed stamp
// (no-op for a never-consumed row) and never consumes.
- const { consumeForIntake, restoreForIntake } = await import(
- "@/lib/medications/inventory/consumption"
- );
+ const { consumeForIntake, restoreForIntake } =
+ await import("@/lib/medications/inventory/consumption");
expect(consumeForIntake).not.toHaveBeenCalled();
expect(restoreForIntake).toHaveBeenCalledTimes(1);
});
diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts
index 5e3c0d197..885a66a32 100644
--- a/src/app/api/user/ai-provider/route.ts
+++ b/src/app/api/user/ai-provider/route.ts
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { isPublicUrl } from "@/lib/validations/notifications";
+import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
import { encrypt, decrypt } from "@/lib/crypto";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { annotate } from "@/lib/logging/context";
@@ -92,12 +93,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const trimmed = body.baseUrl.trim();
// SSRF guard: by default reject private/internal hostnames so a
// compromised user account cannot point the server at the cloud
- // metadata endpoint or internal admin panels. Ops can enable local
- // Ollama / LM Studio on this instance via env flag.
- const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
+ // metadata endpoint or internal admin panels. v1.18.7 (SECURITY LOW) —
+ // ops opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`, now a host allowlist:
+ // `true` permits any private host (legacy), a comma-separated host list
+ // permits only those exact hostnames.
+ const allowPrivate = isLocalAiHostAllowed(trimmed);
if (!allowPrivate && !isPublicUrl(trimmed)) {
return apiError(
- "Base URL points to an internal/private host. Ops must set ALLOW_LOCAL_AI_PRIVATE_HOSTS=true on this instance to allow it (intended for self-hosted Ollama / LM Studio).",
+ "Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. ollama.lan) or to true for any private host (intended for self-hosted Ollama / LM Studio).",
422,
);
}
diff --git a/src/app/api/user/avatar/__tests__/route.test.ts b/src/app/api/user/avatar/__tests__/route.test.ts
index 4f0b26841..0cfb500ee 100644
--- a/src/app/api/user/avatar/__tests__/route.test.ts
+++ b/src/app/api/user/avatar/__tests__/route.test.ts
@@ -186,9 +186,7 @@ describe("POST /api/user/avatar", () => {
// Plain text content — no JPEG/PNG/WebP magic.
const notAnImage = Buffer.from("hello, world — definitely not an image");
- const res = await post(
- mkUpload(notAnImage, "avatar.png", "image/png"),
- );
+ const res = await post(mkUpload(notAnImage, "avatar.png", "image/png"));
expect(res.status).toBe(415);
expect(prisma.user.update).not.toHaveBeenCalled();
});
diff --git a/src/app/api/user/avatar/route.ts b/src/app/api/user/avatar/route.ts
index 296a95017..61df694ac 100644
--- a/src/app/api/user/avatar/route.ts
+++ b/src/app/api/user/avatar/route.ts
@@ -149,10 +149,7 @@ export const POST = apiHandler(async (request: Request) => {
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "content_length_exceeded", contentLength },
});
- return apiError(
- `Upload exceeds ${AVATAR_MAX_BYTES} byte limit`,
- 413,
- );
+ return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
}
// Stream-level cap. The Content-Length pre-flight above only fires when
@@ -196,10 +193,7 @@ export const POST = apiHandler(async (request: Request) => {
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "file_size_exceeded", size: file.size },
});
- return apiError(
- `Upload exceeds ${AVATAR_MAX_BYTES} byte limit`,
- 413,
- );
+ return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
}
let buffer: Buffer;
@@ -218,10 +212,7 @@ export const POST = apiHandler(async (request: Request) => {
action: { name: "user.avatar.upload.rejected" },
meta: { reason: "unsupported_mime" },
});
- return apiError(
- "Unsupported image type. Use JPEG, PNG, or WebP.",
- 415,
- );
+ return apiError("Unsupported image type. Use JPEG, PNG, or WebP.", 415);
}
const dimensions = readAvatarDimensions(buffer, sniffed);
@@ -242,10 +233,7 @@ export const POST = apiHandler(async (request: Request) => {
height: dimensions.height,
},
});
- return apiError(
- `Image exceeds the 2048×2048 dimension limit`,
- 413,
- );
+ return apiError(`Image exceeds the 2048×2048 dimension limit`, 413);
}
const updatedAt = new Date();
diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts
index d086f1ad6..d9138e901 100644
--- a/src/app/api/version/__tests__/route.test.ts
+++ b/src/app/api/version/__tests__/route.test.ts
@@ -35,7 +35,9 @@ describe("GET /api/version", () => {
it("returns the package.json version", async () => {
const response = await (GET as unknown as () => Promise)();
const body = (await response.json()) as VersionEnvelope;
- expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i);
+ expect(body.data.version).toMatch(
+ /^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i,
+ );
expect(body.data.license).toBe("PolyForm-Noncommercial-1.0.0");
expect(body.data.repository).toContain("github.com");
expect(body.data.changelog).toContain("CHANGELOG");
@@ -75,7 +77,9 @@ describe("GET /api/version", () => {
process.env.NEXT_PUBLIC_APP_VERSION = " ";
const response = await (GET as unknown as () => Promise)();
const body = (await response.json()) as VersionEnvelope;
- expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i);
+ expect(body.data.version).toMatch(
+ /^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i,
+ );
});
it("reports offlineGeoEnabled=false when the GeoLite2 databases are absent", async () => {
diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts
index 3f1ffa9c9..a69981a7d 100644
--- a/src/app/api/version/route.ts
+++ b/src/app/api/version/route.ts
@@ -45,7 +45,8 @@ export const GET = apiHandler(async () => {
// BuildKit-layer cache hit on `pnpm build` can never re-ship the
// prior release's version string. Local `pnpm dev` (env unset) falls
// through to package.json as before.
- const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
+ const version =
+ process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
const offlineGeoEnabled = offlineGeoReady();
diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts
index ca630c84a..993373a04 100644
--- a/src/app/api/whoop/callback/__tests__/route.test.ts
+++ b/src/app/api/whoop/callback/__tests__/route.test.ts
@@ -62,7 +62,10 @@ process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function makeReq(nonce: string): NextRequest {
return {
url: `https://app.example/api/whoop/callback?code=auth-code&state=${nonce}`,
- cookies: { get: (name: string) => (name === "whoop_state" ? { value: nonce } : undefined) },
+ cookies: {
+ get: (name: string) =>
+ name === "whoop_state" ? { value: nonce } : undefined,
+ },
} as unknown as NextRequest;
}
diff --git a/src/app/api/whoop/connect/__tests__/route.test.ts b/src/app/api/whoop/connect/__tests__/route.test.ts
index 219c0fa70..6e243ee0f 100644
--- a/src/app/api/whoop/connect/__tests__/route.test.ts
+++ b/src/app/api/whoop/connect/__tests__/route.test.ts
@@ -20,7 +20,8 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/whoop/client", () => ({
getAuthorizationUrl: vi.fn(
- (nonce: string) => `https://api.prod.whoop.com/oauth/oauth2/auth?state=${nonce}`,
+ (nonce: string) =>
+ `https://api.prod.whoop.com/oauth/oauth2/auth?state=${nonce}`,
),
}));
@@ -55,7 +56,9 @@ import { checkRateLimit } from "@/lib/rate-limit";
import type { NextRequest } from "next/server";
const create = prisma.whoopOAuthState.create as ReturnType;
-const consume = consumeWhoopConnectTicket as unknown as ReturnType;
+const consume = consumeWhoopConnectTicket as unknown as ReturnType<
+ typeof vi.fn
+>;
const reqAuth = requireAuth as unknown as ReturnType;
function makeReq(url: string): NextRequest {
@@ -72,9 +75,7 @@ describe("GET /api/whoop/connect", () => {
});
it("cookie path: 302s to WHOOP and stores the state row (no scheme)", async () => {
- const res = await GET(
- makeReq("https://app.example/api/whoop/connect"),
- );
+ const res = await GET(makeReq("https://app.example/api/whoop/connect"));
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"api.prod.whoop.com/oauth/oauth2/auth",
@@ -127,9 +128,7 @@ describe("GET /api/whoop/connect", () => {
expect(create.mock.calls[0][0].data.returnScheme).toBeNull();
create.mockClear();
await GET(
- makeReq(
- "https://app.example/api/whoop/connect?return_scheme=javascript",
- ),
+ makeReq("https://app.example/api/whoop/connect?return_scheme=javascript"),
);
expect(create.mock.calls[0][0].data.returnScheme).toBeNull();
});
diff --git a/src/app/api/whoop/connect/ticket/route.ts b/src/app/api/whoop/connect/ticket/route.ts
index 459921481..294f9195b 100644
--- a/src/app/api/whoop/connect/ticket/route.ts
+++ b/src/app/api/whoop/connect/ticket/route.ts
@@ -30,7 +30,10 @@ export const POST = apiHandler(async () => {
);
if (!rl.allowed) {
annotate({ action: { name: "whoop.connect.ticket.rate_limited" } });
- return apiError("Too many connect-ticket requests. Try again shortly.", 429);
+ return apiError(
+ "Too many connect-ticket requests. Try again shortly.",
+ 429,
+ );
}
// BYO-key gate: a ticket is only useful if the user has WHOOP credentials
diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts
index ec79e0cd1..6ca1e67ea 100644
--- a/src/app/api/whoop/credentials/__tests__/route.test.ts
+++ b/src/app/api/whoop/credentials/__tests__/route.test.ts
@@ -81,11 +81,12 @@ describe("/api/whoop/credentials", () => {
it("DELETE clears credentials and connection", async () => {
userUpdate.mockResolvedValue({});
- (prisma.whoopConnection.delete as ReturnType).mockResolvedValue(
- {},
- );
- const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)())
- .data as { deleted: boolean };
+ (
+ prisma.whoopConnection.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" },
@@ -98,9 +99,9 @@ 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(
- {},
- );
+ (
+ prisma.whoopConnection.delete as ReturnType
+ ).mockResolvedValue({});
await (DELETE as unknown as () => Promise)();
expect(auditLog).toHaveBeenCalledWith("whoop.credentials.delete", {
userId: "u1",
diff --git a/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts b/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts
index 83dc167a2..9dc656ec1 100644
--- a/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts
+++ b/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts
@@ -56,15 +56,18 @@ function signedRequest(
const signature = createHmac("sha256", signingSecret)
.update(timestamp + rawBody, "utf8")
.digest("base64");
- return new NextRequest("https://app.example.com/api/whoop/webhook/" + SECRET, {
- method: "POST",
- headers: {
- "content-type": "application/json",
- "X-WHOOP-Signature": signature,
- "X-WHOOP-Signature-Timestamp": timestamp,
+ return new NextRequest(
+ "https://app.example.com/api/whoop/webhook/" + SECRET,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "X-WHOOP-Signature": signature,
+ "X-WHOOP-Signature-Timestamp": timestamp,
+ },
+ body: rawBody,
},
- body: rawBody,
- });
+ );
}
const ctx = (token: string) => ({ params: Promise.resolve({ token }) });
diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts
index 7fe01103c..c84c81071 100644
--- a/src/app/api/withings/callback/__tests__/route.test.ts
+++ b/src/app/api/withings/callback/__tests__/route.test.ts
@@ -123,9 +123,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
// Stub the rest of the happy path for the winning request — code
// exchange returns plausible tokens so the upsert + redirect run.
- const { getUserWithingsCredentials } = await import(
- "@/lib/withings/credentials"
- );
+ const { getUserWithingsCredentials } =
+ await import("@/lib/withings/credentials");
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "cid",
clientSecret: "csec",
@@ -155,13 +154,9 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
// Exactly one leg landed on `withings=connected`, exactly one
// landed on the replay branch (`withings=error&reason=replay`).
- const locations = [resA, resB]
- .map((r) => r.headers.get("location"))
- .sort();
+ const locations = [resA, resB].map((r) => r.headers.get("location")).sort();
expect(locations).toHaveLength(2);
- expect(
- locations.some((l) => l?.includes("withings=connected")),
- ).toBe(true);
+ expect(locations.some((l) => l?.includes("withings=connected"))).toBe(true);
expect(
locations.some((l) => l?.includes("withings=error&reason=replay")),
).toBe(true);
@@ -265,7 +260,9 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
"withings=error&reason=cross_user",
);
expect(exchangeCode).not.toHaveBeenCalled();
- expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "cross_user" } });
+ expect(annotateMock).toHaveBeenCalledWith({
+ meta: { reason: "cross_user" },
+ });
});
it("non-P2025 infra error branch annotates reason=state on the Wide Event (legacy fallback for unclassified DB failures)", async () => {
@@ -295,7 +292,10 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
// CSRF leg 1 — must reject without touching the ledger so a
// probe can't grief legitimate rows by issuing deletes for
// nonces it doesn't legitimately hold the cookie for.
- const req = callbackRequest("aaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbb");
+ const req = callbackRequest(
+ "aaaaaaaaaaaaaaaaaaaaaa",
+ "bbbbbbbbbbbbbbbbbbbbbb",
+ );
const res = await GET(req);
expect(res.status).toBe(307);
diff --git a/src/app/api/withings/connect/__tests__/route.test.ts b/src/app/api/withings/connect/__tests__/route.test.ts
index cef1b033d..cc3fec2b5 100644
--- a/src/app/api/withings/connect/__tests__/route.test.ts
+++ b/src/app/api/withings/connect/__tests__/route.test.ts
@@ -42,7 +42,8 @@ vi.mock("@/lib/withings/credentials", () => ({
vi.mock("@/lib/withings/client", () => ({
getAuthorizationUrl: vi.fn(
- () => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
+ () =>
+ "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
),
}));
@@ -78,9 +79,7 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.withingsOAuthState.create).mockReset();
- vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue(
- {} as never,
- );
+ vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue({} as never);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "client-id",
clientSecret: "client-secret",
diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts
index f48ad81b5..c1e0c672f 100644
--- a/src/app/api/withings/connect/route.ts
+++ b/src/app/api/withings/connect/route.ts
@@ -77,10 +77,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
getEvent()?.setError(err);
annotate({ action: { name: "withings.connect.create_failed" } });
return NextResponse.redirect(
- new URL(
- "/settings/integrations?withings=error&reason=connect",
- req.url,
- ),
+ new URL("/settings/integrations?withings=error&reason=connect", req.url),
);
}
diff --git a/src/app/api/withings/webhook/[token]/route.ts b/src/app/api/withings/webhook/[token]/route.ts
index 50e1dee42..523c51e9e 100644
--- a/src/app/api/withings/webhook/[token]/route.ts
+++ b/src/app/api/withings/webhook/[token]/route.ts
@@ -26,9 +26,7 @@ interface RouteContext {
params: Promise<{ token: string }>;
}
-async function verifyTokenSegment(
- token: string | undefined,
-): Promise {
+async function verifyTokenSegment(token: string | undefined): Promise {
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts
index 084b95cfe..f9bd64a51 100644
--- a/src/app/api/workouts/__tests__/batch-create.test.ts
+++ b/src/app/api/workouts/__tests__/batch-create.test.ts
@@ -33,7 +33,7 @@ vi.mock("@/lib/db", () => ({
// calls land on the same vi.fn instances.
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- return (fn as any)((prisma as unknown as { workout: unknown }));
+ return (fn as any)(prisma as unknown as { workout: unknown });
}
}),
},
@@ -103,7 +103,10 @@ function makeRequest(
});
}
-function validWorkout(externalId: string, overrides: Record = {}) {
+function validWorkout(
+ externalId: string,
+ overrides: Record = {},
+) {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
@@ -583,9 +586,7 @@ describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", ()
});
it("propagates silent=true when the batch exceeds the historical threshold", async () => {
- const big = Array.from({ length: 51 }, (_, i) =>
- validWorkout(`uuid-${i}`),
- );
+ const big = Array.from({ length: 51 }, (_, i) => validWorkout(`uuid-${i}`));
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 51 });
const res = await POST(makeRequest({ workouts: big }));
expect(res.status).toBe(200);
diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts
index 4d360b71c..50836cb9c 100644
--- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts
+++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts
@@ -173,9 +173,7 @@ describe("GET /api/workouts — canonical dedup", () => {
// This test pins that the route passes `userId: user-1` so the
// database layer drops other users' rows before the picker runs.
const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z");
- vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
- apple,
- ] as never);
+ vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([apple] as never);
await GET(makeRequest());
@@ -230,9 +228,7 @@ describe("GET /api/workouts — canonical dedup", () => {
it("exposes iOS-contract field names on each workout row", async () => {
const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z");
- vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
- apple,
- ] as never);
+ vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([apple] as never);
const res = await GET(makeRequest());
const body = await res.json();
diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts
index f3de4f6d1..71b045c1f 100644
--- a/src/app/api/workouts/__tests__/detail.test.ts
+++ b/src/app/api/workouts/__tests__/detail.test.ts
@@ -115,7 +115,12 @@ describe("GET /api/workouts/{id}", () => {
BASE_ROW as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
- { id: "w-1", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" },
+ {
+ id: "w-1",
+ source: "APPLE_HEALTH",
+ startedAt: BASE_ROW.startedAt,
+ sportType: "RUNNING",
+ },
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
@@ -181,8 +186,18 @@ describe("GET /api/workouts/{id}", () => {
withingsRow as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
- { id: "w-apple", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" },
- { id: "w-withings", source: "WITHINGS", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" },
+ {
+ id: "w-apple",
+ source: "APPLE_HEALTH",
+ startedAt: BASE_ROW.startedAt,
+ sportType: "RUNNING",
+ },
+ {
+ id: "w-withings",
+ source: "WITHINGS",
+ startedAt: BASE_ROW.startedAt,
+ sportType: "RUNNING",
+ },
] as never);
const res = await GET(makeRequest(), makeParams("w-withings"));
@@ -211,7 +226,12 @@ describe("GET /api/workouts/{id}", () => {
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
- { id: "w-1", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" },
+ {
+ id: "w-1",
+ source: "APPLE_HEALTH",
+ startedAt: BASE_ROW.startedAt,
+ sportType: "RUNNING",
+ },
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx
index 2403a4a28..53dacacc1 100644
--- a/src/app/c/[token]/__tests__/page.test.tsx
+++ b/src/app/c/[token]/__tests__/page.test.tsx
@@ -1,16 +1,25 @@
/**
* v1.11.0 (Epic C, C5) — public clinician-view page gating.
+ * v1.18.7 — passphrase gate: the page checks gate state first, gates a
+ * protected link until the unlock cookie is valid, and never bumps access
+ * counters on a gate-blocked hit.
*
- * Asserts the page answers a flat `notFound()` (404) whenever the resolver
- * yields null — the single blunt response for unknown / revoked / expired /
- * malformed tokens — and renders the scoped view on a successful resolve.
+ * Asserts the page answers a flat `notFound()` (404) whenever the gate
+ * resolver yields null — the single blunt response for unknown / revoked /
+ * expired / malformed tokens; renders the scoped view for a legacy (no
+ * passphrase) link and for a protected link with a valid unlock cookie; and
+ * shows the gate (no record load) for a protected link without the cookie.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
+const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
+
class NotFoundError extends Error {
digest = "NEXT_HTTP_ERROR_FALLBACK;404";
}
+let cookieValue: string | undefined;
+
vi.mock("next/navigation", () => ({
notFound: vi.fn(() => {
throw new NotFoundError("NEXT_NOT_FOUND");
@@ -18,10 +27,11 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
- cookies: vi.fn(async () => ({ get: () => undefined })),
+ cookies: vi.fn(async () => ({ get: () => ({ value: cookieValue }) })),
}));
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
resolveShareToken: vi.fn(),
+ resolveShareGateState: vi.fn(),
}));
vi.mock("@/lib/clinician-share/share-view-data", () => ({
loadShareViewData: vi.fn(),
@@ -29,47 +39,116 @@ vi.mock("@/lib/clinician-share/share-view-data", () => ({
vi.mock("@/components/clinician/clinician-view", () => ({
ClinicianView: () => null,
}));
+vi.mock("@/components/clinician/share-unlock-gate", () => ({
+ ShareUnlockGate: () => "GATE",
+}));
import ClinicianSharePage from "../page";
-import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
+import {
+ resolveShareToken,
+ resolveShareGateState,
+} from "@/lib/clinician-share/resolve-share-token";
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
+import { mintUnlockValue } from "@/lib/clinician-share/unlock-cookie";
import { notFound } from "next/navigation";
const resolve = resolveShareToken as ReturnType;
+const gateState = resolveShareGateState as ReturnType;
const loadData = loadShareViewData as ReturnType;
+const TOKEN_HASH = "f".repeat(64);
+
function pageProps(token: string) {
return { params: Promise.resolve({ token }) };
}
+/** The page returns a `` element when it gates. */
+function isGate(out: unknown): boolean {
+ return (
+ typeof out === "object" &&
+ out !== null &&
+ "props" in out &&
+ typeof (out as { props?: { token?: unknown } }).props?.token === "string"
+ );
+}
+
+function resolvedContext() {
+ return {
+ shareLinkId: "link-1",
+ ownerUserId: "owner-1",
+ label: "Clinic",
+ rangeStart: new Date(),
+ rangeEnd: null,
+ sectionsJson: {},
+ resourceTypes: [],
+ allowFhirApi: false,
+ expiresAt: new Date(Date.now() + 86_400_000),
+ };
+}
+
describe("clinician share page", () => {
- beforeEach(() => vi.clearAllMocks());
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
+ cookieValue = undefined;
+ });
it("404s when the token resolves to null (revoked / expired / unknown)", async () => {
- resolve.mockResolvedValue(null);
+ gateState.mockResolvedValue(null);
await expect(
ClinicianSharePage(pageProps(`hls_${"a".repeat(48)}`)),
).rejects.toBeInstanceOf(NotFoundError);
expect(notFound).toHaveBeenCalledTimes(1);
- // No data load is attempted on a failed resolve.
+ // No data load and no counter-bumping resolve on a failed gate.
expect(loadData).not.toHaveBeenCalled();
+ expect(resolve).not.toHaveBeenCalled();
});
- it("loads the scoped view on a successful resolve", async () => {
- resolve.mockResolvedValue({
- shareLinkId: "link-1",
- ownerUserId: "owner-1",
- label: "Clinic",
- rangeStart: new Date(),
- rangeEnd: null,
- sectionsJson: {},
- resourceTypes: [],
- allowFhirApi: false,
- expiresAt: new Date(Date.now() + 86_400_000),
+ it("renders a legacy (no-passphrase) link without a gate", async () => {
+ gateState.mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: null,
});
+ resolve.mockResolvedValue(resolvedContext());
loadData.mockResolvedValue({ report: {}, sections: {} });
await ClinicianSharePage(pageProps(`hls_${"b".repeat(48)}`));
expect(notFound).not.toHaveBeenCalled();
expect(loadData).toHaveBeenCalledTimes(1);
});
+
+ it("gates a protected link with NO unlock cookie (no record load)", async () => {
+ gateState.mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: "stored-hash",
+ });
+ const out = await ClinicianSharePage(pageProps(`hls_${"c".repeat(48)}`));
+ // The gate island is returned; the record is never resolved or loaded.
+ expect(isGate(out)).toBe(true);
+ expect(resolve).not.toHaveBeenCalled();
+ expect(loadData).not.toHaveBeenCalled();
+ });
+
+ it("renders a protected link WITH a valid unlock cookie", async () => {
+ gateState.mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: "stored-hash",
+ });
+ cookieValue = mintUnlockValue(TOKEN_HASH);
+ resolve.mockResolvedValue(resolvedContext());
+ loadData.mockResolvedValue({ report: {}, sections: {} });
+ await ClinicianSharePage(pageProps(`hls_${"d".repeat(48)}`));
+ expect(loadData).toHaveBeenCalledTimes(1);
+ });
+
+ it("gates a protected link with an INVALID / cross-token cookie", async () => {
+ gateState.mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: "stored-hash",
+ });
+ // A cookie minted for a different token must not unlock this one.
+ cookieValue = mintUnlockValue("a".repeat(64));
+ const out = await ClinicianSharePage(pageProps(`hls_${"e".repeat(48)}`));
+ expect(isGate(out)).toBe(true);
+ expect(loadData).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx
index f88b8fb2e..38633ec14 100644
--- a/src/app/c/[token]/page.tsx
+++ b/src/app/c/[token]/page.tsx
@@ -19,16 +19,20 @@ import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
-import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
-import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
-import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
- defaultLocale,
- locales,
- type Locale,
-} from "@/lib/i18n/config";
+ resolveShareGateState,
+ resolveShareToken,
+} from "@/lib/clinician-share/resolve-share-token";
+import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
+import {
+ unlockCookieName,
+ verifyUnlockValue,
+} from "@/lib/clinician-share/unlock-cookie";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { ClinicianView } from "@/components/clinician/clinician-view";
+import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
// Never cache a scoped health view — `no-store` end to end.
export const dynamic = "force-dynamic";
@@ -61,9 +65,31 @@ export default async function ClinicianSharePage({
}) {
const { token } = await params;
- // The ONE trust boundary. No session is read; this proves only that the raw
- // path token hashes to a live, in-window share link and yields the owner
- // scope. Any failure → null → flat 404.
+ // v1.18.7 — gate check first, WITHOUT bumping access counters. Resolves a
+ // malformed / unknown / revoked / expired token to null → the same flat 404
+ // the full read would. A legacy link (no passphrase) falls straight through.
+ const gate = await resolveShareGateState(token);
+ if (!gate) notFound();
+
+ if (gate.passphraseHash !== null) {
+ // Protected link: render nothing of the record until the short-lived,
+ // token-scoped unlock cookie is present and valid. Verifying the cookie is
+ // constant-time; an invalid / expired / cross-token value shows the gate.
+ const cookieStore = await cookies();
+ const unlockValue = cookieStore.get(
+ unlockCookieName(gate.tokenHash),
+ )?.value;
+ if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
+ const locale = await resolveLocale();
+ const { t } = getServerTranslator(locale);
+ return t(key, vars)} token={token} />;
+ }
+ }
+
+ // The ONE trust boundary for the full read. No session is read; this proves
+ // only that the raw path token hashes to a live, in-window share link and
+ // yields the owner scope. Any failure → null → flat 404. The access counter
+ // is bumped here (on a real record render), not on a gate-blocked hit.
const context = await resolveShareToken(token);
if (!context) notFound();
diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx
index 49e130fb6..9fdbe3462 100644
--- a/src/app/coach/page.tsx
+++ b/src/app/coach/page.tsx
@@ -91,28 +91,28 @@ export default function CoachPage() {
data-slot="coach-page-card"
className="bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-none border-y shadow-sm sm:rounded-2xl sm:border"
>
- (
- {title}
- )}
- leadingHeaderActions={
- <>
-
-
-
- >
- }
+ (
+ {title}
+ )}
+ leadingHeaderActions={
+ <>
+
+
+
+ >
+ }
/>
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
index 4dea10486..23170697b 100644
--- a/src/app/global-error.tsx
+++ b/src/app/global-error.tsx
@@ -49,8 +49,7 @@ export default function GlobalError({
display: "flex",
alignItems: "center",
justifyContent: "center",
- padding:
- "calc(env(safe-area-inset-top, 0px) + 24px) 24px 24px 24px",
+ padding: "calc(env(safe-area-inset-top, 0px) + 24px) 24px 24px 24px",
}}
>
@@ -67,8 +66,7 @@ export default function GlobalError({
Etwas ist schiefgegangen / Something went wrong
- Ein kritischer Fehler ist aufgetreten. / A critical error
- occurred.
+ Ein kritischer Fehler ist aufgetreten. / A critical error occurred.
";
- inherits: false;
- initial-value: 0deg;
-}
-
:root {
/* Sampled spring (stiffness ~120, damping ~14): overshoots ~6% then settles
— the Oura/WHOOP fill feel. Falls back to the browser default on very old
@@ -658,93 +666,26 @@
}
}
-/* The conic sheen overlay sits over the ring; opacity animates from 0 so a
- browser without @property support (Safari <16.4 / FF <128) shows nothing
- rather than a frozen highlight band. */
+/* v1.18.7 — the conic specular SHEEN is retired. It swept a white highlight
+ across the arc once on reveal, which was the most overtly "premium / 3-D"
+ touch and clashed hardest with the flat charts. The class is kept as a
+ no-op so the cycle-ring + score-ring call sites that still render the
+ overlay div simply paint nothing. */
.wellness-ring-sheen {
- position: absolute;
- inset: 6%;
- border-radius: 50%;
- opacity: 0;
- /* A specular highlight must always LIGHTEN. Under mix-blend overlay a
- near-black light-mode `--card-foreground` would darken the tile into a
- grey band, so the sheen rides a fixed white in both themes. */
- background: radial-gradient(
- circle at 50% 50%,
- rgba(255, 255, 255, 0.72) 0%,
- transparent 70%
- );
- mix-blend-mode: overlay;
- pointer-events: none;
- -webkit-mask: conic-gradient(
- from var(--shimmer-angle),
- transparent 0deg,
- transparent 38deg,
- #000 58deg,
- transparent 80deg,
- transparent 360deg
- );
- mask: conic-gradient(
- from var(--shimmer-angle),
- transparent 0deg,
- transparent 38deg,
- #000 58deg,
- transparent 80deg,
- transparent 360deg
- );
-}
-[data-revealed="true"] .wellness-ring-sheen {
- animation: ringSheen 1500ms var(--ring-ease) forwards;
- animation-delay: calc(var(--reveal-delay, 0ms) + 550ms);
-}
-@keyframes ringSheen {
- 0% {
- --shimmer-angle: 0deg;
- opacity: 0;
- }
- 15% {
- opacity: 0.5;
- }
- 85% {
- opacity: 0.5;
- }
- 100% {
- --shimmer-angle: 360deg;
- opacity: 0;
- }
+ display: none;
}
-/* Token-tinted bloom behind the progress arc. The breathing pulse runs only
- on the strongest (green-band) ring so the strip celebrates a good score
- rather than jittering all five. `--ring-glow` is set inline per ring. */
-.wellness-ring-arc {
- filter: drop-shadow(
- 0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)
- );
-}
-/* Flat 2-D arc — no bloom, no pulse. The dashboard hero ring uses this so it
- reads as calmly as the chart + tile surfaces around it. */
-.wellness-ring-arc--flat {
- filter: none;
-}
+/* v1.18.7 — the arc is FLAT by default. The earlier treatment painted a
+ token-tinted drop-shadow bloom behind every arc plus a breathing pulse on
+ the green-band ring; against the flat 2-D Recharts charts that read as a
+ glowing 3-D dial. Both are gone — the arc is a plain stroke, the same
+ weight a chart series carries. `.wellness-ring-arc--flat` is kept as an
+ alias so the dashboard-hero call site (and any other) stays valid. */
+.wellness-ring-arc,
+.wellness-ring-arc--flat,
.wellness-ring-arc--pulse {
- /* Breathe a few times to celebrate a strong score, then settle — an
- infinite loop would repaint the (non-compositable) drop-shadow filter
- forever. Motion happens, then the surface goes calm. */
- animation: ringGlow 3.4s ease-in-out 2s 3;
-}
-@keyframes ringGlow {
- 0%,
- 100% {
- filter: drop-shadow(
- 0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent)
- );
- }
- 50% {
- filter: drop-shadow(
- 0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent)
- );
- }
+ filter: none;
+ animation: none;
}
@media (prefers-reduced-motion: reduce) {
@@ -762,15 +703,6 @@
transform: none;
animation: none;
}
- [data-revealed="true"] .wellness-ring-sheen {
- animation: none;
- opacity: 0;
- }
- .wellness-ring-arc,
- .wellness-ring-arc--pulse {
- filter: none;
- animation: none;
- }
}
/*
diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts
index 91347efb0..baf9fc31b 100644
--- a/src/app/insights/__tests__/assessment-spine-order.test.ts
+++ b/src/app/insights/__tests__/assessment-spine-order.test.ts
@@ -48,7 +48,11 @@ const BESPOKE_PAGES: ReadonlyArray<{
}> = [
{ slug: "weight", assessmentTag: " {
for (const { slug, assessmentTag, assessmentMayTrail } of BESPOKE_PAGES) {
it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => {
- const source = readFileSync(
- join(PAGES_DIR, slug, "page.tsx"),
- "utf8",
- );
+ const source = readFileSync(join(PAGES_DIR, slug, "page.tsx"), "utf8");
// The render branch that carries the assessment is the final
// `… ` in the module (the empty-state
@@ -70,7 +71,9 @@ describe("bespoke metric-detail spine — assessment is the last block", () => {
-1,
);
const shellOpen = source.lastIndexOf(")) {
+ if (
+ cur &&
+ typeof cur === "object" &&
+ part in (cur as Record)
+ ) {
cur = (cur as Record)[part];
} else {
return false;
diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx
index 7bd339b4f..d15bad292 100644
--- a/src/app/insights/bmi/page.tsx
+++ b/src/app/insights/bmi/page.tsx
@@ -95,9 +95,7 @@ export default function InsightsBmiPage() {
}
/>
-
+
);
}
diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx
index 3ef05f645..e80f5133e 100644
--- a/src/app/insights/medications/page.tsx
+++ b/src/app/insights/medications/page.tsx
@@ -108,7 +108,10 @@ export default function InsightsMedikamentePage() {
MED_STATUS_TIMEOUT_MS,
);
try {
- return apiGet(`/api/insights/medication-compliance-status?locale=${locale}`, { signal: controller.signal });
+ return apiGet(
+ `/api/insights/medication-compliance-status?locale=${locale}`,
+ { signal: controller.signal },
+ );
} finally {
clearTimeout(timeoutHandle);
}
@@ -170,9 +173,7 @@ export default function InsightsMedikamentePage() {
}
/>
-
+
);
}
@@ -217,7 +218,10 @@ export default function InsightsMedikamentePage() {
·
{categoryLabel}
{med.streak > 0 ? (
-
+
{med.streak === 1
? t("insights.dayStreakOne", { count: med.streak })
: t("insights.dayStreak", { count: med.streak })}
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 2b13d97e3..36ec5ed8e 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -24,7 +24,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
-import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived";
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
@@ -234,11 +233,11 @@ export default function InsightsPage() {
useScrollResetOnRoute();
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
- // `` so every routed sub-page can reach it.
- // The hero strip + suggested-prompt chips call `askCoach(prefill)`
- // on the same context, and the drawer itself is mounted next to
- // the provider in `src/app/insights/layout.tsx`.
- const coachLaunch = useCoachLaunch();
+ // ``; the drawer is mounted next to the provider in
+ // `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach
+ // entry points (the action button + suggested-prompt chips), so the
+ // overview page no longer reads the launch context — the drawer + its
+ // own launcher stay untouched.
const flags = useFeatureFlags();
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
@@ -471,14 +470,6 @@ export default function InsightsPage() {
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
userName={heroGreetingName}
- onAskCoach={
- coachLaunch
- ? (prefill?: string) => coachLaunch.askCoach(prefill ?? null)
- : undefined
- }
- onPickPrompt={
- coachLaunch ? (prompt) => coachLaunch.askCoach(prompt) : undefined
- }
healthScore={analytics?.healthScore ?? undefined}
// v1.16.8 — reserve the score card's column while the thick
// analytics payload is in flight. Every overview query
diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx
index 1dc4c8773..ccb7fdecf 100644
--- a/src/app/insights/pulse/page.tsx
+++ b/src/app/insights/pulse/page.tsx
@@ -165,7 +165,10 @@ export default function InsightsPulsPage() {
{/* v1.12.8 — the Einschätzung (assessment) sits ABOVE the cardio-fitness
CTA so the plain-language read of the pulse data leads, and the
cross-link to the dedicated VO₂ max page trails it. */}
- } />
+ }
+ />
{/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"), so the pulse page links across to its dedicated
@@ -182,7 +185,10 @@ export default function InsightsPulsPage() {
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
-
+
{t("insights.vo2Max.cardioLinkBody")}
diff --git a/src/app/insights/sleep/__tests__/empty-state-href.test.ts b/src/app/insights/sleep/__tests__/empty-state-href.test.ts
index 546cfacfe..58613b6ba 100644
--- a/src/app/insights/sleep/__tests__/empty-state-href.test.ts
+++ b/src/app/insights/sleep/__tests__/empty-state-href.test.ts
@@ -19,7 +19,10 @@ describe("sleep empty-state CTA href", () => {
"utf8",
);
const match = source.match(/href="\/settings\/([a-z-]+)"/);
- expect(match, "expected a /settings/ href in the sleep page").not.toBeNull();
+ expect(
+ match,
+ "expected a /settings/ href in the sleep page",
+ ).not.toBeNull();
const slug = match![1];
expect(slug).toBe("integrations");
expect(isSettingsSectionSlug(slug)).toBe(true);
diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx
index d6f4271de..f8e779af6 100644
--- a/src/app/insights/sleep/page.tsx
+++ b/src/app/insights/sleep/page.tsx
@@ -14,6 +14,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section";
+import { ChronotypeSection } from "@/components/insights/sleep/chronotype-section";
import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -116,9 +117,11 @@ export default function InsightsSchlafPage() {
/>
{/*
- v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative
- (the same DTO iOS renders), gated on the data-bearing branch so a
- source-less account never fetches.
+ v1.17.0 — sleep-debt headline. Server-authoritative (the same DTO iOS
+ renders), gated on the data-bearing branch so a source-less account
+ never fetches. v1.18.7 W-D — the chronotype moved out to the prominent
+ bottom block (`` below); this section is the
+ sleep-debt headline alone.
*/}
@@ -134,6 +137,15 @@ export default function InsightsSchlafPage() {
+
+ {/*
+ v1.18.7 W-D — chronotype as the PROMINENT bottom treatment of the
+ Sleep view (maintainer directive): a large band label with the natural
+ sleep-midpoint clock beneath it and an expandable disclosure for social
+ jetlag + MSFsc. Reads the same `["sleep-rhythm"]` cache the debt section
+ warmed, so it adds no round-trip; gated on the data-bearing branch.
+ */}
+
);
}
diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx
index b31c56ede..b1fd59e0f 100644
--- a/src/app/insights/weight/page.tsx
+++ b/src/app/insights/weight/page.tsx
@@ -125,7 +125,10 @@ export default function InsightsGewichtPage() {
off the overview onto its metric page). */}
- } />
+ }
+ />
);
}
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 425d10cfd..6abc781fb 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -100,7 +100,9 @@ export default function LabsPage() {
onOpenChange={setDialogOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
- footer={
}
+ footer={
+
+ }
>
-
+
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
diff --git a/src/app/measurements/__tests__/add-token-resolution.test.ts b/src/app/measurements/__tests__/add-token-resolution.test.ts
index dd227de92..22e10b51c 100644
--- a/src/app/measurements/__tests__/add-token-resolution.test.ts
+++ b/src/app/measurements/__tests__/add-token-resolution.test.ts
@@ -18,7 +18,12 @@ import {
describe("measurements ?add= deep link", () => {
const repoRoot = join(__dirname, "..", "..", "..", "..");
const insightsRoot = join(repoRoot, "src", "app", "insights");
- const insightsComponentsRoot = join(repoRoot, "src", "components", "insights");
+ const insightsComponentsRoot = join(
+ repoRoot,
+ "src",
+ "components",
+ "insights",
+ );
// Captures both `href="/measurements?add=FOO"` literals and the
// dynamic `?add=${prop}` template the shared HealthKit metric page
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index 57fc95791..8411579ba 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -10,6 +10,7 @@ import {
resolveAddToken,
} from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -41,6 +42,18 @@ export default function MeasurementsPage() {
// effect, so that path stays render-driven.
const addParam = searchParams.get("add");
const initialAdd = addParam ? resolveAddToken(addParam) : null;
+ // v1.18.7 (Wave E) — a `?type=` deep link (e.g. the
+ // Vorsorge card's "Show measurements") seeds the list's type filter on
+ // first paint. Captured once via a lazy initializer so the value is
+ // stable even after the effect below strips the query string; an unknown
+ // token is dropped (validated against the canonical type-label map), so a
+ // stale link never traps the user on a broken filter.
+ const typeParam = searchParams.get("type");
+ const [initialType] = useState(() =>
+ typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS
+ ? typeParam
+ : undefined,
+ );
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
const [defaultType, setDefaultType] = useState(
() => initialAdd ?? undefined,
@@ -82,11 +95,14 @@ export default function MeasurementsPage() {
}
// Drop the query string so the back-button leaves the user on
- // `/measurements` rather than re-opening the dialog. Effect-driven
- // (client-only) because `router.replace` is not callable during SSR.
+ // `/measurements` rather than re-opening the dialog (or re-seeding the
+ // type filter). The type filter is already seeded into the list's own
+ // state via `initialType`, so stripping the param doesn't lose it.
+ // Effect-driven (client-only) because `router.replace` is not callable
+ // during SSR.
useEffect(() => {
- if (addParam) router.replace("/measurements");
- }, [addParam, router]);
+ if (addParam || typeParam) router.replace("/measurements");
+ }, [addParam, typeParam, router]);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
@@ -157,7 +173,10 @@ export default function MeasurementsPage() {
/>
- setDialogOpen(true)} />
+ setDialogOpen(true)}
+ initialType={initialType}
+ />
);
}
diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx
index ffe82a7be..dd1365992 100644
--- a/src/app/medications/[id]/page.tsx
+++ b/src/app/medications/[id]/page.tsx
@@ -26,7 +26,7 @@ import { Card } from "@/components/ui/card";
import {
MedicationDetailTabs,
type MedicationDetailSnapshot,
-} from "@/components/medications/detail/MedicationDetailTabs";
+} from "@/components/medications/detail/medication-detail-tabs";
import { apiGet } from "@/lib/api/api-fetch";
export default function MedicationDetailPage({
diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx
index 418e65477..f1c2a45ca 100644
--- a/src/app/medications/__tests__/list-view-toggle.test.tsx
+++ b/src/app/medications/__tests__/list-view-toggle.test.tsx
@@ -27,7 +27,7 @@ vi.mock("@/hooks/use-auth", () => ({
// The create wizard and the dialogs are closed in every assertion here;
// mock them away so the page render stays scoped to the list surface.
-vi.mock("@/components/medications/wizard/MedicationWizardDialog", () => ({
+vi.mock("@/components/medications/wizard/medication-wizard-dialog", () => ({
MedicationWizardDialog: () => null,
}));
vi.mock("@/components/medications/log-intake-dialog", () => ({
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index 002763a41..a65d0182e 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
-import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
+import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
import { LogIntakeDialog } from "@/components/medications/log-intake-dialog";
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index ea9c4d44e..ff0edadc8 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -1,12 +1,12 @@
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
-import { OnboardingShell } from "@/components/onboarding/OnboardingShell";
-import { WelcomeCarousel } from "@/components/onboarding/WelcomeCarousel";
-import { GoalsChipPicker } from "@/components/onboarding/GoalsChipPicker";
-import { SourceCardGrid } from "@/components/onboarding/SourceCardGrid";
-import { BaselineForm } from "@/components/onboarding/BaselineForm";
-import { DoneScreen } from "@/components/onboarding/DoneScreen";
+import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
+import { WelcomeCarousel } from "@/components/onboarding/welcome-carousel";
+import { GoalsChipPicker } from "@/components/onboarding/goals-chip-picker";
+import { SourceCardGrid } from "@/components/onboarding/source-card-grid";
+import { BaselineForm } from "@/components/onboarding/baseline-form";
+import { DoneScreen } from "@/components/onboarding/done-screen";
import { Button } from "@/components/ui/button";
import { getSession } from "@/lib/auth/session";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -56,7 +56,10 @@ export default async function OnboardingStepPage({ params }: PageProps) {
const { step: stepParam } = await params;
const parsedStep = Number.parseInt(stepParam, 10);
- if (!Number.isFinite(parsedStep) || !VALID_STEPS.includes(parsedStep as Step)) {
+ if (
+ !Number.isFinite(parsedStep) ||
+ !VALID_STEPS.includes(parsedStep as Step)
+ ) {
notFound();
}
const requested = parsedStep as Step;
diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx
index 7e2daca90..5a233e2b0 100644
--- a/src/app/onboarding/layout.tsx
+++ b/src/app/onboarding/layout.tsx
@@ -10,6 +10,10 @@ import type { ReactNode } from "react";
* Content agent that follows W14b-Foundation will swap the root page's
* redirect target once every step has real content.
*/
-export default function OnboardingLayout({ children }: { children: ReactNode }) {
+export default function OnboardingLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
return {children}
;
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d021b42b3..0c27f4df8 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -619,8 +619,7 @@ export default function DashboardPage() {
batchWindow.from,
batchWindow.to,
),
- enabled:
- isAuthenticated && mounted && batchChartTypes.length > 0,
+ enabled: isAuthenticated && mounted && batchChartTypes.length > 0,
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
diff --git a/src/app/privacy/__tests__/page.test.tsx b/src/app/privacy/__tests__/page.test.tsx
index 982688f16..d145a5862 100644
--- a/src/app/privacy/__tests__/page.test.tsx
+++ b/src/app/privacy/__tests__/page.test.tsx
@@ -79,12 +79,10 @@ describe("", () => {
// Each section ships a `data-slot="privacy-section-de"` body and a
// collapsible `data-slot="privacy-section-en"` translation. 11
// sections total.
- const deSlotCount = (
- html.match(/data-slot="privacy-section-de"/g) ?? []
- ).length;
- const enSlotCount = (
- html.match(/data-slot="privacy-section-en"/g) ?? []
- ).length;
+ const deSlotCount = (html.match(/data-slot="privacy-section-de"/g) ?? [])
+ .length;
+ const enSlotCount = (html.match(/data-slot="privacy-section-en"/g) ?? [])
+ .length;
expect(deSlotCount).toBe(11);
expect(enSlotCount).toBe(11);
// The English translation `` carries the "English
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 6fce0785f..7a230fb95 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -67,7 +67,7 @@ function Section({
bodyEn,
}: SectionProps) {
return (
-
+
{`${numberLabel}. ${titleEn}`}
@@ -82,7 +82,7 @@ function Section({
{bodyDe}
@@ -144,7 +144,10 @@ function SubProcessor({
{locationDe}
-
+
Role. {roleEn}
@@ -177,7 +180,7 @@ export default function PrivacyPage() {
{/* Top bar — visible home-link so an App-Store reviewer can confirm
the policy belongs to the same brand as the iOS app. Public; no
nav menu (this page is reachable without a session). */}
-
+
Diese Erklärung ist deutsch-englisch geführt. Jeder Abschnitt
- beginnt mit dem deutschen Text; die englische Übersetzung
- steht eingeklappt direkt darunter.
+ beginnt mit dem deutschen Text; die englische Übersetzung steht
+ eingeklappt direkt darunter.
@@ -247,7 +250,10 @@ export default function PrivacyPage() {
>
-
+
1. Overview / Überblick
@@ -260,7 +266,10 @@ export default function PrivacyPage() {
-
+
3. Why we collect each category / Zwecke der Verarbeitung
@@ -273,13 +282,19 @@ export default function PrivacyPage() {
-
+
5. Data storage and retention / Speicherung &
Speicherdauer
-
+
6. Your rights (GDPR Art. 15-22, DSGVO) / Ihre Rechte
@@ -310,12 +325,18 @@ export default function PrivacyPage() {
-
+
10. Changes to this policy / Änderungen
-
+
11. Contact / Kontakt
@@ -331,9 +352,9 @@ export default function PrivacyPage() {
bodyDe={
<>
- HealthLog ist eine quelloffene, selbst-hostbare Anwendung
- zur persönlichen Gesundheitsprotokollierung. Der Quellcode
- wird unter der GNU Affero General Public License v3.0 auf{" "}
+ HealthLog ist eine quelloffene, selbst-hostbare Anwendung zur
+ persönlichen Gesundheitsprotokollierung. Der Quellcode wird
+ unter der GNU Affero General Public License v3.0 auf{" "}
github.com/MBombeck/HealthLog
{" "}
- veröffentlicht. Diese Erklärung beschreibt, wie die
- Instanz, über die Sie diese Seite abrufen, und die
- zugehörige iOS-Anwendung HealthLog for iOS {" "}
- (Bundle-ID{" "}
+ veröffentlicht. Diese Erklärung beschreibt, wie die Instanz,
+ über die Sie diese Seite abrufen, und die zugehörige
+ iOS-Anwendung HealthLog for iOS (Bundle-ID{" "}
dev.healthlog.app
@@ -353,11 +373,10 @@ export default function PrivacyPage() {
Verantwortlicher im Sinne von Art. 4 Nr. 7 DSGVO ist der
- Betreiber dieser Instanz (Privatperson mit Sitz in
- Deutschland). Eigenständig betriebene, selbst-gehostete
- Instanzen unterliegen der Datenschutzerklärung ihres
- jeweiligen Betreibers; dieses Dokument gilt ausschließlich
- für diese Instanz.
+ Betreiber dieser Instanz (Privatperson mit Sitz in Deutschland).
+ Eigenständig betriebene, selbst-gehostete Instanzen unterliegen
+ der Datenschutzerklärung ihres jeweiligen Betreibers; dieses
+ Dokument gilt ausschließlich für diese Instanz.
Für Fragen oder Datenschutz-Anfragen siehe Abschnitt 11
@@ -378,21 +397,20 @@ export default function PrivacyPage() {
>
github.com/MBombeck/HealthLog
{" "}
- under the GNU Affero General Public License v3.0. This
- policy describes how the instance serving this page and
- the companion iOS application HealthLog for iOS {" "}
- (bundle identifier{" "}
+ under the GNU Affero General Public License v3.0. This policy
+ describes how the instance serving this page and the companion
+ iOS application HealthLog for iOS (bundle identifier{" "}
dev.healthlog.app
) handle personal data.
- The controller under Art. 4 (7) GDPR is the operator of
- this instance (an individual based in Germany). Self-
- hosted deployments controlled by a different operator are
- governed by that operator's own privacy policy; this
- document applies only to this instance.
+ The controller under Art. 4 (7) GDPR is the operator of this
+ instance (an individual based in Germany). Self- hosted
+ deployments controlled by a different operator are governed by
+ that operator's own privacy policy; this document applies
+ only to this instance.
For questions or data-subject requests, see section 11
@@ -411,10 +429,9 @@ export default function PrivacyPage() {
<>
HealthLog speichert Beobachtungen, die der Nutzer selbst
- erfasst, sowie Gesundheitssignale, die der Nutzer
- ausdrücklich über eine Integration verbunden hat. Die
- folgenden Kategorien sind für den veröffentlichten
- Funktionsumfang abschließend.
+ erfasst, sowie Gesundheitssignale, die der Nutzer ausdrücklich
+ über eine Integration verbunden hat. Die folgenden Kategorien
+ sind für den veröffentlichten Funktionsumfang abschließend.
2.1 Konto und Authentifizierung
@@ -422,45 +439,42 @@ export default function PrivacyPage() {
E-Mail-Adresse und gewählter Benutzername.
- Passwort-Hash (Argon2id; das Klartext-Passwort wird zu
- keinem Zeitpunkt gespeichert oder protokolliert).
+ Passwort-Hash (Argon2id; das Klartext-Passwort wird zu keinem
+ Zeitpunkt gespeichert oder protokolliert).
Optional: WebAuthn- / Passkey-Anmeldedaten (öffentlicher
Schlüssel, Credential-ID, Signaturzähler).
- Sitzungskennungen (HTTP-only-Cookie im Web; opake API-
- Token unter iOS, gespeichert im Geräte-Keychain).
+ Sitzungskennungen (HTTP-only-Cookie im Web; opake API- Token
+ unter iOS, gespeichert im Geräte-Keychain).
- Profil-Metadaten: Sprache, Zeitzone, Geburtsdatum
- (optional), biologisches Geschlecht (optional), Körper-
- größe (optional) — zur Berechnung altersgerechter
- Zielbereiche und des BMI.
+ Profil-Metadaten: Sprache, Zeitzone, Geburtsdatum (optional),
+ biologisches Geschlecht (optional), Körper- größe (optional) —
+ zur Berechnung altersgerechter Zielbereiche und des BMI.
2.2 Manuell erfasste Gesundheitsdaten
- Jeder in den App-Formularen eingegebene Wert, einschließ-
- lich Zeitstempel, optionaler Notiz und Metrik-Typ. Die
- vollständige Aufzählung der unterstützten Metriken steht
- im OpenAPI-Schema; die nutzerrelevante Teilmenge umfasst
- Körpergewicht, Körperfett, Körpertemperatur, BMI
- (abgeleitet), Blutdruck (systolisch / diastolisch) und
- Puls (gepaarte Messung), Blutzucker mit Kontext (nüchtern /
- postprandial / zufällig / vor dem Schlafengehen) und
- wählbarer Einheit (mg/dL oder mmol/L), Ruhepuls, HRV
- (SDNN), VO₂max, Sauerstoffsättigung, Schlafdauer inkl. Pro-
- Stadien-Aufschlüsselung (Wach / REM / Core / Tief),
- Schritte, gelaufene oder gerannte Distanz, Aktive-Energie,
- gestiegene Stockwerke, Lärmpegel (Umgebung und Kopfhörer),
- Tageslicht-Zeit, Stimmung (Skala 1–5) mit optionaler
- Freitext-Notiz sowie Workouts (Sportart, Dauer, Distanz
- und optional GPS-Route, ausschließlich wenn der Nutzer
- eine Route anhängt).
+ Jeder in den App-Formularen eingegebene Wert, einschließ- lich
+ Zeitstempel, optionaler Notiz und Metrik-Typ. Die vollständige
+ Aufzählung der unterstützten Metriken steht im OpenAPI-Schema;
+ die nutzerrelevante Teilmenge umfasst Körpergewicht, Körperfett,
+ Körpertemperatur, BMI (abgeleitet), Blutdruck (systolisch /
+ diastolisch) und Puls (gepaarte Messung), Blutzucker mit Kontext
+ (nüchtern / postprandial / zufällig / vor dem Schlafengehen) und
+ wählbarer Einheit (mg/dL oder mmol/L), Ruhepuls, HRV (SDNN),
+ VO₂max, Sauerstoffsättigung, Schlafdauer inkl. Pro-
+ Stadien-Aufschlüsselung (Wach / REM / Core / Tief), Schritte,
+ gelaufene oder gerannte Distanz, Aktive-Energie, gestiegene
+ Stockwerke, Lärmpegel (Umgebung und Kopfhörer), Tageslicht-Zeit,
+ Stimmung (Skala 1–5) mit optionaler Freitext-Notiz sowie
+ Workouts (Sportart, Dauer, Distanz und optional GPS-Route,
+ ausschließlich wenn der Nutzer eine Route anhängt).
2.3 Apple Health (iOS-Anwendung) — Datenfluss
@@ -468,19 +482,18 @@ export default function PrivacyPage() {
Wenn der Nutzer der iOS-Anwendung Lesezugriff auf Apple
HealthKit erteilt, liest HealthLog Stichproben der unten
- aufgeführten Identifier. HealthKit-Daten verbleiben auf
- dem Gerät des Nutzers und in dessen iCloud-gestütztem
- Health-Speicher; die iOS-Anwendung überträgt die relevant-
- en Stichproben über HTTPS (TLS 1.3, Zertifikat-Pinning) an
- den HealthLog-Server (Endpunkt{" "}
+ aufgeführten Identifier. HealthKit-Daten verbleiben auf dem
+ Gerät des Nutzers und in dessen iCloud-gestütztem
+ Health-Speicher; die iOS-Anwendung überträgt die relevant- en
+ Stichproben über HTTPS (TLS 1.3, Zertifikat-Pinning) an den
+ HealthLog-Server (Endpunkt{" "}
POST /api/measurements
- ), damit die Web-Oberfläche dieselben Verläufe darstellen
- kann wie das iPhone. Es findet keine Übertragung an Dritte
- statt; die Daten werden ausschließlich auf der unter
- Abschnitt 5 beschriebenen Infrastruktur in Deutschland
- gespeichert.
+ ), damit die Web-Oberfläche dieselben Verläufe darstellen kann
+ wie das iPhone. Es findet keine Übertragung an Dritte statt; die
+ Daten werden ausschließlich auf der unter Abschnitt 5
+ beschriebenen Infrastruktur in Deutschland gespeichert.
HKQuantityTypeIdentifier:
bodyTemperature
- bloodPressureSystolic
+
+ bloodPressureSystolic
+
- bloodPressureDiastolic
+
+ bloodPressureDiastolic
+
bloodGlucose
@@ -515,7 +532,9 @@ export default function PrivacyPage() {
restingHeartRate
- heartRateVariabilitySDNN
+
+ heartRateVariabilitySDNN
+
vo2Max
@@ -530,50 +549,56 @@ export default function PrivacyPage() {
flightsClimbed
- distanceWalkingRunning
+
+ distanceWalkingRunning
+
- environmentalAudioExposure
+
+ environmentalAudioExposure
+
- headphoneAudioExposure
+
+ headphoneAudioExposure
+
- timeInDaylight (iOS 17+)
+ timeInDaylight (iOS
+ 17+)
HKCategoryTypeIdentifier:
- sleepAnalysis —
- mit voller Pro-Stadien-Granularität (Awake, REM, Core,
- Deep). Die Stadien werden serverseitig als separate
- Zeilen abgelegt, damit das Diagramm sie stapeln kann.
+ sleepAnalysis — mit
+ voller Pro-Stadien-Granularität (Awake, REM, Core, Deep). Die
+ Stadien werden serverseitig als separate Zeilen abgelegt,
+ damit das Diagramm sie stapeln kann.
- Schreibzugriff wird für eine Teilmenge angefordert
- (Körpermasse, Blutdruck systolisch / diastolisch, Blut-
- zucker), damit manuelle Einträge in der iOS-App zurück
- nach HealthKit fließen können. Der Nutzer steuert beide
- Richtungen über die Berechtigungsoberfläche der iOS-
- Health-App und kann jederzeit widerrufen.
+ Schreibzugriff wird für eine Teilmenge angefordert (Körpermasse,
+ Blutdruck systolisch / diastolisch, Blut- zucker), damit
+ manuelle Einträge in der iOS-App zurück nach HealthKit fließen
+ können. Der Nutzer steuert beide Richtungen über die
+ Berechtigungsoberfläche der iOS- Health-App und kann jederzeit
+ widerrufen.
2.4 Withings-Synchronisation (optional)
- Verbindet der Nutzer ein Withings-Konto, speichert
- HealthLog die OAuth-Refresh- und -Access-Token (spalten-
- weise verschlüsselt) sowie die Withings-Nutzerkennung.
- Webhook-getriebene Folgesynchronisierungen ziehen Körper-
- gewicht, Fettanteil, fettfreie Masse, Muskelmasse,
- Knochenmasse, Wassergehalt, Magermasse, Hydration,
- Blutdruck (systolisch / diastolisch) und Puls, Blut-
- zucker, Körpertemperatur (Basal- und Hauttemperatur),
- Aktivitätssummen (Schritte, Distanz, aktive Kalorien),
- Schlafsitzungen mit Pro-Stadien-Segmenten (Awake / Light /
- Deep / REM) sowie SpO₂ und Herzfrequenzvariabilität,
+ Verbindet der Nutzer ein Withings-Konto, speichert HealthLog die
+ OAuth-Refresh- und -Access-Token (spalten- weise verschlüsselt)
+ sowie die Withings-Nutzerkennung. Webhook-getriebene
+ Folgesynchronisierungen ziehen Körper- gewicht, Fettanteil,
+ fettfreie Masse, Muskelmasse, Knochenmasse, Wassergehalt,
+ Magermasse, Hydration, Blutdruck (systolisch / diastolisch) und
+ Puls, Blut- zucker, Körpertemperatur (Basal- und
+ Hauttemperatur), Aktivitätssummen (Schritte, Distanz, aktive
+ Kalorien), Schlafsitzungen mit Pro-Stadien-Segmenten (Awake /
+ Light / Deep / REM) sowie SpO₂ und Herzfrequenzvariabilität,
soweit vorhanden.
@@ -586,38 +611,36 @@ export default function PrivacyPage() {
GLP-1).
- Einnahme-Ereignisse: geplante Zeit, tatsächliche Zeit,
- Status (eingenommen, ausgelassen, verpasst).
+ Einnahme-Ereignisse: geplante Zeit, tatsächliche Zeit, Status
+ (eingenommen, ausgelassen, verpasst).
Bei GLP-1-Therapien: Dosis-Änderungs-Historie, Injektions-
- Ereignisse mit optionaler Injektionsstelle und Pen-
- Kennung, Nebenwirkungs-Protokolle entlang einer fixen
- Taxonomie, Pen-und-Ampullen-Inventar mit 30-Tage-In-
- Use-Uhr.
+ Ereignisse mit optionaler Injektionsstelle und Pen- Kennung,
+ Nebenwirkungs-Protokolle entlang einer fixen Taxonomie,
+ Pen-und-Ampullen-Inventar mit 30-Tage-In- Use-Uhr.
2.6 KI-Coach und Insights — Off-Device-Transit
- Aktiviert der Nutzer die KI-Coach-Oberfläche und
- konfiguriert einen Sprachmodell-Anbieter, übermittelt
- HealthLog auf Anforderung ein Snapshot-Bündel an diesen
- Anbieter. Das Bündel enthält Gesundheitskontext aus den
- oben genannten Daten (Aggregate, jüngste Beobachtungen,
- Zielbereiche, optionaler Medikamentenkontext) und{" "}
+ Aktiviert der Nutzer die KI-Coach-Oberfläche und konfiguriert
+ einen Sprachmodell-Anbieter, übermittelt HealthLog auf
+ Anforderung ein Snapshot-Bündel an diesen Anbieter. Das Bündel
+ enthält Gesundheitskontext aus den oben genannten Daten
+ (Aggregate, jüngste Beobachtungen, Zielbereiche, optionaler
+ Medikamentenkontext) und{" "}
niemals rohe HealthKit-Identifier oder rohe Sample-IDs
- ; der Server normalisiert HK-Stichproben vorab in die
- interne Metrik-Taxonomie. Die Übertragung erfolgt
- verschlüsselt (TLS 1.3); standardmäßig ist die KI-Coach-
- Funktion deaktiviert und wird nur nach ausdrücklicher
- Nutzereinwilligung pro Anbieter aktiviert (siehe Einwilli-
- gungsnachweis unter Abschnitt 2.7). HealthLog speichert
- keinen langlebigen Gesprächszustand über den letzten
- Thread hinaus. Abschnitt 4 listet die in Frage kommenden
+ ; der Server normalisiert HK-Stichproben vorab in die interne
+ Metrik-Taxonomie. Die Übertragung erfolgt verschlüsselt (TLS
+ 1.3); standardmäßig ist die KI-Coach- Funktion deaktiviert und
+ wird nur nach ausdrücklicher Nutzereinwilligung pro Anbieter
+ aktiviert (siehe Einwilli- gungsnachweis unter Abschnitt 2.7).
+ HealthLog speichert keinen langlebigen Gesprächszustand über den
+ letzten Thread hinaus. Abschnitt 4 listet die in Frage kommenden
Anbieter (Anthropic, OpenAI) auf.
@@ -628,24 +651,23 @@ export default function PrivacyPage() {
Aktivierung pro Anbieter als auch Änderungen am
Datentiefe-Schalter — wird mit Zeitstempel, Anbieter,
Versionskennung des Einwilligungstextes und Hash des
- Snapshot-Schemas als „Consent Receipt“ persistiert. Der
- Endpunkt zum Abruf ist{" "}
+ Snapshot-Schemas als „Consent Receipt“ persistiert.
+ Der Endpunkt zum Abruf ist{" "}
GET /api/account/consents
- ; die Speicherdauer der Belege beträgt fünf Jahre, danach
- werden sie automatisch gelöscht. Ein Widerruf wird als
- eigenes Receipt mit umgekehrter Polarität abgelegt; der
- Anbieter erhält ab diesem Zeitpunkt keine Snapshot-Bündel
- mehr.
+ ; die Speicherdauer der Belege beträgt fünf Jahre, danach werden
+ sie automatisch gelöscht. Ein Widerruf wird als eigenes Receipt
+ mit umgekehrter Polarität abgelegt; der Anbieter erhält ab
+ diesem Zeitpunkt keine Snapshot-Bündel mehr.
2.8 Geräte- und Integrations-Metadaten, Push
- Geräte-Kennung (zufällige UUID, clientseitig erzeugt
- und im iOS-Keychain gespeichert), übermittelt als{" "}
+ Geräte-Kennung (zufällige UUID, clientseitig erzeugt und im
+ iOS-Keychain gespeichert), übermittelt als{" "}
X-Device-Id
{" "}
@@ -658,8 +680,8 @@ export default function PrivacyPage() {
aktivierten Push-Benachrichtigungen.
- Telegram-Chat-Kennung — nur, wenn der Nutzer die
- optionale Telegram-Bot-Benachrichtigung verbunden hat.
+ Telegram-Chat-Kennung — nur, wenn der Nutzer die optionale
+ Telegram-Bot-Benachrichtigung verbunden hat.
@@ -668,8 +690,8 @@ export default function PrivacyPage() {
Authentifizierungs-Ereignisse (Login-Erfolg/-Fehlschlag,
- Passkey-Registrierung, Passwort-Wechsel, Sitzungs-
- Widerruf) — 90 Tage zur forensischen Aufklärung.
+ Passkey-Registrierung, Passwort-Wechsel, Sitzungs- Widerruf) —
+ 90 Tage zur forensischen Aufklärung.
Server-Zugriffsprotokolle (Zeit, Pfad, Statuscode, User-
@@ -677,8 +699,8 @@ export default function PrivacyPage() {
Fehlersuche.
- Fehlgeschlagene APNs-Zustellungen — 30 Tage zur
- Wartung des Push-Pfads, danach automatische Löschung.
+ Fehlgeschlagene APNs-Zustellungen — 30 Tage zur Wartung des
+ Push-Pfads, danach automatische Löschung.
@@ -691,17 +713,16 @@ export default function PrivacyPage() {
Keine Zahlungsdaten (es gibt keinen Bezahltarif).
- Keine fortlaufende Hintergrund-Standortermittlung —
- GPS-Routen nur, wenn der Nutzer sie einem Workout
- ausdrücklich beifügt.
+ Keine fortlaufende Hintergrund-Standortermittlung — GPS-Routen
+ nur, wenn der Nutzer sie einem Workout ausdrücklich beifügt.
Keine Social-Network-Kennungen und kein Kontaktlisten-
Abgleich.
- Keine clientseitigen Produkt-Analytics; die Instanz
- liefert kein Analytics-SDK aus.
+ Keine clientseitigen Produkt-Analytics; die Instanz liefert
+ kein Analytics-SDK aus.
>
@@ -709,11 +730,10 @@ export default function PrivacyPage() {
bodyEn={
<>
- HealthLog is designed to record observations the user
- enters themselves and to consume health signals the user
- has explicitly connected through a third-party
- integration. The categories below are exhaustive for the
- released feature set.
+ HealthLog is designed to record observations the user enters
+ themselves and to consume health signals the user has explicitly
+ connected through a third-party integration. The categories
+ below are exhaustive for the released feature set.
2.1 Account and authentication
@@ -721,62 +741,58 @@ export default function PrivacyPage() {
Email address and chosen username.
- Password hash (Argon2id; the plain-text password is
- never stored or logged).
+ Password hash (Argon2id; the plain-text password is never
+ stored or logged).
Optional WebAuthn / passkey credentials (public key,
credential ID, sign counter).
- Session identifiers (HTTP-only cookie on the web;
- opaque API tokens on iOS, stored in the device
- Keychain).
+ Session identifiers (HTTP-only cookie on the web; opaque API
+ tokens on iOS, stored in the device Keychain).
- Profile metadata: locale, timezone, date of birth
- (optional), biological sex (optional), height
- (optional) — used to compute age-adjusted target
- ranges and BMI.
+ Profile metadata: locale, timezone, date of birth (optional),
+ biological sex (optional), height (optional) — used to compute
+ age-adjusted target ranges and BMI.
2.2 Manually entered health data
- Any value entered through the in-app forms, including
- timestamp, optional note, and metric type: body weight,
- body-fat percentage, body temperature, BMI (derived),
- blood pressure (systolic / diastolic) and pulse rate as
- paired readings, blood glucose with context (fasting,
- postprandial, random, bedtime) and configurable unit
- (mg/dL or mmol/L), resting heart rate, heart-rate
- variability (SDNN), VO₂ max, oxygen saturation, sleep
- duration with per-stage breakdown (Awake / REM / Core /
- Deep), step count, distance walked or run, active-energy
- burned, flights climbed, environmental and headphone
- audio-exposure levels, time in daylight, mood (1-5
- scale) with optional free-text note, and workout
- sessions (activity type, duration, distance, and an
- optional GPS route attached manually by the user).
+ Any value entered through the in-app forms, including timestamp,
+ optional note, and metric type: body weight, body-fat
+ percentage, body temperature, BMI (derived), blood pressure
+ (systolic / diastolic) and pulse rate as paired readings, blood
+ glucose with context (fasting, postprandial, random, bedtime)
+ and configurable unit (mg/dL or mmol/L), resting heart rate,
+ heart-rate variability (SDNN), VO₂ max, oxygen saturation, sleep
+ duration with per-stage breakdown (Awake / REM / Core / Deep),
+ step count, distance walked or run, active-energy burned,
+ flights climbed, environmental and headphone audio-exposure
+ levels, time in daylight, mood (1-5 scale) with optional
+ free-text note, and workout sessions (activity type, duration,
+ distance, and an optional GPS route attached manually by the
+ user).
2.3 Apple Health (iOS application) — data flow
- When the user grants the iOS application read access to
- Apple HealthKit, HealthLog reads samples for the
- identifiers listed below. HealthKit data remains on the
- user's device and the user's iCloud-backed Health store;
- the iOS application copies relevant samples to the
- HealthLog server over HTTPS (TLS 1.3, certificate
- pinning) via{" "}
+ When the user grants the iOS application read access to Apple
+ HealthKit, HealthLog reads samples for the identifiers listed
+ below. HealthKit data remains on the user's device and the
+ user's iCloud-backed Health store; the iOS application
+ copies relevant samples to the HealthLog server over HTTPS (TLS
+ 1.3, certificate pinning) via{" "}
POST /api/measurements
{" "}
- so the web surface can render the same trends as the
- iPhone. No third-party transit; data lives on the
- German-located infrastructure described in section 5.
+ so the web surface can render the same trends as the iPhone. No
+ third-party transit; data lives on the German-located
+ infrastructure described in section 5.
HKQuantityTypeIdentifier:
bodyTemperature
- bloodPressureSystolic
+
+ bloodPressureSystolic
+
- bloodPressureDiastolic
+
+ bloodPressureDiastolic
+
bloodGlucose
@@ -811,7 +831,9 @@ export default function PrivacyPage() {
restingHeartRate
- heartRateVariabilitySDNN
+
+ heartRateVariabilitySDNN
+
vo2Max
@@ -826,117 +848,119 @@ export default function PrivacyPage() {
flightsClimbed
- distanceWalkingRunning
+
+ distanceWalkingRunning
+
- environmentalAudioExposure
+
+ environmentalAudioExposure
+
- headphoneAudioExposure
+
+ headphoneAudioExposure
+
- timeInDaylight (iOS 17+)
+ timeInDaylight (iOS
+ 17+)
HKCategoryTypeIdentifier:
sleepAnalysis —
- with full per-stage granularity (Awake, REM, Core,
- Deep). Stages are stored server-side as separate rows
- so the chart can stack them.
+ with full per-stage granularity (Awake, REM, Core, Deep).
+ Stages are stored server-side as separate rows so the chart
+ can stack them.
Write access is requested for a subset (body mass, blood-
- pressure systolic / diastolic, blood glucose) so that
- manual entries made inside the iOS app can flow back
- into HealthKit. The user controls both directions in
- the iOS Health app's permission surface and may revoke
- at any time.
+ pressure systolic / diastolic, blood glucose) so that manual
+ entries made inside the iOS app can flow back into HealthKit.
+ The user controls both directions in the iOS Health app's
+ permission surface and may revoke at any time.
2.4 Withings sync (optional)
- When the user connects a Withings account, HealthLog
- stores the OAuth refresh and access tokens (encrypted
- at the column level) and the user's Withings
- identifier. Subsequent webhook-driven syncs pull body
- weight, fat ratio, fat-free mass, muscle mass, bone
- mass, water mass, lean mass, hydration, blood pressure
- (systolic / diastolic) and pulse, blood glucose
- readings, body temperature (basal and skin), activity
- totals (steps, distance, active calories), sleep
- sessions with per-stage segments (Awake / Light / Deep /
- REM), and SpO₂ + HRV where available.
+ When the user connects a Withings account, HealthLog stores the
+ OAuth refresh and access tokens (encrypted at the column level)
+ and the user's Withings identifier. Subsequent
+ webhook-driven syncs pull body weight, fat ratio, fat-free mass,
+ muscle mass, bone mass, water mass, lean mass, hydration, blood
+ pressure (systolic / diastolic) and pulse, blood glucose
+ readings, body temperature (basal and skin), activity totals
+ (steps, distance, active calories), sleep sessions with
+ per-stage segments (Awake / Light / Deep / REM), and SpO₂ + HRV
+ where available.
2.5 Medications
- Active prescriptions: drug name, strength, route,
- schedule, treatment-class flag (standard or GLP-1).
+ Active prescriptions: drug name, strength, route, schedule,
+ treatment-class flag (standard or GLP-1).
- Intake events: scheduled time, actual time, status
- (taken, skipped, missed).
+ Intake events: scheduled time, actual time, status (taken,
+ skipped, missed).
- For GLP-1 treatments: dose-change history, injection
- events with optional injection site and pen
- identifier, side-effect logs against a fixed taxonomy,
- pen-and-vial inventory with a 30-day in-use clock.
+ For GLP-1 treatments: dose-change history, injection events
+ with optional injection site and pen identifier, side-effect
+ logs against a fixed taxonomy, pen-and-vial inventory with a
+ 30-day in-use clock.
2.6 AI Coach and Insights — off-device transit
- When the user enables the AI Coach surface and
- configures a language-model provider, HealthLog sends a
- snapshot bundle to that provider on demand. The bundle
- contains health context derived from the data above
- (aggregates, recent observations, target ranges,
- optional medication context) and{" "}
+ When the user enables the AI Coach surface and configures a
+ language-model provider, HealthLog sends a snapshot bundle to
+ that provider on demand. The bundle contains health context
+ derived from the data above (aggregates, recent observations,
+ target ranges, optional medication context) and{" "}
- never includes raw HealthKit identifiers or raw sample
- IDs
+ never includes raw HealthKit identifiers or raw sample IDs
- ; the server normalises HK samples into the internal
- metric taxonomy first. Transit is encrypted (TLS 1.3).
- The AI Coach is off by default and is enabled per-
- provider only after explicit user consent (see consent
- receipt in section 2.7). HealthLog does not retain
- long-term conversation state beyond the most recent
- thread. Section 4 enumerates the eligible providers
+ ; the server normalises HK samples into the internal metric
+ taxonomy first. Transit is encrypted (TLS 1.3). The AI Coach is
+ off by default and is enabled per- provider only after explicit
+ user consent (see consent receipt in section 2.7). HealthLog
+ does not retain long-term conversation state beyond the most
+ recent thread. Section 4 enumerates the eligible providers
(Anthropic, OpenAI).
2.7 Consent receipt persistence
- Every consent to AI transit — both per-provider
- activation and changes to the data-depth toggle — is
- persisted as a “consent receipt” with timestamp,
- provider, consent-text version, and a hash of the
- snapshot schema. The retrieval endpoint is{" "}
+ Every consent to AI transit — both per-provider activation and
+ changes to the data-depth toggle — is persisted as a
+ “consent receipt” with timestamp, provider,
+ consent-text version, and a hash of the snapshot schema. The
+ retrieval endpoint is{" "}
GET /api/account/consents
; receipts are retained for five years and then deleted
automatically. A withdrawal is recorded as a separate
- reverse-polarity receipt; from that moment the provider
- receives no further snapshot bundles.
+ reverse-polarity receipt; from that moment the provider receives
+ no further snapshot bundles.
2.8 Device and integration metadata, push
- A device identifier (random UUID generated client-
- side, stored in the iOS Keychain) sent as the{" "}
+ A device identifier (random UUID generated client- side,
+ stored in the iOS Keychain) sent as the{" "}
X-Device-Id
{" "}
@@ -944,14 +968,13 @@ export default function PrivacyPage() {
prevention.
- Apple Push Notification service (APNs) device token
- and environment flag (sandbox / production), recorded
- only when the user enables push notifications.
+ Apple Push Notification service (APNs) device token and
+ environment flag (sandbox / production), recorded only when
+ the user enables push notifications.
- Telegram chat identifier — only when the user has
- explicitly connected the optional Telegram-bot
- notifier.
+ Telegram chat identifier — only when the user has explicitly
+ connected the optional Telegram-bot notifier.
@@ -959,19 +982,18 @@ export default function PrivacyPage() {
- Authentication events (login success / failure,
- passkey registration, password change, session
- revocation) — retained for 90 days for security
- forensics.
+ Authentication events (login success / failure, passkey
+ registration, password change, session revocation) — retained
+ for 90 days for security forensics.
- Server access logs (timestamp, request path, status
- code, user-agent, IP address) — 14 days for abuse-
- rate-limiting and debugging.
+ Server access logs (timestamp, request path, status code,
+ user-agent, IP address) — 14 days for abuse- rate-limiting and
+ debugging.
- Failed APNs deliveries — 30 days to support push-
- pipeline maintenance, then auto-deleted.
+ Failed APNs deliveries — 30 days to support push- pipeline
+ maintenance, then auto-deleted.
@@ -979,23 +1001,20 @@ export default function PrivacyPage() {
- No third-party advertising identifiers, fingerprints,
- or cross-app tracking.
+ No third-party advertising identifiers, fingerprints, or
+ cross-app tracking.
- No payment information (the application has no paid
- tier).
+ No payment information (the application has no paid tier).
- No precise background location (workouts only carry
- GPS when the user attaches a route).
+ No precise background location (workouts only carry GPS when
+ the user attaches a route).
+ No social-network identifiers or contact-list scrapes.
- No social-network identifiers or contact-list scrapes.
-
-
- No third-party product analytics. The instance does
- not ship a client-side analytics SDK.
+ No third-party product analytics. The instance does not ship a
+ client-side analytics SDK.
>
@@ -1021,18 +1040,18 @@ export default function PrivacyPage() {
Gesundheitsdaten
{" "}
- — Darstellung von Verläufen, Berechnung der Zielwerte-
- Treue, Erzeugung des Coach-Snapshots auf Abruf. Rechts-
- grundlage: Art. 9 Abs. 2 lit. a DSGVO (ausdrückliche
- Einwilligung für Daten besonderer Kategorien).
+ — Darstellung von Verläufen, Berechnung der Zielwerte- Treue,
+ Erzeugung des Coach-Snapshots auf Abruf. Rechts- grundlage: Art.
+ 9 Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung für Daten
+ besonderer Kategorien).
Coach-Snapshots
{" "}
- — Erzeugung personalisierter schriftlicher Rückmeldungen.
- Das Bündel wird nur für die Dauer der Anfrage an den
- konfigurierten KI-Anbieter übertragen.
+ — Erzeugung personalisierter schriftlicher Rückmeldungen. Das
+ Bündel wird nur für die Dauer der Anfrage an den konfigurierten
+ KI-Anbieter übertragen.
@@ -1042,12 +1061,10 @@ export default function PrivacyPage() {
Sitzungshygiene.
-
- Audit-Logs
- {" "}
- — Sicherheits-Forensik und Rate-Limit. Rechtsgrundlage:
- Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am
- sicheren Betrieb).
+ Audit-Logs {" "}
+ — Sicherheits-Forensik und Rate-Limit. Rechtsgrundlage: Art. 6
+ Abs. 1 lit. f DSGVO (berechtigtes Interesse am sicheren
+ Betrieb).
@@ -1063,40 +1080,33 @@ export default function PrivacyPage() {
Authentication data
{" "}
- — identifying the user and securing the session. Legal
- basis: GDPR Art. 6 (1) (b) performance of contract.
+ — identifying the user and securing the session. Legal basis:
+ GDPR Art. 6 (1) (b) performance of contract.
-
- Health data
- {" "}
- — displaying trends, computing target adherence,
- generating the Coach context bundle when the user
- invokes it. Legal basis: GDPR Art. 9 (2) (a) explicit
- consent for special-category data.
+ Health data {" "}
+ — displaying trends, computing target adherence, generating the
+ Coach context bundle when the user invokes it. Legal basis: GDPR
+ Art. 9 (2) (a) explicit consent for special-category data.
Coach context bundles
{" "}
- — generating personalised written feedback. The bundle
- is transmitted to the configured AI provider for the
- duration of the request only.
+ — generating personalised written feedback. The bundle is
+ transmitted to the configured AI provider for the duration of
+ the request only.
-
- Device data
- {" "}
- — push notifications, abuse prevention, multi-device
- session hygiene.
+ Device data {" "}
+ — push notifications, abuse prevention, multi-device session
+ hygiene.
-
- Audit logs
- {" "}
- — security-event tracing and rate-limiting. Legal
- basis: GDPR Art. 6 (1) (f) legitimate interest in
- operating the service securely.
+ Audit logs {" "}
+ — security-event tracing and rate-limiting. Legal basis: GDPR
+ Art. 6 (1) (f) legitimate interest in operating the service
+ securely.
@@ -1116,32 +1126,31 @@ export default function PrivacyPage() {
bodyDe={
<>
- Folgende Anbieter können personenbezogene Daten im Auftrag
- des Betreibers verarbeiten. Die Liste ist für den
- veröffentlichten Funktionsumfang abschließend. Anbieter
- werden nur für die Funktionen eingebunden, die der Nutzer
- ausdrücklich aktiviert hat. Für jede Übertragung in
- Drittländer (insbesondere USA) bestehen Standardvertrags-
- klauseln nach Art. 46 Abs. 2 lit. c DSGVO mit dem
- jeweiligen Anbieter.
+ Folgende Anbieter können personenbezogene Daten im Auftrag des
+ Betreibers verarbeiten. Die Liste ist für den veröffentlichten
+ Funktionsumfang abschließend. Anbieter werden nur für die
+ Funktionen eingebunden, die der Nutzer ausdrücklich aktiviert
+ hat. Für jede Übertragung in Drittländer (insbesondere USA)
+ bestehen Standardvertrags- klauseln nach Art. 46 Abs. 2 lit. c
+ DSGVO mit dem jeweiligen Anbieter.
- Die englische Übersetzung enthält die detaillierte
- Auflistung pro Anbieter mit Rolle, übermittelten Daten,
- Speicherort und Link zur Datenschutzerklärung.
+ Die englische Übersetzung enthält die detaillierte Auflistung
+ pro Anbieter mit Rolle, übermittelten Daten, Speicherort und
+ Link zur Datenschutzerklärung.
>
}
bodyEn={
<>
- The following providers may process personal data on
- behalf of the HealthLog operator. The list is exhaustive
- for the released feature set; sub-processors are only
- engaged for the features the user has explicitly enabled.
- Each transfer to a third country (notably the United
- States) is covered by Standard Contractual Clauses under
- Art. 46 (2) (c) GDPR with the respective provider.
+ The following providers may process personal data on behalf of
+ the HealthLog operator. The list is exhaustive for the released
+ feature set; sub-processors are only engaged for the features
+ the user has explicitly enabled. Each transfer to a third
+ country (notably the United States) is covered by Standard
+ Contractual Clauses under Art. 46 (2) (c) GDPR with the
+ respective provider.
{" "}
— primäre Datenhaltung in einer PostgreSQL-Datenbank auf
- Hetzner-Infrastruktur in Deutschland; die Anwendung wird
- vom Betreiber als Privatperson in Deutschland verant-
- wortet. Außer den unter Abschnitt 4 genannten Anbietern
- ist keine weitere Drittspeicherung beteiligt.
+ Hetzner-Infrastruktur in Deutschland; die Anwendung wird vom
+ Betreiber als Privatperson in Deutschland verant- wortet. Außer
+ den unter Abschnitt 4 genannten Anbietern ist keine weitere
+ Drittspeicherung beteiligt.
@@ -1257,15 +1266,13 @@ export default function PrivacyPage() {
Verschlüsselung im Ruhezustand
{" "}
- — verschlüsselte Festplatten; sensible Spalten (Auth-
- Token, Integrations-Secrets) zusätzlich auf Spaltenebene
- mit einem separaten Schlüssel verschlüsselt.
+ — verschlüsselte Festplatten; sensible Spalten (Auth- Token,
+ Integrations-Secrets) zusätzlich auf Spaltenebene mit einem
+ separaten Schlüssel verschlüsselt.
-
- Backups
- {" "}
- — täglich, verschlüsselt, 30 Tage Aufbewahrung auf einem
+ Backups —
+ täglich, verschlüsselt, 30 Tage Aufbewahrung auf einem
S3-kompatiblen Objektspeicher mit eigener
Verschlüsselungsschicht.
@@ -1273,23 +1280,19 @@ export default function PrivacyPage() {
Standard-Speicherdauern
{" "}
- — Messungen: gleitendes 5-Jahres-Fenster (Roh-Stichproben
- älter als 5 Jahre werden automatisch verdichtet bzw.
- gelöscht; aggregierte Verläufe bleiben). Audit-Logs:
- 90 Tage. Server-Zugriffsprotokolle: 14 Tage. Fehl-
- geschlagene APNs-Zustellungen: 30 Tage. Einwilligungs-
- belege: 5 Jahre.
+ — Messungen: gleitendes 5-Jahres-Fenster (Roh-Stichproben älter
+ als 5 Jahre werden automatisch verdichtet bzw. gelöscht;
+ aggregierte Verläufe bleiben). Audit-Logs: 90 Tage.
+ Server-Zugriffsprotokolle: 14 Tage. Fehl- geschlagene
+ APNs-Zustellungen: 30 Tage. Einwilligungs- belege: 5 Jahre.
-
- Löschung
- {" "}
- — der Endpunkt zur Konto-Löschung läuft kaskadiert durch
- jede nutzer-skopierte Tabelle: Gesundheitsmessungen,
- Sitzungen, Audit-Log, Integrations-Token, Push-
- Abonnements, Erfolge, hochgeladene Dateien. Die Löschung
- erfolgt sofort; Backups laufen innerhalb des 30-Tage-
- Fensters aus.
+ Löschung —
+ der Endpunkt zur Konto-Löschung läuft kaskadiert durch jede
+ nutzer-skopierte Tabelle: Gesundheitsmessungen, Sitzungen,
+ Audit-Log, Integrations-Token, Push- Abonnements, Erfolge,
+ hochgeladene Dateien. Die Löschung erfolgt sofort; Backups
+ laufen innerhalb des 30-Tage- Fensters aus.
}
@@ -1299,10 +1302,10 @@ export default function PrivacyPage() {
Server location
{" "}
- — primary store is PostgreSQL on Hetzner-hosted
- hardware in Germany; the application is operated by the
- controller as an individual based in Germany. No third-
- party hosting beyond the providers listed in section 4.
+ — primary store is PostgreSQL on Hetzner-hosted hardware in
+ Germany; the application is operated by the controller as an
+ individual based in Germany. No third- party hosting beyond the
+ providers listed in section 4.
@@ -1315,35 +1318,30 @@ export default function PrivacyPage() {
Encryption at rest
{" "}
- — disk-level encryption plus column-level encryption
- with a separate key for sensitive columns (auth tokens,
- integration secrets).
+ — disk-level encryption plus column-level encryption with a
+ separate key for sensitive columns (auth tokens, integration
+ secrets).
- Backups {" "}
- — daily, encrypted, retained for 30 days on an S3-
- compatible object store with its own encryption-at-
- rest layer.
+ Backups —
+ daily, encrypted, retained for 30 days on an S3- compatible
+ object store with its own encryption-at- rest layer.
Default retention windows
{" "}
- — measurements: rolling 5-year window (raw samples
- older than 5 years are aggregated or removed; long-arc
- aggregates persist). Audit logs: 90 days. Server
- access logs: 14 days. Failed APNs deliveries: 30 days.
- Consent receipts: 5 years.
+ — measurements: rolling 5-year window (raw samples older than 5
+ years are aggregated or removed; long-arc aggregates persist).
+ Audit logs: 90 days. Server access logs: 14 days. Failed APNs
+ deliveries: 30 days. Consent receipts: 5 years.
-
- Deletion
- {" "}
- — the account-deletion endpoint cascades through every
- user-scoped table: health observations, sessions, audit
- log, integration tokens, push subscriptions,
- achievements, uploaded files. Deletion is immediate;
- backups age out under the 30-day window.
+ Deletion —
+ the account-deletion endpoint cascades through every user-scoped
+ table: health observations, sessions, audit log, integration
+ tokens, push subscriptions, achievements, uploaded files.
+ Deletion is immediate; backups age out under the 30-day window.
}
@@ -1360,24 +1358,23 @@ export default function PrivacyPage() {
Auskunftsrecht (Art. 15)
{" "}
- — jeder gespeicherte Wert ist über die Historie in der
- App erreichbar. Ein konsolidierter JSON-Export liegt
- unter Einstellungen → Daten & Export .
+ — jeder gespeicherte Wert ist über die Historie in der App
+ erreichbar. Ein konsolidierter JSON-Export liegt unter{" "}
+ Einstellungen → Daten & Export .
Recht auf Berichtigung (Art. 16)
{" "}
- — jeder Eintrag bietet eine Bearbeiten- und Löschen-
- Aktion in der App.
+ — jeder Eintrag bietet eine Bearbeiten- und Löschen- Aktion in
+ der App.
Recht auf Löschung (Art. 17)
{" "}
- — Einstellungen → Daten → Konto löschen . Die
- Aktion kaskadiert sofort über alle nutzer-skopierten
- Tabellen via{" "}
+ — Einstellungen → Daten → Konto löschen . Die Aktion
+ kaskadiert sofort über alle nutzer-skopierten Tabellen via{" "}
User.delete
{" "}
@@ -1391,8 +1388,8 @@ export default function PrivacyPage() {
Recht auf Einschränkung (Art. 18)
{" "}
- — administrative Sperre per Datenschutz-Anfrage (siehe
- Abschnitt 11).
+ — administrative Sperre per Datenschutz-Anfrage (siehe Abschnitt
+ 11).
@@ -1404,23 +1401,21 @@ export default function PrivacyPage() {
- Widerspruch und automatisierte Entscheidungen
- (Art. 21–22)
+ Widerspruch und automatisierte Entscheidungen (Art. 21–22)
{" "}
- — der KI-Coach trifft keine automatisierten Entscheid-
- ungen im Sinne von Art. 22 DSGVO. Er erzeugt Texte zum
- Nachlesen; ohne ausdrückliche Bestätigung wird keine
- Aktion ausgeführt. Siehe Abschnitt 7 zur Medizinpro-
- dukte-Grenze.
+ — der KI-Coach trifft keine automatisierten Entscheid- ungen im
+ Sinne von Art. 22 DSGVO. Er erzeugt Texte zum Nachlesen; ohne
+ ausdrückliche Bestätigung wird keine Aktion ausgeführt. Siehe
+ Abschnitt 7 zur Medizinpro- dukte-Grenze.
Beschwerderecht
{" "}
— beim Bundesbeauftragten für den Datenschutz und die
- Informationsfreiheit (BfDI) oder bei der für den
- gewöhnlichen Aufenthaltsort der betroffenen Person
- zuständigen Aufsichtsbehörde.
+ Informationsfreiheit (BfDI) oder bei der für den gewöhnlichen
+ Aufenthaltsort der betroffenen Person zuständigen
+ Aufsichtsbehörde.
}
@@ -1430,24 +1425,22 @@ export default function PrivacyPage() {
Right of access (Art. 15)
{" "}
- — every stored value is reachable through the in-app
- history surfaces. A consolidated JSON export is offered
- under Settings → Data & export .
+ — every stored value is reachable through the in-app history
+ surfaces. A consolidated JSON export is offered under{" "}
+ Settings → Data & export .
Right to rectification (Art. 16)
{" "}
- — every record carries an in-app edit and delete
- action.
+ — every record carries an in-app edit and delete action.
Right to erasure (Art. 17)
{" "}
- — Settings → Data → Delete account . The action
- cascades immediately through every user-scoped table
- via{" "}
+ — Settings → Data → Delete account . The action cascades
+ immediately through every user-scoped table via{" "}
User.delete
{" "}
@@ -1461,36 +1454,33 @@ export default function PrivacyPage() {
Right to restriction of processing (Art. 18)
{" "}
- — request an administrative suspension via the contact
- channel in section 11.
+ — request an administrative suspension via the contact channel
+ in section 11.
Right to data portability (Art. 20)
{" "}
- — the JSON export under{" "}
- Settings → Data & export returns the full
- record set in a structured, machine-readable format.
+ — the JSON export under Settings → Data & export {" "}
+ returns the full record set in a structured, machine-readable
+ format.
- Right to object / automated decision-making (Art. 21-
- 22)
+ Right to object / automated decision-making (Art. 21- 22)
{" "}
- — the AI Coach does not make automated decisions in the
- Art. 22 sense. It generates written suggestions for the
- user to read; no action is taken without explicit
- confirmation. See section 7 for the medical-device
- boundary.
+ — the AI Coach does not make automated decisions in the Art. 22
+ sense. It generates written suggestions for the user to read; no
+ action is taken without explicit confirmation. See section 7 for
+ the medical-device boundary.
Right to lodge a complaint
{" "}
- — with the Federal Commissioner for Data Protection and
- Freedom of Information (BfDI) for the German federal
- level, or with the data-protection authority of the
- user's habitual residence.
+ — with the Federal Commissioner for Data Protection and Freedom
+ of Information (BfDI) for the German federal level, or with the
+ data-protection authority of the user's habitual residence.
}
@@ -1504,55 +1494,52 @@ export default function PrivacyPage() {
bodyDe={
<>
- HealthLog ist kein Medizinprodukt im
- Sinne der EU-Verordnung 2017/745 (MDR). Die Anwendung
- erfasst und visualisiert Beobachtungen, die der Nutzer
- selbst eingegeben hat; sie diagnostiziert, behandelt,
- verordnet oder überwacht keine spezifische medizinische
- Erkrankung.
+ HealthLog ist kein Medizinprodukt im Sinne der
+ EU-Verordnung 2017/745 (MDR). Die Anwendung erfasst und
+ visualisiert Beobachtungen, die der Nutzer selbst eingegeben
+ hat; sie diagnostiziert, behandelt, verordnet oder überwacht
+ keine spezifische medizinische Erkrankung.
- Die KI-Coach-Oberfläche ist durch zwei ausdrückliche
- Grundregeln im System-Prompt eingegrenzt: sie empfiehlt
- keine GLP-1-Dosen (GROUND RULE 9) und erzeugt keine
- wirkstoff-bezogenen Schätzungen (GROUND RULE 15). Der
- GLP-1-Research-Mode ist eine reine Visualisierung, die
- erst nach einer versionierten Bestätigung freigeschaltet
- wird, die dem Nutzer die MDR-Grenze ausdrücklich nennt.
- Keine dieser Oberflächen gibt klinische Empfehlungen aus.
+ Die KI-Coach-Oberfläche ist durch zwei ausdrückliche Grundregeln
+ im System-Prompt eingegrenzt: sie empfiehlt keine GLP-1-Dosen
+ (GROUND RULE 9) und erzeugt keine wirkstoff-bezogenen
+ Schätzungen (GROUND RULE 15). Der GLP-1-Research-Mode ist eine
+ reine Visualisierung, die erst nach einer versionierten
+ Bestätigung freigeschaltet wird, die dem Nutzer die MDR-Grenze
+ ausdrücklich nennt. Keine dieser Oberflächen gibt klinische
+ Empfehlungen aus.
Für medizinische Beratung muss eine approbierte ärztliche
- Fachkraft konsultiert werden. HealthLog soll diese
- Beratung unterstützen, indem es selbst erfasste Daten
- aufbereitet — nicht ersetzen.
+ Fachkraft konsultiert werden. HealthLog soll diese Beratung
+ unterstützen, indem es selbst erfasste Daten aufbereitet — nicht
+ ersetzen.
>
}
bodyEn={
<>
- HealthLog is not a medical device {" "}
- within the meaning of EU Regulation 2017/745 (MDR). The
- application records and displays observations the user
- has entered; it does not diagnose, treat, prescribe, or
- monitor a specific medical condition.
+ HealthLog is not a medical device within the
+ meaning of EU Regulation 2017/745 (MDR). The application records
+ and displays observations the user has entered; it does not
+ diagnose, treat, prescribe, or monitor a specific medical
+ condition.
- The AI Coach surface is constrained by two explicit
- ground rules in its system prompt: it does not
- recommend GLP-1 doses (GROUND RULE 9), and it does not
- produce drug-level estimates (GROUND RULE 15). The GLP-1
- Research Mode chart is a display-only visualisation
- gated behind a versioned acknowledgment that cites the
- MDR boundary to the user before unlocking. None of
- these surfaces issue clinical recommendations.
+ The AI Coach surface is constrained by two explicit ground rules
+ in its system prompt: it does not recommend GLP-1 doses (GROUND
+ RULE 9), and it does not produce drug-level estimates (GROUND
+ RULE 15). The GLP-1 Research Mode chart is a display-only
+ visualisation gated behind a versioned acknowledgment that cites
+ the MDR boundary to the user before unlocking. None of these
+ surfaces issue clinical recommendations.
- For medical advice the user must consult a licensed
- clinician. HealthLog is intended to support that
- conversation by surfacing self-tracked data — not to
- replace it.
+ For medical advice the user must consult a licensed clinician.
+ HealthLog is intended to support that conversation by surfacing
+ self-tracked data — not to replace it.
>
}
@@ -1566,10 +1553,10 @@ export default function PrivacyPage() {
bodyDe={
<>
- Die Privacy-Nutrition-Labels der iOS-Anwendung in App
- Store Connect ordnen sich folgenden Kategorien zu (jeweils{" "}
- linked to the user , ausschließlich zur App-
- Funktion verwendet):
+ Die Privacy-Nutrition-Labels der iOS-Anwendung in App Store
+ Connect ordnen sich folgenden Kategorien zu (jeweils{" "}
+ linked to the user , ausschließlich zur App- Funktion
+ verwendet):
@@ -1597,9 +1584,7 @@ export default function PrivacyPage() {
(Notizen zu Messungen, Stimmungsnotizen, Bug-Reports).
-
- Kennungen
- {" "}
+ Kennungen {" "}
(Geräte- und Nutzer-Kennung).
@@ -1609,9 +1594,7 @@ export default function PrivacyPage() {
— nicht erhoben.
-
- Standort
- {" "}
+ Standort {" "}
— nicht erhoben, außer bei einer optional vom Nutzer
angehängten GPS-Workout-Route.
@@ -1621,10 +1604,9 @@ export default function PrivacyPage() {
bodyEn={
<>
- The iOS application's Privacy Nutrition Labels in App
- Store Connect map to the following categories (each
- linked to the user , used only for app
- functionality):
+ The iOS application's Privacy Nutrition Labels in App Store
+ Connect map to the following categories (each
+ linked to the user , used only for app functionality):
@@ -1649,8 +1631,8 @@ export default function PrivacyPage() {
User Content
{" "}
- (notes attached to measurements, mood notes, bug-
- report text).
+ (notes attached to measurements, mood notes, bug- report
+ text).
@@ -1660,17 +1642,15 @@ export default function PrivacyPage() {
- Diagnostics, Usage Data, Browsing History, Search
- History, Other Data
+ Diagnostics, Usage Data, Browsing History, Search History,
+ Other Data
{" "}
— not collected.
-
- Location
- {" "}
- — not collected, except for an optional GPS route the
- user attaches manually to a workout.
+ Location {" "}
+ — not collected, except for an optional GPS route the user
+ attaches manually to a workout.
>
@@ -1685,21 +1665,19 @@ export default function PrivacyPage() {
bodyDe={
HealthLog richtet sich nicht an Kinder unter 16 Jahren. Die
- Anwendung sollte nicht ohne nachweisbare elterliche Aufsicht
- von Personen unter 16 Jahren verwendet werden. Der Betreiber
- erhebt wissentlich keine personenbezogenen Daten von Kindern
- unter 16 Jahren; sollte dies auffallen, werden die Daten
- umgehend gelöscht.
+ Anwendung sollte nicht ohne nachweisbare elterliche Aufsicht von
+ Personen unter 16 Jahren verwendet werden. Der Betreiber erhebt
+ wissentlich keine personenbezogenen Daten von Kindern unter 16
+ Jahren; sollte dies auffallen, werden die Daten umgehend gelöscht.
}
bodyEn={
- HealthLog is not directed at children under the age of 16.
- The application should not be used by anyone under 16
- without verifiable parental supervision. The operator
- does not knowingly collect personal data from children
- under 16; if such data is discovered, it will be deleted
- on detection.
+ HealthLog is not directed at children under the age of 16. The
+ application should not be used by anyone under 16 without
+ verifiable parental supervision. The operator does not knowingly
+ collect personal data from children under 16; if such data is
+ discovered, it will be deleted on detection.
}
/>
@@ -1714,17 +1692,16 @@ export default function PrivacyPage() {
Diese Erklärung trägt oben einen Versionsstempel. Materielle
Änderungen werden in den In-App-Versionshinweisen und im
Open-Source-Changelog zusammengefasst. Die veröffentlichte
- Policy-Version ist an die Anwendungsversion gebunden, mit
- der sie eingeführt wurde.
+ Policy-Version ist an die Anwendungsversion gebunden, mit der sie
+ eingeführt wurde.
}
bodyEn={
- This document is version-stamped at the top. Material
- changes will be summarised in the in-app release notes
- and on the open-source changelog. The published policy
- version is bound to the application release version that
- introduced it.
+ This document is version-stamped at the top. Material changes will
+ be summarised in the in-app release notes and on the open-source
+ changelog. The published policy version is bound to the
+ application release version that introduced it.
}
/>
@@ -1737,15 +1714,14 @@ export default function PrivacyPage() {
bodyDe={
<>
- Verantwortlicher und Ansprechpartner für DSGVO-Anfragen
- ist der Betreiber dieser Instanz, eine Privatperson mit
- Sitz in Deutschland (vollständige Postanschrift wird
- Antragstellern auf Anfrage über den unten genannten Kanal
- mitgeteilt; sie ist nicht öffentlich aufgeführt, um
- gezielte Belästigung zu verhindern — eine Klarstellung,
- die die deutschen DPAs in ihren Hinweisen zu Impressums-
- Pflichten ausdrücklich zulassen, sofern eine elektronische
- Erreichbarkeit besteht).
+ Verantwortlicher und Ansprechpartner für DSGVO-Anfragen ist der
+ Betreiber dieser Instanz, eine Privatperson mit Sitz in
+ Deutschland (vollständige Postanschrift wird Antragstellern auf
+ Anfrage über den unten genannten Kanal mitgeteilt; sie ist nicht
+ öffentlich aufgeführt, um gezielte Belästigung zu verhindern —
+ eine Klarstellung, die die deutschen DPAs in ihren Hinweisen zu
+ Impressums- Pflichten ausdrücklich zulassen, sofern eine
+ elektronische Erreichbarkeit besteht).
@@ -1762,8 +1738,8 @@ export default function PrivacyPage() {
"HealthLog DSGVO — <Auskunft | Löschung |
Übertragbarkeit>"
{" "}
- kennzeichnen. Erste Antwort innerhalb von 30 Tagen gemäß
- Art. 12 Abs. 3 DSGVO.
+ kennzeichnen. Erste Antwort innerhalb von 30 Tagen gemäß Art. 12
+ Abs. 3 DSGVO.
@@ -1779,26 +1755,22 @@ export default function PrivacyPage() {
github.com/MBombeck/HealthLog/issues
{" "}
mit Titel{" "}
-
- GDPR — <Access | Erasure | Portability> request
- {" "}
- — bitte keine personenbezogenen Daten im öffentlichen
- Issue posten; ein privater Kanal wird in der ersten
- Antwort angeboten.
+ GDPR — <Access | Erasure | Portability> request —
+ bitte keine personenbezogenen Daten im öffentlichen Issue
+ posten; ein privater Kanal wird in der ersten Antwort angeboten.
>
}
bodyEn={
<>
- The controller and point of contact for GDPR enquiries
- is the operator of this instance, an individual based
- in Germany (the full postal address is disclosed to
- requesters via the channel below upon request; it is
- not publicly listed to prevent targeted harassment — a
- practice explicitly tolerated by German data-protection
- authorities provided an electronic contact route is
- available).
+ The controller and point of contact for GDPR enquiries is the
+ operator of this instance, an individual based in Germany (the
+ full postal address is disclosed to requesters via the channel
+ below upon request; it is not publicly listed to prevent
+ targeted harassment — a practice explicitly tolerated by German
+ data-protection authorities provided an electronic contact route
+ is available).
@@ -1831,19 +1803,16 @@ export default function PrivacyPage() {
github.com/MBombeck/HealthLog/issues
{" "}
titled{" "}
-
- GDPR — <Access | Erasure | Portability> request
- {" "}
- — please do not include personal data in the public
- issue; a private channel will be offered in the first
- response.
+ GDPR — <Access | Erasure | Portability> request —
+ please do not include personal data in the public issue; a
+ private channel will be offered in the first response.
>
}
/>
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index dd3e9159d..8e7d77599 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -10,17 +10,21 @@ import { CoachSection } from "@/components/settings/coach-section";
import { DashboardSection } from "@/components/settings/dashboard-section";
import { ExportSection } from "@/components/settings/export-section";
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
+import { IllnessSection } from "@/components/settings/illness-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
+import { LabsSection } from "@/components/settings/labs-section";
import { LayoutSection } from "@/components/settings/layout-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { ModulesSection } from "@/components/settings/modules-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
+import { VorsorgeSection } from "@/components/settings/vorsorge-section";
// v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries
// again (split out of the Integrations sub-tabs).
import { ChannelsSection } from "@/components/settings/channels-section";
+import { SharingSection } from "@/components/settings/sharing-section";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import {
@@ -64,9 +68,13 @@ const SECTION_COMPONENTS: Record<
insights: InsightsSection,
medications: MedicationsSection,
mood: MoodSection,
+ labs: LabsSection,
+ illness: IllnessSection,
+ vorsorge: VorsorgeSection,
thresholds: ThresholdsSection,
api: ApiSection,
gesundheitsakte: GesundheitsakteSection,
+ sharing: SharingSection,
export: ExportSection,
advanced: AdvancedSection,
};
diff --git a/src/app/settings/illness/page.tsx b/src/app/settings/illness/page.tsx
deleted file mode 100644
index b6b37b890..000000000
--- a/src/app/settings/illness/page.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client";
-
-import { Loader2 } from "lucide-react";
-
-import { useAuth } from "@/hooks/use-auth";
-import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
-import { IllnessSettings } from "@/components/illness/illness-settings";
-
-/**
- * v1.18.6 (W8 / MOD-03) — the Illness module's own settings page. Dedicated
- * static route (wins over `/settings/[section]`); reached from the wrench
- * beside the Illness page's "hinzufügen" button.
- */
-export default function IllnessSettingsPage() {
- const { isLoading } = useAuth();
- const mounted = useMounted();
- const { t } = useTranslations();
-
- if (!mounted || isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/app/settings/labs/page.tsx b/src/app/settings/labs/page.tsx
deleted file mode 100644
index 5805b5fa9..000000000
--- a/src/app/settings/labs/page.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client";
-
-import { Loader2 } from "lucide-react";
-
-import { useAuth } from "@/hooks/use-auth";
-import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
-import { LabsSettings } from "@/components/labs/labs-settings";
-
-/**
- * v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page.
- * Dedicated static route (wins over `/settings/[section]`); reached from the
- * wrench beside the Labs page's "hinzufügen" button.
- */
-export default function LabsSettingsPage() {
- const { isLoading } = useAuth();
- const mounted = useMounted();
- const { t } = useTranslations();
-
- if (!mounted || isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/app/settings/vorsorge/page.tsx b/src/app/settings/vorsorge/page.tsx
deleted file mode 100644
index 147b3d51f..000000000
--- a/src/app/settings/vorsorge/page.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-
-import { Loader2 } from "lucide-react";
-
-import { useAuth } from "@/hooks/use-auth";
-import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
-import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
-
-/**
- * v1.18.6 (W8 / MOD-03) — the Vorsorge module's own settings page.
- *
- * A dedicated static route that wins over the dynamic `/settings/[section]`
- * router (static segments take precedence in Next.js), so it adds a
- * per-module settings surface without touching the parallel-phase-owned
- * `section-slugs` registry or `settings-shell`. Reached from the wrench
- * beside the Vorsorge page's "hinzufügen" button.
- */
-export default function VorsorgeSettingsPage() {
- const { isLoading } = useAuth();
- const mounted = useMounted();
- const { t } = useTranslations();
-
- if (!mounted || isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/components/__tests__/motion-reduce-spin-coverage.test.ts b/src/components/__tests__/motion-reduce-spin-coverage.test.ts
index 225dda2e2..e05ede00e 100644
--- a/src/components/__tests__/motion-reduce-spin-coverage.test.ts
+++ b/src/components/__tests__/motion-reduce-spin-coverage.test.ts
@@ -50,8 +50,7 @@ describe("motion-reduce coverage — every animate-spin pairs with motion-reduce
lines.forEach((line, idx) => {
if (!line.includes("animate-spin")) return;
// Skip comments and string literals that aren't classNames.
- if (line.trim().startsWith("//") || line.trim().startsWith("*"))
- return;
+ if (line.trim().startsWith("//") || line.trim().startsWith("*")) return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${line.trim()}`);
}
diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx
index 28e54b689..f0bd0209c 100644
--- a/src/components/admin/admin-shell.tsx
+++ b/src/components/admin/admin-shell.tsx
@@ -212,7 +212,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
// with the top of the first card by construction. `/admin` overview
// (activeSlug === null) shows the console title; each section shows its own.
const headingTitle =
- activeSlug === null ? t("admin.title") : t(`admin.section.${activeSlug}.title`);
+ activeSlug === null
+ ? t("admin.title")
+ : t(`admin.section.${activeSlug}.title`);
const headingSubtitle =
activeSlug === null
? t("admin.subtitle")
diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx
index ddcd6473f..c43f24e3f 100644
--- a/src/components/admin/ai-quality-section.tsx
+++ b/src/components/admin/ai-quality-section.tsx
@@ -115,67 +115,67 @@ export function AiQualitySection() {
-
-
- {t("admin.aiQuality.colSeverity")}
-
-
- {t("admin.aiQuality.colMetric")}
-
-
- {t("admin.aiQuality.colProvider")}
-
-
- {t("admin.aiQuality.colPromptVersion")}
-
-
- {t("admin.aiQuality.colHelpful")}
-
-
- {t("admin.aiQuality.colNotHelpful")}
-
-
- {t("admin.aiQuality.colHelpfulRate")}
-
-
-
-
- {buckets.map((bucket, idx) => {
- const ratePct = Math.round(bucket.helpfulRate * 100);
- return (
-
-
+
+ {t("admin.aiQuality.colSeverity")}
+
+
+ {t("admin.aiQuality.colMetric")}
+
+
+ {t("admin.aiQuality.colProvider")}
+
+
+ {t("admin.aiQuality.colPromptVersion")}
+
+
+ {t("admin.aiQuality.colHelpful")}
+
+
+ {t("admin.aiQuality.colNotHelpful")}
+
+
+ {t("admin.aiQuality.colHelpfulRate")}
+
+
+
+
+ {buckets.map((bucket, idx) => {
+ const ratePct = Math.round(bucket.helpfulRate * 100);
+ return (
+
- {bucket.severity}
-
- {bucket.metricSourceType}
- {bucket.providerType}
-
- {bucket.promptVersion}
-
- {bucket.helpful}
-
- {bucket.notHelpful}
-
-
- {ratePct}%
-
-
- );
- })}
-
-
+
+ {bucket.severity}
+
+
{bucket.metricSourceType}
+
{bucket.providerType}
+
+ {bucket.promptVersion}
+
+
{bucket.helpful}
+
+ {bucket.notHelpful}
+
+
+ {ratePct}%
+
+
+ );
+ })}
+
+
)}
diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx
index 3f60454fd..18dae6626 100644
--- a/src/components/admin/reminders-section.tsx
+++ b/src/components/admin/reminders-section.tsx
@@ -194,210 +194,218 @@ export function RemindersSection() {
return (
<>
-
- {/* v1.4.22 D / F-58 — icon swap so the section header matches
+
+ {/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
timing chips inline instead of competing as the page icon. */}
-
+
-
-
-
-
- {t("admin.reminderLateMinutes")}
-
-
- {t("admin.reminderLateMinutesDescription")}
-
-
setReminderLateDraft(Number(e.target.value))}
- autoComplete="off"
- data-lpignore="true"
- data-1p-ignore="true"
- disabled={updateSettings.isPending}
- className="w-32"
- />
-
-
-
- {t("admin.reminderMissedMinutes")}
-
-
- {t("admin.reminderMissedMinutesDescription")}
-
-
setReminderMissedDraft(Number(e.target.value))}
- autoComplete="off"
- data-lpignore="true"
- data-1p-ignore="true"
- disabled={updateSettings.isPending}
- className="w-32"
- />
+
+
+
+
+ {t("admin.reminderLateMinutes")}
+
+
+ {t("admin.reminderLateMinutesDescription")}
+
+
setReminderLateDraft(Number(e.target.value))}
+ autoComplete="off"
+ data-lpignore="true"
+ data-1p-ignore="true"
+ disabled={updateSettings.isPending}
+ className="w-32"
+ />
+
+
+
+ {t("admin.reminderMissedMinutes")}
+
+
+ {t("admin.reminderMissedMinutesDescription")}
+
+
setReminderMissedDraft(Number(e.target.value))}
+ autoComplete="off"
+ data-lpignore="true"
+ data-1p-ignore="true"
+ disabled={updateSettings.isPending}
+ className="w-32"
+ />
+
-
-
-
testNotification.mutate()}
- disabled={testNotification.isPending}
- >
- {testNotification.isPending && (
-
- )}
-
- {t("admin.notificationTestSend")}
-
-
reminderCheck.mutate()}
- disabled={reminderCheck.isPending}
- >
- {reminderCheck.isPending && (
-
- )}
-
- {t("admin.reminderCheckRun")}
-
-
{
- updateSettings.mutate(
- {
- ...(reminderLateDraft != null && {
- reminderLateMinutes: reminderLateDraft,
- }),
- ...(reminderMissedDraft != null && {
- reminderMissedMinutes: reminderMissedDraft,
- }),
- },
- {
- onSuccess: () => {
- setReminderLateDraft(null);
- setReminderMissedDraft(null);
+
+
testNotification.mutate()}
+ disabled={testNotification.isPending}
+ >
+ {testNotification.isPending && (
+
+ )}
+
+ {t("admin.notificationTestSend")}
+
+
reminderCheck.mutate()}
+ disabled={reminderCheck.isPending}
+ >
+ {reminderCheck.isPending && (
+
+ )}
+
+ {t("admin.reminderCheckRun")}
+
+
{
+ updateSettings.mutate(
+ {
+ ...(reminderLateDraft != null && {
+ reminderLateMinutes: reminderLateDraft,
+ }),
+ ...(reminderMissedDraft != null && {
+ reminderMissedMinutes: reminderMissedDraft,
+ }),
},
- },
- );
- }}
- disabled={
- updateSettings.isPending ||
- (reminderLateDraft == null && reminderMissedDraft == null)
- }
- >
- {updateSettings.isPending && (
-
- )}
- {t("common.save")}
-
-
+ {
+ onSuccess: () => {
+ setReminderLateDraft(null);
+ setReminderMissedDraft(null);
+ },
+ },
+ );
+ }}
+ disabled={
+ updateSettings.isPending ||
+ (reminderLateDraft == null && reminderMissedDraft == null)
+ }
+ >
+ {updateSettings.isPending && (
+
+ )}
+ {t("common.save")}
+
+
- {testNotification.data?.results &&
- testNotification.data.results.length > 0 && (
-
- {testNotification.data.results.map((r, i) => (
-
- {r.success ? (
-
- ) : (
-
- )}
- {r.channel}
- {r.error && (
- — {r.error}
- )}
-
- ))}
-
- )}
-
- {reminderCheck.data?.medications &&
- reminderCheck.data.medications.length > 0 && (
-
- {/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
- sequential (parent admin SectionFrame ships
). */}
-
- {t("admin.reminderCheckResults")}
-
-
- {reminderCheck.data.medications.map((med, i) => (
-
-
-
- {med.name} ({med.dose})
-
-
- {med.notificationsEnabled
- ? t("admin.reminderCheckNotifOn")
- : t("admin.reminderCheckNotifOff")}
-
-
-
- {med.user} — {med.dayOfWeek} {med.localTime} —{" "}
- {t("admin.reminderCheckEventsToday")}: {med.eventsToday}
-
- {med.schedules.map((sched, j) => {
- const statusColor =
- sched.status === "open"
- ? "text-success dark:text-green-400"
- : sched.status === "threshold"
- ? "text-warning dark:text-yellow-400"
- : sched.status === "missed"
- ? "text-destructive dark:text-red-400"
- : sched.status === "skipped"
- ? "text-muted-foreground"
- : "";
- return (
-
-
- {sched.window}
-
-
- [{sched.days}]
-
- {sched.label}
- {sched.notificationSent && (
-
-
- {t("admin.reminderCheckNotifSent")}
-
- )}
-
- );
- })}
+ {testNotification.data?.results &&
+ testNotification.data.results.length > 0 && (
+
+ {testNotification.data.results.map((r, i) => (
+
+ {r.success ? (
+
+ ) : (
+
+ )}
+ {r.channel}
+ {r.error && (
+ — {r.error}
+ )}
))}
-
- )}
-
-
+ )}
+
+ {reminderCheck.data?.medications &&
+ reminderCheck.data.medications.length > 0 && (
+
+ {/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
+ sequential (parent admin SectionFrame ships
). */}
+
+ {t("admin.reminderCheckResults")}
+
+
+ {reminderCheck.data.medications.map((med, i) => (
+
+
+
+ {med.name} ({med.dose})
+
+
+ {med.notificationsEnabled
+ ? t("admin.reminderCheckNotifOn")
+ : t("admin.reminderCheckNotifOff")}
+
+
+
+ {med.user} — {med.dayOfWeek} {med.localTime} —{" "}
+ {t("admin.reminderCheckEventsToday")}: {med.eventsToday}
+
+ {med.schedules.map((sched, j) => {
+ const statusColor =
+ sched.status === "open"
+ ? "text-success dark:text-green-400"
+ : sched.status === "threshold"
+ ? "text-warning dark:text-yellow-400"
+ : sched.status === "missed"
+ ? "text-destructive dark:text-red-400"
+ : sched.status === "skipped"
+ ? "text-muted-foreground"
+ : "";
+ return (
+
+
+ {sched.window}
+
+
+ [{sched.days}]
+
+ {sched.label}
+ {sched.notificationSent && (
+
+
+ {t("admin.reminderCheckNotifSent")}
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ )}
+
+
>
);
}
diff --git a/src/components/charts/__tests__/bd-tile.test.tsx b/src/components/charts/__tests__/bd-tile.test.tsx
index 9ee1ac337..489d21608 100644
--- a/src/components/charts/__tests__/bd-tile.test.tsx
+++ b/src/components/charts/__tests__/bd-tile.test.tsx
@@ -86,9 +86,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
);
// Same primitive slots on both tiles.
- for (const slot of [
- "trend-card-value-row",
- ]) {
+ for (const slot of ["trend-card-value-row"]) {
expect(bdHtml).toContain(`data-slot="${slot}"`);
expect(weightHtml).toContain(`data-slot="${slot}"`);
}
diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx
index 609ceb6d0..a9533aab7 100644
--- a/src/components/charts/__tests__/chart-error-state.test.tsx
+++ b/src/components/charts/__tests__/chart-error-state.test.tsx
@@ -100,9 +100,8 @@ describe("chart isError rendering — error state, not empty state", () => {
mockQueryError();
const { I18nProvider } = await import("@/lib/i18n/context");
- const { MedicationComplianceChart } = await import(
- "../medication-compliance-chart"
- );
+ const { MedicationComplianceChart } =
+ await import("../medication-compliance-chart");
const html = renderToStaticMarkup(
@@ -125,9 +124,7 @@ describe(" — announcement, sizing, accessible name", () => {
});
async function renderState(
- props: Partial<
- import("../chart-error-state").ChartErrorStateProps
- > = {},
+ props: Partial = {},
) {
const { ChartErrorState } = await import("../chart-error-state");
return renderToStaticMarkup(
diff --git a/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx
index 9a344b43c..197cf9a01 100644
--- a/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx
+++ b/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx
@@ -29,7 +29,6 @@ describe(" — empty-state gate by raw count", () =>
vi.resetModules();
});
-
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
const data = [
{ date: "2026-05-19", scheduled: 10, taken: 8 },
@@ -48,9 +47,8 @@ describe(" — empty-state gate by raw count", () =>
}));
const { I18nProvider } = await import("@/lib/i18n/context");
- const { MedicationComplianceChart } = await import(
- "../medication-compliance-chart"
- );
+ const { MedicationComplianceChart } =
+ await import("../medication-compliance-chart");
const html = renderToStaticMarkup(
@@ -79,9 +77,8 @@ describe(" — empty-state gate by raw count", () =>
}));
const { I18nProvider } = await import("@/lib/i18n/context");
- const { MedicationComplianceChart } = await import(
- "../medication-compliance-chart"
- );
+ const { MedicationComplianceChart } =
+ await import("../medication-compliance-chart");
const html = renderToStaticMarkup(
diff --git a/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx
index 1733651b2..35bd7d700 100644
--- a/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx
+++ b/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx
@@ -28,7 +28,6 @@ describe(" — empty-state gate by raw count", () => {
vi.resetModules();
});
-
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
const data = {
entries: [
diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx
index 21377d57b..94dc50ec9 100644
--- a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx
+++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx
@@ -209,8 +209,10 @@ describe("dashboard.*Short keys exist across all six locales", () => {
.default;
const dashboard = messages.dashboard ?? {};
for (const key of REQUIRED_KEYS) {
- expect(dashboard[key], `dashboard.${key} missing in ${locale}.json`)
- .toBeTypeOf("string");
+ expect(
+ dashboard[key],
+ `dashboard.${key} missing in ${locale}.json`,
+ ).toBeTypeOf("string");
expect(
(dashboard[key] as string).length,
`dashboard.${key} empty in ${locale}.json`,
diff --git a/src/components/charts/__tests__/trend-card-stale-days.test.tsx b/src/components/charts/__tests__/trend-card-stale-days.test.tsx
index 8b690ef49..72fde7852 100644
--- a/src/components/charts/__tests__/trend-card-stale-days.test.tsx
+++ b/src/components/charts/__tests__/trend-card-stale-days.test.tsx
@@ -36,12 +36,12 @@ const baseProps = {
describe("", () => {
it("stays silent when the metric is fresh (<= 7 days)", () => {
const html = render( );
- expect(html).not.toContain("data-slot=\"tile-stale-hint\"");
+ expect(html).not.toContain('data-slot="tile-stale-hint"');
});
it("renders day-bucket copy when staleDays is in (7, 30]", () => {
const html = render( , "en");
- expect(html).toContain("data-slot=\"tile-stale-hint\"");
+ expect(html).toContain('data-slot="tile-stale-hint"');
expect(html).toContain("Last reading 12 d ago");
});
diff --git a/src/components/charts/chart-overlay-controls.tsx b/src/components/charts/chart-overlay-controls.tsx
index f7e188815..e759a8009 100644
--- a/src/components/charts/chart-overlay-controls.tsx
+++ b/src/components/charts/chart-overlay-controls.tsx
@@ -248,7 +248,7 @@ export function ChartOverlayControlsBody({
prefs.comparisonBaseline === value ? "default" : "outline"
}
size="sm"
- className={`min-h-11 sm:min-h-9 px-2 text-[11px] ${isGreyed ? "opacity-50" : ""}`.trim()}
+ className={`min-h-11 px-2 text-[11px] sm:min-h-9 ${isGreyed ? "opacity-50" : ""}`.trim()}
onClick={() => setComparisonBaseline(value)}
aria-pressed={prefs.comparisonBaseline === value}
aria-disabled={isGreyed || undefined}
diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx
index a4401fbdc..6e1c0e7c3 100644
--- a/src/components/charts/chart-skeleton.tsx
+++ b/src/components/charts/chart-skeleton.tsx
@@ -69,10 +69,7 @@ export function ChartSkeleton({
const [showSlowHint, setShowSlowHint] = useState(false);
useEffect(() => {
if (mini) return undefined;
- const handle = setTimeout(
- () => setShowSlowHint(true),
- SLOW_HINT_DELAY_MS,
- );
+ const handle = setTimeout(() => setShowSlowHint(true), SLOW_HINT_DELAY_MS);
return () => clearTimeout(handle);
}, [mini]);
diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx
index c6a23e071..e92a03f48 100644
--- a/src/components/charts/compliance-heatmap.tsx
+++ b/src/components/charts/compliance-heatmap.tsx
@@ -259,7 +259,11 @@ export function ComplianceHeatmap({
only kicks in for the non-stretch (static 18 px cell) case
when the parent column is narrower than `weeks * 21 px`. */}
setTooltip((prev) => (prev?.pinned ? prev : null))}
+ onMouseLeave={() =>
+ setTooltip((prev) => (prev?.pinned ? prev : null))
+ }
>
{/* Month labels */}
{monthMarkers.map((m, i) => (
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index c26b57a24..305b927fa 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -740,7 +740,9 @@ export function HealthChart({
// request so a longer comparison window still resolves.
sleepParams.set(
"days",
- String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
+ String(
+ Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays))),
+ ),
);
// v1.16.8 — a failed fetch rejects the whole query instead of
// silently resolving an empty series: the swallowed-catch
@@ -872,9 +874,13 @@ export function HealthChart({
const scaledMin = measurement.minValue * valueScale;
const scaledMax = measurement.maxValue * valueScale;
current.min =
- current.min === null ? scaledMin : Math.min(current.min, scaledMin);
+ current.min === null
+ ? scaledMin
+ : Math.min(current.min, scaledMin);
current.max =
- current.max === null ? scaledMax : Math.max(current.max, scaledMax);
+ current.max === null
+ ? scaledMax
+ : Math.max(current.max, scaledMax);
}
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
@@ -1063,20 +1069,20 @@ export function HealthChart({
// recomputes whenever the range tab changes the visible slice, so the
// `` a sub-page lifts the callback into always reflects the
// same window the chart paints. Skipped in mini mode (no stat strip).
- const visibleStatsByType = useMemo | null>(
- () => {
- if (mini || !chartData?.length) return null;
- const out: Record = {};
- for (const type of types) {
- const stats = computeWindowStats(
- chartData.map((point) => point[type] as number | undefined),
- );
- if (stats.count > 0) out[type] = stats;
- }
- return Object.keys(out).length > 0 ? out : null;
- },
- [mini, chartData, types],
- );
+ const visibleStatsByType = useMemo | null>(() => {
+ if (mini || !chartData?.length) return null;
+ const out: Record = {};
+ for (const type of types) {
+ const stats = computeWindowStats(
+ chartData.map((point) => point[type] as number | undefined),
+ );
+ if (stats.count > 0) out[type] = stats;
+ }
+ return Object.keys(out).length > 0 ? out : null;
+ }, [mini, chartData, types]);
// Report the visible-range stats up to the sub-page so the shared
// `` can read them. Effect (not render-time call) so the
@@ -1674,10 +1680,7 @@ export function HealthChart({
// positions through the `ticks` prop so the legacy
// day-aware density policy stays effective on the
// pulse chart.
- ticks={computeTickPositions(
- chartData ?? [],
- viewportWidth,
- )}
+ ticks={computeTickPositions(chartData ?? [], viewportWidth)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
/>
@@ -1690,7 +1693,9 @@ export function HealthChart({
domain={yDomain}
tickFormatter={(value) =>
typeof value === "number"
- ? formatAxisValue(useDecimalAxis ? value : Math.round(value))
+ ? formatAxisValue(
+ useDecimalAxis ? value : Math.round(value),
+ )
: String(value)
}
unit={
diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx
index cf7c81611..d5fdd8ed8 100644
--- a/src/components/charts/medication-compliance-chart.tsx
+++ b/src/components/charts/medication-compliance-chart.tsx
@@ -246,7 +246,9 @@ export function MedicationComplianceChart({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.dashboardMedicationCompliance(days),
queryFn: async (): Promise => {
- return apiGet(`/api/medications/intake?scope=compliance&days=${days}`);
+ return apiGet(
+ `/api/medications/intake?scope=compliance&days=${days}`,
+ );
},
enabled: isAuthenticated,
});
@@ -452,7 +454,7 @@ export function MedicationComplianceChart({
/>
)
) : (
-
+
@@ -218,7 +218,9 @@ export function MoodHeatmap({
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
- onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
+ onMouseLeave={() =>
+ setTooltip((prev) => (prev?.pinned ? prev : null))
+ }
>
{monthMarkers.map((m, i) => (
{[
- { color: "var(--dracula-green)", label: t("insights.mood.legendGreat") },
- { color: "var(--dracula-yellow)", label: t("insights.mood.legendGood") },
- { color: "var(--dracula-orange)", label: t("insights.mood.legendOkay") },
+ {
+ color: "var(--dracula-green)",
+ label: t("insights.mood.legendGreat"),
+ },
+ {
+ color: "var(--dracula-yellow)",
+ label: t("insights.mood.legendGood"),
+ },
+ {
+ color: "var(--dracula-orange)",
+ label: t("insights.mood.legendOkay"),
+ },
{ color: "var(--dracula-red)", label: t("insights.mood.legendLow") },
].map(({ color, label }) => (
diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx
index ac2bb5fe0..1f6f9f3ed 100644
--- a/src/components/charts/scatter-correlation-chart.tsx
+++ b/src/components/charts/scatter-correlation-chart.tsx
@@ -97,7 +97,7 @@ export function ScatterCorrelationChart>({
// shape once layout settles; this floor only prevents the brief
// zero-height window that fires Recharts' width(-1)/height(-1)
// console warning when the chart swaps in after the skeleton.
- className="touch-pan-y aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto"
+ className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto"
style={{ height: undefined }}
data-explicit-height={height}
>
diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx
index 01f9c445e..f654a841b 100644
--- a/src/components/charts/trend-card.tsx
+++ b/src/components/charts/trend-card.tsx
@@ -134,11 +134,7 @@ export function TrendCard({
// appeared on the same tile because slope30 and trend7Delta could
// legitimately disagree (30-day downward arc, 7-day uptick).
const primarySignal: number | null =
- trend7Delta != null
- ? trend7Delta
- : slope30
- ? slope30.slope * 7
- : null;
+ trend7Delta != null ? trend7Delta : slope30 ? slope30.slope * 7 : null;
const TrendIcon = ((): typeof ArrowUp => {
if (primarySignal == null) return Minus;
@@ -233,51 +229,51 @@ export function TrendCard({
// deterministic height. No unit; the arrow slot below still
// renders its muted placeholder so the row width stays stable.
{emptyHint}
) : (
<>
-
- {latest !== null ? renderPair(latest, secondary?.latest) : "—"}
-
-
- {unit}
-
+
+ {latest !== null ? renderPair(latest, secondary?.latest) : "—"}
+
+
+ {unit}
+
>
)}
- {label}
+
+ {label}
{value}
);
@@ -73,7 +67,7 @@ function Section({
children: React.ReactNode;
}) {
return (
-
+
@@ -100,8 +94,7 @@ export function ClinicianView({
const complianceEntries = sections.compliance
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
: [];
- const wellness =
- report.wellnessScores?.filter((s) => s.count > 0) ?? [];
+ const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? [];
return (
{label ? (
- {label}
+ {label}
) : null}
-
+
{t("clinicianView.period", {
start: fmtDate(report.period.start),
end: fmtDate(report.period.end),
})}
-
+
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
-
+
{t("clinicianView.provenance")}
@@ -213,10 +206,10 @@ export function ClinicianView({
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
-
+
{t("clinicianView.wellness.title")}
-
+
{t("clinicianView.wellness.disclaimer")}
@@ -239,7 +232,7 @@ export function ClinicianView({
) : null}
-
+
{t("clinicianView.footer")}
diff --git a/src/components/clinician/share-unlock-gate.tsx b/src/components/clinician/share-unlock-gate.tsx
new file mode 100644
index 000000000..a4f613e27
--- /dev/null
+++ b/src/components/clinician/share-unlock-gate.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+/**
+ * v1.18.7 — passphrase gate for a protected clinician share link.
+ *
+ * Renders ONLY when the server determined the link carries a passphrase and the
+ * short-lived unlock cookie is absent. The record is never sent to the client
+ * until the cookie is set — this island is the whole page until then.
+ *
+ * Two paths:
+ * - The owner shares a QR / deep link carrying `#k=` in the URL
+ * FRAGMENT (never sent to the server). On mount we read `location.hash`,
+ * auto-submit it, scrub the hash from history, and reload on success so the
+ * server re-renders the record with the now-set cookie.
+ * - No fragment → a localised passphrase input the clinician types into.
+ *
+ * The submit POSTs to `/api/c//unlock`; on success the server has set an
+ * httpOnly cookie scoped to this token's path, so a full reload paints the
+ * record. Failure shows one blunt localised error (the server leaks nothing).
+ */
+import { useEffect, useRef, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { apiPost } from "@/lib/api/api-fetch";
+
+type Translate = (
+ key: string,
+ vars?: Record,
+) => string;
+
+interface ShareUnlockGateProps {
+ t: Translate;
+ /** The raw `hls_` token from the path — used only to build the verify URL. */
+ token: string;
+}
+
+export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
+ const [passphrase, setPassphrase] = useState("");
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState(false);
+ const autoTried = useRef(false);
+
+ async function submit(value: string): Promise {
+ const trimmed = value.trim();
+ if (!trimmed) return;
+ setPending(true);
+ setError(false);
+ try {
+ await apiPost(`/api/c/${encodeURIComponent(token)}/unlock`, {
+ passphrase: trimmed,
+ });
+ // Cookie is set server-side; a full reload re-renders with the record.
+ window.location.reload();
+ } catch {
+ setError(true);
+ setPending(false);
+ }
+ }
+
+ // Auto-unlock from the `#k=` fragment, once.
+ useEffect(() => {
+ if (autoTried.current) return;
+ autoTried.current = true;
+ const hash = window.location.hash.replace(/^#/, "");
+ const params = new URLSearchParams(hash);
+ const k = params.get("k");
+ if (k) {
+ // Scrub the secret from the address bar / history before the request.
+ history.replaceState(null, "", window.location.pathname);
+ // Defer out of the effect body so the submit's setState does not run
+ // synchronously inside the effect (no cascading render).
+ queueMicrotask(() => void submit(k));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx
index 75be6f152..9b65dcd69 100644
--- a/src/components/cycle/__tests__/bbt-chart.test.tsx
+++ b/src/components/cycle/__tests__/bbt-chart.test.tsx
@@ -11,10 +11,7 @@ function render(node: React.ReactNode) {
);
}
-function day(
- date: string,
- overrides: Partial = {},
-): CalendarDay {
+function day(date: string, overrides: Partial = {}): CalendarDay {
return {
date,
phase: "FOLLICULAR",
diff --git a/src/components/cycle/__tests__/field-info.test.tsx b/src/components/cycle/__tests__/field-info.test.tsx
index c97a58e63..4e80206d2 100644
--- a/src/components/cycle/__tests__/field-info.test.tsx
+++ b/src/components/cycle/__tests__/field-info.test.tsx
@@ -29,7 +29,10 @@ describe("", () => {
// Radix renders the tooltip content lazily (portal, on open); the trigger
// therefore reports a closed state until interacted with.
const html = render(
- ,
+ ,
);
expect(html).toContain('data-state="closed"');
});
diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts
index f2e5a54cf..e7b281c00 100644
--- a/src/components/cycle/__tests__/log-day-sheet.test.ts
+++ b/src/components/cycle/__tests__/log-day-sheet.test.ts
@@ -107,7 +107,11 @@ describe("buildDayLogInput (new row → omit empties)", () => {
);
expect(input.basalBodyTempC).toBe(36.5);
expect(input.temperatureExcluded).toBe(true);
- const patch = buildDayLogPatch({ ...blank(), bbt: "36.5", bbtDisturbed: true });
+ const patch = buildDayLogPatch({
+ ...blank(),
+ bbt: "36.5",
+ bbtDisturbed: true,
+ });
expect(patch.temperatureExcluded).toBe(true);
});
@@ -145,7 +149,13 @@ describe("buildDayLogInput (new row → omit empties)", () => {
describe("section summary-badge counts (v1.17.0)", () => {
it("counts each logged symptom", () => {
expect(symptomsCount(blank())).toBe(0);
- const s = { ...blank(), symptoms: new Map([["cramps", 2], ["headache", null]]) };
+ const s = {
+ ...blank(),
+ symptoms: new Map([
+ ["cramps", 2],
+ ["headache", null],
+ ]),
+ };
expect(symptomsCount(s)).toBe(2);
});
diff --git a/src/components/cycle/cycle-history-chart.tsx b/src/components/cycle/cycle-history-chart.tsx
index 31bd337f2..4d5201969 100644
--- a/src/components/cycle/cycle-history-chart.tsx
+++ b/src/components/cycle/cycle-history-chart.tsx
@@ -131,9 +131,7 @@ export function CycleHistoryChart({
{t("cycle.history.chartTitle")}
{hasData ? (
-
- {t("cycle.history.chartCaption")}
-
+ {t("cycle.history.chartCaption")}
) : null}
@@ -144,7 +142,10 @@ export function CycleHistoryChart({
) : (
<>
{/* Stat chips — avg / variability / regularity classification. */}
-
+
- t("cycle.history.days", { count: b.lengthDays })
- }
+ barTitle={(b) => t("cycle.history.days", { count: b.lengthDays })}
/>
{/* Inline legend mirroring the bar segments. */}
@@ -279,8 +278,7 @@ function BarField({
const x = cx - barW / 2;
const totalH = scale(b.lengthDays);
const topY = baseY - totalH;
- const periodH =
- b.periodDays != null ? scale(b.periodDays) : 0;
+ const periodH = b.periodDays != null ? scale(b.periodDays) : 0;
const periodTopY = baseY - periodH;
const restH = totalH - periodH;
const ovuY =
@@ -353,9 +351,7 @@ function StatChip({
} & React.HTMLAttributes) {
return (
-
-
-
- {t("cycle.wellnessRing.label")}
-
-
-
-
-
- {t(`cycle.phase.${wheel.phase}`)}
-
-
+
+
+
+ {t("cycle.wellnessRing.label")}
+
+
+
+
+
+ {t(`cycle.phase.${wheel.phase}`)}
+
+
);
diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx
index 68b323cca..2ee30642f 100644
--- a/src/components/cycle/cycle-settings.tsx
+++ b/src/components/cycle/cycle-settings.tsx
@@ -17,11 +17,7 @@ import { Switch } from "@/components/ui/switch";
import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "@/lib/i18n/context";
-import type {
- CycleGoal,
- CycleProfileDTO,
- SecondarySymptom,
-} from "./types";
+import type { CycleGoal, CycleProfileDTO, SecondarySymptom } from "./types";
import { useUpdateCyclePrefs } from "./use-cycle";
/**
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 74e6b9bea..43a63ac41 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -171,10 +171,7 @@ export function CycleView() {
node never matched, leaving the tile stuck at the rise rule's
`opacity: 0` (invisible but still boxed). The `contents` wrapper
carries the flag without adding a layout box. */}
-
+
- {loading ? (
-
-
-
- ) : calendarError ? (
-
-
- {t("cycle.loadError")}
+ {loading ? (
+
+
+
+ ) : calendarError ? (
+
+
+ {t("cycle.loadError")}
+
+
void calendar.refetch()}
+ >
+
+ {t("common.retry")}
+
+
+ ) : (
+
+ )}
+ {!calendarError ? (
+
+ {t("cycle.ring.caption")}
+ ) : null}
+ {/* First-period CTA — only when no cycle is active yet. */}
+ {!loading && !calendarError && wheel.dayOfCycle == null ? (
void calendar.refetch()}
+ className="mt-1 w-full"
+ onClick={() => openSheet(today)}
>
-
- {t("common.retry")}
+ {t("cycle.ring.firstPeriodCta")}
-
- ) : (
-
- )}
- {!calendarError ? (
-
- {t("cycle.ring.caption")}
-
- ) : null}
- {/* First-period CTA — only when no cycle is active yet. */}
- {!loading && !calendarError && wheel.dayOfCycle == null ? (
-
openSheet(today)}
- >
- {t("cycle.ring.firstPeriodCta")}
-
- ) : null}
+ ) : null}
@@ -255,7 +252,7 @@ export function CycleView() {
triggers size to their content (`text-xs`, no `flex-1`) and the
strip scrolls cleanly if needed; from `sm` up they reclaim the
equal-width `flex-1` layout. */}
-
+
(
null,
);
- const [cervixOpening, setCervixOpening] = useState(null);
+ const [cervixOpening, setCervixOpening] = useState(
+ null,
+ );
const [intercourse, setIntercourse] = useState(false);
const [protectedSex, setProtectedSex] = useState(false);
const [pregnancyTest, setPregnancyTest] = useState(
@@ -884,121 +883,121 @@ export function LogDaySheet({
/>
}
>
- setBbt(e.target.value)}
- placeholder={t("cycle.sheet.temperaturePlaceholder")}
- className="w-32"
- aria-label={t("cycle.sheet.temperature")}
- />
-
- {t("cycle.sheet.bbtHint")}
-
- {resolveBbt(bbt) != null && (
-
-
-
- {t("cycle.sheet.temperatureDisturbed")}
-
-
- {t("cycle.sheet.temperatureDisturbedHint")}
-
-
-
-
- )}
-
-
- {/* Ovulation test */}
- setBbt(e.target.value)}
+ placeholder={t("cycle.sheet.temperaturePlaceholder")}
+ className="w-32"
+ aria-label={t("cycle.sheet.temperature")}
/>
- }
- >
-
- {OPK_VALUES.map((v) => (
- setOpk((cur) => (cur === v ? null : v))}
- >
- {t(`cycle.ovulationTest.${v}`)}
-
- ))}
-
-
-
- {/* Symptothermal secondary symptom: cervical mucus (default) OR the
- cervix observation, per the user's advanced-settings choice. Only one
- of the two is offered so the log sheet never overwhelms. */}
- {showCervix ? (
-
-
- t(`cycle.cervixPosition.${v}`)}
- onToggle={(v) =>
- setCervixPosition((cur) => (cur === v ? null : v))
- }
- />
- t(`cycle.cervixFirmness.${v}`)}
- onToggle={(v) =>
- setCervixFirmness((cur) => (cur === v ? null : v))
- }
- />
- t(`cycle.cervixOpening.${v}`)}
- onToggle={(v) =>
- setCervixOpening((cur) => (cur === v ? null : v))
- }
- />
-
+
+ {t("cycle.sheet.bbtHint")}
+
+ {resolveBbt(bbt) != null && (
+
+
+
+ {t("cycle.sheet.temperatureDisturbed")}
+
+
+ {t("cycle.sheet.temperatureDisturbedHint")}
+
+
+
+
+ )}
- ) : (
+
+ {/* Ovulation test */}
}
>
- {MUCUS_VALUES.map((v) => (
+ {OPK_VALUES.map((v) => (
setMucus((cur) => (cur === v ? null : v))}
+ active={opk === v}
+ onClick={() => setOpk((cur) => (cur === v ? null : v))}
>
- {t(`cycle.mucus.${v}`)}
+ {t(`cycle.ovulationTest.${v}`)}
))}
- )}
+
+ {/* Symptothermal secondary symptom: cervical mucus (default) OR the
+ cervix observation, per the user's advanced-settings choice. Only one
+ of the two is offered so the log sheet never overwhelms. */}
+ {showCervix ? (
+
+
+ t(`cycle.cervixPosition.${v}`)}
+ onToggle={(v) =>
+ setCervixPosition((cur) => (cur === v ? null : v))
+ }
+ />
+ t(`cycle.cervixFirmness.${v}`)}
+ onToggle={(v) =>
+ setCervixFirmness((cur) => (cur === v ? null : v))
+ }
+ />
+ t(`cycle.cervixOpening.${v}`)}
+ onToggle={(v) =>
+ setCervixOpening((cur) => (cur === v ? null : v))
+ }
+ />
+
+
+ ) : (
+
+ }
+ >
+
+ {MUCUS_VALUES.map((v) => (
+ setMucus((cur) => (cur === v ? null : v))}
+ >
+ {t(`cycle.mucus.${v}`)}
+
+ ))}
+
+
+ )}
{/* Intimacy & contraception */}
@@ -1008,48 +1007,48 @@ export function LogDaySheet({
>
{/* Intercourse + protection */}
-
-
-
-
- {t("cycle.sheet.intercourse")}
-
-
-
-
-
- {intercourse ? (
+
- {t("cycle.sheet.protected")}
+ {t("cycle.sheet.intercourse")}
- ) : null}
-
+ {intercourse ? (
+
+
+
+ {t("cycle.sheet.protected")}
+
+
+
+
+
+ ) : null}
+
{/* Contraceptive */}
diff --git a/src/components/cycle/phase-education-card.tsx b/src/components/cycle/phase-education-card.tsx
index eb8e65bcd..7bba9871f 100644
--- a/src/components/cycle/phase-education-card.tsx
+++ b/src/components/cycle/phase-education-card.tsx
@@ -124,83 +124,83 @@ export function PhaseEducationCard({
className,
)}
>
- {/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
-
-
-
- {t("cycle.phaseEducation.title")}
-
-
+ {/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
+
+
+
+ {t("cycle.phaseEducation.title")}
+
+
- {stillLearning ? (
-
- {t("cycle.phaseEducation.stillLearning")}
-
- ) : (
- <>
-
-
- {phaseName}
-
+ {stillLearning ? (
+
+ {t("cycle.phaseEducation.stillLearning")}
+
+ ) : (
+ <>
+
+
+ {phaseName}
+
- {/* Zone 2 — the curated, descriptive-only "what's happening" line,
+ {/* Zone 2 — the curated, descriptive-only "what's happening" line,
followed by a deeper "context" line (what someone might notice +
gentle framing of normal variation). Both stay descriptive, never
clinical advice / a medical claim. */}
-
- {t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
-
-
- {t(PHASE_CONTEXT_KEY[phase as CyclePhase])}
-
+
+ {t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
+
+
+ {t(PHASE_CONTEXT_KEY[phase as CyclePhase])}
+
- {/* Zone 3 — the user's OWN clustered symptoms for this phase
+ {/* Zone 3 — the user's OWN clustered symptoms for this phase
("you often notice this here"). */}
- {chips.length > 0 ? (
-
-
- {t("cycle.phaseEducation.commonHere")}
-
-
- {chips.map((row) => {
- const meta = byKey.get(row.symptomKey);
- const Icon = meta?.icon;
- const name = meta ? t(meta.labelKey) : row.symptomKey;
- return (
-
- {Icon ? (
-
- ) : null}
- {name}
-
- );
- })}
-
-
- ) : null}
- >
- )}
+ {chips.length > 0 ? (
+
+
+ {t("cycle.phaseEducation.commonHere")}
+
+
+ {chips.map((row) => {
+ const meta = byKey.get(row.symptomKey);
+ const Icon = meta?.icon;
+ const name = meta ? t(meta.labelKey) : row.symptomKey;
+ return (
+
+ {Icon ? (
+
+ ) : null}
+ {name}
+
+ );
+ })}
+
+
+ ) : null}
+ >
+ )}
);
diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts
index d6f1ef287..591971cfd 100644
--- a/src/components/cycle/use-cycle.ts
+++ b/src/components/cycle/use-cycle.ts
@@ -193,8 +193,7 @@ export function useDeleteCustomSymptom() {
export function useCycleProfile() {
return useQuery({
queryKey: queryKeys.cycleProfile(),
- queryFn: () =>
- apiGet
("/api/cycle/profile"),
+ queryFn: () => apiGet("/api/cycle/profile"),
staleTime: 5 * 60_000,
});
}
@@ -207,8 +206,7 @@ export function useCycleProfile() {
export function useCycleInsights() {
return useQuery({
queryKey: queryKeys.cycleInsights(),
- queryFn: () =>
- apiGet("/api/cycle/insights"),
+ queryFn: () => apiGet("/api/cycle/insights"),
staleTime: 5 * 60_000,
});
}
diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx
index 1bc2d2b61..750d97bab 100644
--- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx
+++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx
@@ -40,10 +40,9 @@ let mockResult: {
};
vi.mock("@/hooks/use-workouts", async () => {
- const actual =
- await vi.importActual(
- "@/hooks/use-workouts",
- );
+ const actual = await vi.importActual(
+ "@/hooks/use-workouts",
+ );
return {
...actual,
useWorkouts: () => mockResult,
diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx
index 02154f6de..d13db1963 100644
--- a/src/components/dashboard/hero/dashboard-hero.tsx
+++ b/src/components/dashboard/hero/dashboard-hero.tsx
@@ -232,92 +232,94 @@ export function DashboardHero({
)}
>
-
-
- {/* Greeting leads the typographic hierarchy: larger + heavier
+
+
+ {/* Greeting leads the typographic hierarchy: larger + heavier
than the verdict so the personalised line reads first.
The verdict keeps its medium weight one step down — still
the content lead, clearly subordinate to the greeting.
Both stay on the existing foreground token; the band adds
no new colour. */}
-
- {welcomeText}
-
-
- {sentence}
+ {welcomeText}
- {cta !== null && ctaLabelKey ? (
- cta.kind === "link" ? (
-
- {t(ctaLabelKey)}
-
- ) : (
-
onQuickEntry(cta.target)}
- >
- {t(ctaLabelKey)}
-
- )
- ) : null}
-
-
-
-
- {meds.scheduledToday > 0
- ? t("dashboard.hero.doses.summary", {
- taken: meds.takenToday,
- scheduled: meds.scheduledToday,
- })
- : t("dashboard.hero.doses.none")}
-
- {doseDetail !== null ? (
-
- · {doseDetail}
+
+
+ {sentence}
+
+ {cta !== null && ctaLabelKey ? (
+ cta.kind === "link" ? (
+
+ {t(ctaLabelKey)}
+
+ ) : (
+
onQuickEntry(cta.target)}
+ >
+ {t(ctaLabelKey)}
+
+ )
+ ) : null}
+
+
+
+
+ {meds.scheduledToday > 0
+ ? t("dashboard.hero.doses.summary", {
+ taken: meds.takenToday,
+ scheduled: meds.scheduledToday,
+ })
+ : t("dashboard.hero.doses.none")}
- ) : null}
+ {doseDetail !== null ? (
+
+ · {doseDetail}
+
+ ) : null}
+
-
- {/* Right column — fixed 120 px ring footprint. A null score
+ {/* Right column — fixed 120 px ring footprint. A null score
renders the ring's provisional state (aria announces "not
enough data", never 0) at identical geometry, so the column
NEVER collapses. */}
-
-
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating
(renders nothing unless an episode is active), value-free, and
untinted so it frames the score without alarming. Closes the
diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx
index b85d45fa2..25d7f1c4a 100644
--- a/src/components/dashboard/medication-intake-quick-add.tsx
+++ b/src/components/dashboard/medication-intake-quick-add.tsx
@@ -281,7 +281,7 @@ export function MedicationIntakeQuickAdd({
// Fan-out to every inline compliance chart key (one per medication)
// so the detail-page tile refreshes when the user re-enters it.
await queryClient.invalidateQueries({
- queryKey: ["compliance-chart-inline"],
+ queryKey: queryKeys.complianceChartInline(),
});
toast.success(t("common.saved"));
diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx
index e52db6cab..ad111d972 100644
--- a/src/components/dashboard/recent-workouts-tile.tsx
+++ b/src/components/dashboard/recent-workouts-tile.tsx
@@ -103,13 +103,14 @@ export function RecentWorkoutsTile() {
return (
-
+
{t("dashboard.recentWorkouts.title")}
@@ -134,9 +135,7 @@ export function RecentWorkoutsTile() {
) : workouts.length === 0 ? (
-
- {t("dashboard.recentWorkouts.empty.title")}
-
+
{t("dashboard.recentWorkouts.empty.title")}
{t("dashboard.recentWorkouts.empty.cta")}
diff --git a/src/components/data-list/delete-button.tsx b/src/components/data-list/delete-button.tsx
index 3252d381e..5dffb893d 100644
--- a/src/components/data-list/delete-button.tsx
+++ b/src/components/data-list/delete-button.tsx
@@ -62,11 +62,10 @@ export function DeleteButton({
{description}
- {cancelLabel ?? t("common.cancel")}
-
+
+ {cancelLabel ?? t("common.cancel")}
+
+
{confirmLabel ?? t("common.delete")}
diff --git a/src/components/data-list/selection.ts b/src/components/data-list/selection.ts
index d482aebf3..be8138753 100644
--- a/src/components/data-list/selection.ts
+++ b/src/components/data-list/selection.ts
@@ -12,7 +12,10 @@
*/
/** Toggle a single id in/out of the selection set, returning a new Set. */
-export function toggleId(selected: ReadonlySet
, id: string): Set {
+export function toggleId(
+ selected: ReadonlySet,
+ id: string,
+): Set {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
diff --git a/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx b/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx
index 66df0b1f3..496ba193f 100644
--- a/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx
+++ b/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx
@@ -16,10 +16,11 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
}));
-const useAchievementsQuery =
- vi.fn<(opts?: { enabled?: boolean }) => { data: undefined }>(() => ({
- data: undefined,
- }));
+const useAchievementsQuery = vi.fn<
+ (opts?: { enabled?: boolean }) => { data: undefined }
+>(() => ({
+ data: undefined,
+}));
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: (opts?: { enabled?: boolean }) =>
diff --git a/src/components/gamification/__tests__/recent-achievements-card.test.tsx b/src/components/gamification/__tests__/recent-achievements-card.test.tsx
index dc538d1f2..6b5f98ec2 100644
--- a/src/components/gamification/__tests__/recent-achievements-card.test.tsx
+++ b/src/components/gamification/__tests__/recent-achievements-card.test.tsx
@@ -142,7 +142,11 @@ describe(" — achievements module gate", () => {
mockData = {
summary: {},
achievements: [
- { ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
+ {
+ ...baseAchievement,
+ id: "a",
+ completedAt: "2026-04-15T12:00:00.000Z",
+ },
],
metrics: {},
};
@@ -154,7 +158,11 @@ describe(" — achievements module gate", () => {
mockData = {
summary: {},
achievements: [
- { ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" },
+ {
+ ...baseAchievement,
+ id: "a",
+ completedAt: "2026-04-15T12:00:00.000Z",
+ },
],
metrics: {},
};
diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx
index df7807644..df45024f8 100644
--- a/src/components/gamification/recent-achievements-card.tsx
+++ b/src/components/gamification/recent-achievements-card.tsx
@@ -115,7 +115,10 @@ export function RecentAchievementsCard() {
>
-
+
{t("achievements.dashboardCard.title")}
diff --git a/src/components/i18n/__tests__/maintainership-banner.test.tsx b/src/components/i18n/__tests__/maintainership-banner.test.tsx
index 90e297a73..243344e24 100644
--- a/src/components/i18n/__tests__/maintainership-banner.test.tsx
+++ b/src/components/i18n/__tests__/maintainership-banner.test.tsx
@@ -112,16 +112,11 @@ describe("", () => {
};
for (const [locale, pattern] of Object.entries(machineTranslatedPatterns)) {
const data = JSON.parse(
- readFileSync(
- join(process.cwd(), "messages", `${locale}.json`),
- "utf8",
- ),
+ readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"),
) as { i18n: { maintainershipBanner: { notice: string } } };
const notice = data.i18n.maintainershipBanner.notice;
expect(notice, `${locale} banner notice`).toMatch(pattern);
- expect(notice, `${locale} banner notice mentions Coach`).toMatch(
- /Coach/,
- );
+ expect(notice, `${locale} banner notice mentions Coach`).toMatch(/Coach/);
}
});
});
diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx
index 389530700..000bac25f 100644
--- a/src/components/illness/illness-correlation-card.tsx
+++ b/src/components/illness/illness-correlation-card.tsx
@@ -16,10 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useIllnessCorrelation } from "./use-illness";
-import type {
- IllnessCorrelationValue,
- IllnessVitalDeviation,
-} from "./types";
+import type { IllnessCorrelationValue, IllnessVitalDeviation } from "./types";
/** Translate a MeasurementType to its illness-surface label. */
function useVitalLabel() {
@@ -76,7 +73,9 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
{gap > 0
? t("illness.correlation.recoveryGapLag", { days: gap })
: gap < 0
- ? t("illness.correlation.recoveryGapLead", { days: Math.abs(gap) })
+ ? t("illness.correlation.recoveryGapLead", {
+ days: Math.abs(gap),
+ })
: t("illness.correlation.recoveryGapSame")}
diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx
index 83e9f9d73..674686a3c 100644
--- a/src/components/illness/illness-day-timeline.tsx
+++ b/src/components/illness/illness-day-timeline.tsx
@@ -32,8 +32,8 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
const fmt = useFormatters();
return (
-
-
+
+
{fmt.dateShort(`${log.date}T12:00:00`)}
diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx
index 79706cc8a..5c56898ad 100644
--- a/src/components/illness/illness-episode-detail.tsx
+++ b/src/components/illness/illness-episode-detail.tsx
@@ -86,7 +86,10 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
) : null}
-
setLogOpen(true)} className="min-h-11 sm:min-h-9">
+ setLogOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
{t("illness.logDay")}
{episode ? (
@@ -94,7 +97,9 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
- active && !isChronic ? () => resolve.mutate(episode.id) : undefined
+ active && !isChronic
+ ? () => resolve.mutate(episode.id)
+ : undefined
}
resolving={resolve.isPending}
/>
@@ -105,7 +110,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
{episode?.note ? (
-
+
{t("illness.detail.note")}
diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx
index fd627f385..28edcea83 100644
--- a/src/components/illness/illness-insights-card.tsx
+++ b/src/components/illness/illness-insights-card.tsx
@@ -14,7 +14,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { useIllnessInsights } from "./use-illness";
-export function IllnessInsightsCard({ windowDays = 365 }: { windowDays?: number }) {
+export function IllnessInsightsCard({
+ windowDays = 365,
+}: {
+ windowDays?: number;
+}) {
const { t } = useTranslations();
const { data, isLoading, isError } = useIllnessInsights(windowDays);
@@ -47,7 +51,9 @@ export function IllnessInsightsCard({ windowDays = 365 }: { windowDays?: number
{data.episodeCount === 1
? t("illness.insights.countOne")
- : t("illness.insights.countOther", { count: data.episodeCount })}
+ : t("illness.insights.countOther", {
+ count: data.episodeCount,
+ })}
{data.typicalRecoveryGapDays !== null
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index d3722301e..4db8ab460 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -109,7 +109,7 @@ function EpisodeCard({
onLogDay(episode.id)}
>
{t("illness.logDay")}
@@ -277,7 +277,7 @@ function EpisodeGroup({
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={gridId}
- className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium uppercase tracking-wide transition-colors"
+ className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium tracking-wide uppercase transition-colors"
>
-
+
{title}
{grid}
@@ -314,7 +314,9 @@ export function IllnessView() {
const today = todayLocal();
const [newOpen, setNewOpen] = useState(false);
- const [editEpisode, setEditEpisode] = useState(null);
+ const [editEpisode, setEditEpisode] = useState(
+ null,
+ );
const [logEpisodeId, setLogEpisodeId] = useState(null);
const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx
index 4f59518ec..19319ee94 100644
--- a/src/components/illness/log-day-sheet.tsx
+++ b/src/components/illness/log-day-sheet.tsx
@@ -17,10 +17,7 @@ import { useMemo, useState } from "react";
import { Activity, CalendarDays, NotebookPen, Thermometer } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
-import {
- SheetSection,
- SheetSectionCount,
-} from "@/components/ui/sheet-section";
+import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -259,7 +256,9 @@ export function LogDaySheet({
title={t("illness.sheet.day")}
icon={ }
summary={
- {selectedDate}
+
+ {selectedDate}
+
}
defaultOpen
>
@@ -319,9 +318,7 @@ export function LogDaySheet({
key={value}
active={functionalImpact === value}
onClick={() =>
- setFunctionalImpact(
- functionalImpact === value ? null : value,
- )
+ setFunctionalImpact(functionalImpact === value ? null : value)
}
>
{impactLabels[value]}
diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx
index b312d6512..a93d61480 100644
--- a/src/components/illness/new-episode-sheet.tsx
+++ b/src/components/illness/new-episode-sheet.tsx
@@ -22,12 +22,12 @@ import {
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
-import { useCreateEpisode, useIllnessEpisodes, useUpdateEpisode } from "./use-illness";
-import type {
- IllnessEpisodeDTO,
- IllnessLifecycle,
- IllnessType,
-} from "./types";
+import {
+ useCreateEpisode,
+ useIllnessEpisodes,
+ useUpdateEpisode,
+} from "./use-illness";
+import type { IllnessEpisodeDTO, IllnessLifecycle, IllnessType } from "./types";
const TYPES: IllnessType[] = [
"INFECTION",
@@ -151,7 +151,10 @@ export function NewEpisodeSheet({
onOpenChange(false)}>
{t("common.cancel")}
-
+
{t("common.save")}
>
@@ -212,7 +215,9 @@ export function NewEpisodeSheet({
- {t("illness.new.parentNone")}
+
+ {t("illness.new.parentNone")}
+
{parentCandidates.map((e) => (
{e.label}
diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts
index bf135726e..75ecf6866 100644
--- a/src/components/illness/use-illness.ts
+++ b/src/components/illness/use-illness.ts
@@ -122,8 +122,13 @@ export function useCreateEpisode() {
export function useUpdateEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
- mutationFn: ({ id, input }: { id: string; input: IllnessEpisodeUpdateInput }) =>
- apiPatch(`/api/illness/episodes/${id}`, input),
+ mutationFn: ({
+ id,
+ input,
+ }: {
+ id: string;
+ input: IllnessEpisodeUpdateInput;
+ }) => apiPatch(`/api/illness/episodes/${id}`, input),
onSuccess: invalidate,
});
}
@@ -142,8 +147,7 @@ export function useResolveEpisode() {
export function useDeleteEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
- mutationFn: (id: string) =>
- apiDelete(`/api/illness/episodes/${id}`),
+ mutationFn: (id: string) => apiDelete(`/api/illness/episodes/${id}`),
onSuccess: invalidate,
});
}
diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx
index 0f2b47a83..be94ffffb 100644
--- a/src/components/insights/__tests__/daily-briefing.test.tsx
+++ b/src/components/insights/__tests__/daily-briefing.test.tsx
@@ -217,17 +217,12 @@ describe("", () => {
});
it("renders the German connect-provider hint under the de locale", () => {
- const html = render(
- ,
- "de",
- );
+ const html = render( , "de");
expect(html).toContain("Kein KI-Anbieter verbunden");
});
it("prefers the briefing content over the no-provider hint when both exist", () => {
- const html = render(
- ,
- );
+ const html = render( );
expect(html).toContain('data-slot="daily-briefing-findings"');
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
});
diff --git a/src/components/insights/__tests__/health-score-card-provenance.test.tsx b/src/components/insights/__tests__/health-score-card-provenance.test.tsx
index d65d364cf..c84c51766 100644
--- a/src/components/insights/__tests__/health-score-card-provenance.test.tsx
+++ b/src/components/insights/__tests__/health-score-card-provenance.test.tsx
@@ -120,7 +120,9 @@ describe(" — provenance accordion (W8e)", () => {
// attribute order because additional ARIA attributes (e.g.
// `aria-labelledby`) may sit between `id` and `data-slot`.
const panelMatch = html.match(
- new RegExp(`]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`),
+ new RegExp(
+ `]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`,
+ ),
);
expect(panelMatch).not.toBeNull();
});
@@ -265,9 +267,7 @@ describe(" — provenance accordion (W8e)", () => {
delta={null}
/>,
);
- expect(html).toContain(
- 'data-slot="health-score-card-provisional-badge"',
- );
+ expect(html).toContain('data-slot="health-score-card-provisional-badge"');
// EN badge copy.
expect(html).toContain("Provisional");
});
@@ -370,8 +370,6 @@ describe(" — provenance accordion (W8e)", () => {
initiallyExpanded
/>,
);
- expect(html).toContain(
- 'data-slot="health-score-card-provenance-footnote"',
- );
+ expect(html).toContain('data-slot="health-score-card-provenance-footnote"');
});
});
diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx
index af2a04019..bc4cee525 100644
--- a/src/components/insights/__tests__/health-score-card.test.tsx
+++ b/src/components/insights/__tests__/health-score-card.test.tsx
@@ -208,7 +208,6 @@ describe("", () => {
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
-
it("renders German strings", () => {
const html = ssr(
", () => {
delta={null}
/>,
);
- const cardOpen = html.match(
- /]*data-slot="health-score-card"[^>]*>/,
- );
+ const cardOpen = html.match(/
]*data-slot="health-score-card"[^>]*>/);
expect(cardOpen).not.toBeNull();
expect(cardOpen?.[0]).toContain("h-full");
expect(cardOpen?.[0]).toContain("flex");
diff --git a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx
index 7ef0d6b1f..05ed40648 100644
--- a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx
+++ b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx
@@ -106,9 +106,7 @@ describe("
", () => {
// directly. Pinning the negative so a future refactor can't
// reintroduce the wrapper.
const html = ssr( );
- expect(html).not.toMatch(
- /]*onclick/i,
- );
+ expect(html).not.toMatch(/]*onclick/i);
});
it("renders the trigger in German with the localised aria-label", () => {
@@ -129,9 +127,7 @@ describe("", () => {
// future "open on render" refactor from leaking long-form copy
// into the static markup.
const html = ssr( );
- expect(html).not.toContain(
- 'data-slot="health-score-delta-explainer-body"',
- );
+ expect(html).not.toContain('data-slot="health-score-delta-explainer-body"');
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-title"',
);
@@ -187,15 +183,9 @@ describe("", () => {
// The probe re-uses the same `t()` payload the popover does,
// so any missing key surfaces as the literal key path which
// would never contain a space.
- expect(html).not.toContain(
- "insights.healthScore.deltaExplainer.trigger",
- );
- expect(html).not.toContain(
- "insights.healthScore.deltaExplainer.title",
- );
- expect(html).not.toContain(
- "insights.healthScore.deltaExplainer.body",
- );
+ expect(html).not.toContain("insights.healthScore.deltaExplainer.trigger");
+ expect(html).not.toContain("insights.healthScore.deltaExplainer.title");
+ expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
}
});
});
diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx
index 49ade7738..b43e2ac74 100644
--- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx
+++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx
@@ -42,7 +42,8 @@ vi.mock("@/hooks/use-insights-analytics", () => ({
const metricStatusMock = vi.fn();
vi.mock("@/hooks/use-insight-status", async (importOriginal) => {
- const actual = await importOriginal();
+ const actual =
+ await importOriginal();
return {
...actual,
useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args),
@@ -113,7 +114,10 @@ describe(" status mount", () => {
expect(html).toContain('data-testid="insight-status-card-loading"');
expect(html).not.toContain("Your HRV has trended upward");
// The card fetch is enabled (statusMetric present + data present).
- expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true);
+ expect(metricStatusMock).toHaveBeenCalledWith(
+ "HEART_RATE_VARIABILITY",
+ true,
+ );
});
it("does not mount the assessment card when statusMetric is omitted", () => {
@@ -158,7 +162,10 @@ describe(" status mount", () => {
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
- ,
+ ,
);
// Stat-strip skeleton + chart skeleton paint; the assessment card never
@@ -180,7 +187,10 @@ describe(" status mount", () => {
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
- ,
+ ,
);
expect(html).toContain('data-slot="healthkit-metric-error"');
diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx
index 797e136b2..7b4c616d1 100644
--- a/src/components/insights/__tests__/hero-strip.test.tsx
+++ b/src/components/insights/__tests__/hero-strip.test.tsx
@@ -6,8 +6,9 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/**
* Insights hero strip — pins the slots so future polish can't
- * silently drop the greeting / subtitle / action row / suggested-
- * prompt strip.
+ * silently drop the greeting / subtitle / baseline meta. v1.18.7
+ * removed the coach action row + suggested-prompt strip; the negative
+ * tests below pin their absence.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -121,26 +122,12 @@ describe("", () => {
);
});
- it("renders the ask-the-coach action button as disabled with a 'Coming soon' title when no handler is supplied", () => {
+ // v1.18.7 — the overview "Ask the coach" action button was removed from
+ // the hero band. The Coach lives in the bottom-right drawer (mounted by
+ // the insights layout shell), not as a hero affordance.
+ it("does NOT render an ask-the-coach action button", () => {
const html = render( );
- const coachTag = html.match(
- /]*data-slot="insights-hero-strip-action-coach"[^>]*>/,
- );
- expect(coachTag).not.toBeNull();
- expect(coachTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
- expect(coachTag?.[0]).toContain('title="Coming soon"');
- });
-
- it("enables the ask-the-coach button when onAskCoach is supplied (B2b)", () => {
- const html = render(
- {}} />,
- );
- const coachTag = html.match(
- /]*data-slot="insights-hero-strip-action-coach"[^>]*>/,
- );
- expect(coachTag).not.toBeNull();
- expect(coachTag?.[0]).not.toMatch(/\sdisabled(=""|\s|>)/);
- expect(coachTag?.[0]).not.toContain('title="Coming soon"');
+ expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"');
});
// v1.4.25 W3 — the regenerate button moved from the hero action row
@@ -152,20 +139,13 @@ describe("", () => {
});
// ── Suggested prompts strip ────────────────────────────────────────
- it("mounts the SuggestedPrompts strip below the action band", () => {
+ // v1.18.7 — the guided-questions chip strip ("Try asking …") was removed
+ // from the hero band along with the coach action row.
+ it("does NOT mount the SuggestedPrompts strip in the hero band", () => {
const html = render( );
- expect(html).toMatch(/data-slot="insights-hero-strip-prompts"/);
- expect(html).toMatch(/data-slot="insights-suggested-prompts"/);
- expect(html).toContain("Try asking");
- expect(html).toContain("What should I tell my doctor?");
- });
-
- it("forwards onPickPrompt down to the prompt strip without throwing", () => {
- const html = render(
- {}} />,
- );
- // The chips render verbatim; SSR doesn't run click handlers.
- expect(html).toMatch(/data-slot="insights-suggested-prompts-chip"/);
+ expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
+ expect(html).not.toContain('data-slot="insights-suggested-prompts"');
+ expect(html).not.toContain("What should I tell my doctor?");
});
// ── Misc ───────────────────────────────────────────────────────────
@@ -229,12 +209,11 @@ describe("", () => {
expect(html).toContain(">86<");
});
- it("does not render an inline Ask-the-Coach button inside the Health Score card", () => {
- // v1.4.27 F8 — the inline HSC Ask-the-Coach button retired in
- // favour of the hero strip's existing action-row button. Even
- // when `onAskCoach` is supplied, the HSC panel must not surface
- // its own button. The action-row button at
- // `insights-hero-strip-action-coach` carries the affordance.
+ it("does not render any Ask-the-Coach affordance in the hero band", () => {
+ // v1.4.27 F8 — the inline HSC Ask-the-Coach button was retired.
+ // v1.18.7 — the hero action-row coach button was removed too; the
+ // Coach is the bottom-right drawer, not a hero affordance. Neither
+ // the score card nor the band surfaces a coach button.
const html = render(
", () => {
},
delta: 5,
}}
- onAskCoach={() => {}}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
- expect(html).toContain('data-slot="insights-hero-strip-action-coach"');
+ expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"');
});
it("uses the lg row layout when healthScore is supplied (smoke test on container class)", () => {
diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx
index d55bf0954..026757b66 100644
--- a/src/components/insights/__tests__/insight-status-card.test.tsx
+++ b/src/components/insights/__tests__/insight-status-card.test.tsx
@@ -100,10 +100,7 @@ describe("", () => {
// upstream is unchanged; the card simply never announced it, so the
// assessment reads as authoritative.
const html = render(
- ,
+ ,
);
expect(html).toContain("Pulse stayed inside the band.");
expect(html).not.toContain("Cached");
diff --git a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx
index 3987da8a5..6345c1c0c 100644
--- a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx
+++ b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx
@@ -35,8 +35,7 @@ describe(" — v1.4.31 memo + availability-stability", () => {
// React.memo'd components carry the `$$typeof` Memo tag plus a
// `compare` field. Probing the type tag is the cheapest way to
// pin the memo contract without spinning a full React tree.
- const tag = (InsightsTabStrip as unknown as { $$typeof: symbol })
- .$$typeof;
+ const tag = (InsightsTabStrip as unknown as { $$typeof: symbol }).$$typeof;
expect(typeof tag).toBe("symbol");
expect(tag.toString()).toContain("memo");
});
diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx
index 27980719a..051a4c8e3 100644
--- a/src/components/insights/__tests__/insights-tab-strip.test.tsx
+++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx
@@ -270,12 +270,7 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", ()
});
it("shows a pill when its slug is layout-visible AND has data", () => {
- const visibleTileIds = new Set([
- "overview",
- "pulse",
- "weight",
- "bmi",
- ]);
+ const visibleTileIds = new Set(["overview", "pulse", "weight", "bmi"]);
const html = render(
— saved-layout visibility gate (v1.15.14 W2)", ()
// when their data is present.
it("default layout shows every data-having pill (nav-pill regression guard)", () => {
const visibleTileIds = new Set(
- DEFAULT_INSIGHTS_LAYOUT.tiles
- .filter((t) => t.visible)
- .map((t) => t.id),
+ DEFAULT_INSIGHTS_LAYOUT.tiles.filter((t) => t.visible).map((t) => t.id),
);
const availability: InsightInputs = {
summaries: {
@@ -470,9 +463,7 @@ describe(" — module enable/disable gate (v1.18.0)", () => {
});
it("hides the Recovery pill when the recovery module is disabled, keeps it when enabled", () => {
- const disabled = render(
- ,
- );
+ const disabled = render( );
expect(disabled).not.toContain(">Recovery<");
expect(disabled).not.toContain('href="/insights/recovery"');
// Overview still anchors the strip.
diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx
index 92bb3d3e8..02183430a 100644
--- a/src/components/insights/__tests__/layout-coach-fab.test.tsx
+++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx
@@ -71,18 +71,20 @@ describe("", () => {
expect(html).not.toContain("lg:bottom-6");
});
- it("paints a dark glyph + offset focus ring on the gradient", () => {
+ it("paints a white glyph + offset focus ring on the gradient", () => {
mockPathname = "/";
const html = render(
,
);
- // White on the purple/pink gradient sat at ≈2.3:1; the background
- // token reads ≈6.5:1.
- expect(html).toContain("text-background");
- expect(html).not.toContain("text-white");
- // The offset ring draws a visible halo around the gradient circle.
+ // The glyph is decorative (aria-hidden) and the button carries its own
+ // accessible name, so the text-contrast rule does not gate it; the
+ // white glyph reads as a clean mark on the gradient. The button still
+ // sets text-background for any non-glyph text it might hold.
+ expect(html).toContain("text-white");
+ // The offset ring draws a visible halo around the gradient circle and
+ // provides the non-text UI-component contrast against the page.
expect(html).toContain("focus-visible:ring-offset-2");
expect(html).toContain("focus-visible:ring-offset-background");
});
diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx
index 6452cd205..45fbd7795 100644
--- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx
+++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx
@@ -4,10 +4,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
-import {
- SleepHypnogram,
- type SleepHypnogramSession,
-} from "../sleep-hypnogram";
+import { SleepHypnogram, type SleepHypnogramSession } from "../sleep-hypnogram";
/**
* v1.16.8 — hypnogram polish: the night's measuring source rides the
diff --git a/src/components/insights/__tests__/trend-annotation.test.tsx b/src/components/insights/__tests__/trend-annotation.test.tsx
index ac52fbf95..e281858f7 100644
--- a/src/components/insights/__tests__/trend-annotation.test.tsx
+++ b/src/components/insights/__tests__/trend-annotation.test.tsx
@@ -105,9 +105,7 @@ describe("", () => {
annotation="Mood ran steady across the back half of the week — three good days, one dip, then a recovery. The dip aligns with the Tuesday BP spike but the magnitude was small. Logged five mood entries in seven days, well above the 30-day median of three."
/>,
);
- expect(html).toMatch(
- /]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
- );
+ expect(html).toMatch(/
]*class="[^"]*line-clamp-3[^"]*"[^>]*>/);
});
it("clamps the empty-state caption to 3 lines too (same row contract)", () => {
diff --git a/src/components/insights/__tests__/trend-descriptor-caption.test.tsx b/src/components/insights/__tests__/trend-descriptor-caption.test.tsx
index 079b5afe1..9b0bdbd09 100644
--- a/src/components/insights/__tests__/trend-descriptor-caption.test.tsx
+++ b/src/components/insights/__tests__/trend-descriptor-caption.test.tsx
@@ -24,9 +24,7 @@ vi.mock("@/hooks/use-auth", () => ({
import { TrendDescriptorCaption } from "../trend-annotation";
-function seededClient(
- seed: (client: QueryClient) => void,
-): QueryClient {
+function seededClient(seed: (client: QueryClient) => void): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
@@ -34,7 +32,11 @@ function seededClient(
return client;
}
-function render(client: QueryClient, node: React.ReactNode, locale: "en" | "de" = "en") {
+function render(
+ client: QueryClient,
+ node: React.ReactNode,
+ locale: "en" | "de" = "en",
+) {
return renderToStaticMarkup(
{node}
diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx
index 34e1fe848..b69b00b9e 100644
--- a/src/components/insights/__tests__/trends-row.test.tsx
+++ b/src/components/insights/__tests__/trends-row.test.tsx
@@ -177,8 +177,7 @@ describe("", () => {
// flight. The loading flag now propagates to each annotation
// slot so the user reads a generating-state shimmer instead.
const html = render( );
- const pending =
- html.match(/data-slot="trend-annotation-pending"/g) ?? [];
+ const pending = html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
expect(html).not.toContain("Awaiting more data");
});
@@ -197,8 +196,7 @@ describe("", () => {
expect(html).not.toContain("stale BP");
expect(html).not.toContain("stale weight");
expect(html).not.toContain("stale mood");
- const pending =
- html.match(/data-slot="trend-annotation-pending"/g) ?? [];
+ const pending = html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
});
@@ -242,7 +240,9 @@ describe("", () => {
expect(html).toMatch(/data-metric="pulse"/);
expect(html).not.toMatch(/data-slot="trend-annotation-empty"/);
// The standard caption renders in its place.
- expect(html).toMatch(/data-slot="trends-row-caption"[^>]*data-metric="pulse"/);
+ expect(html).toMatch(
+ /data-slot="trends-row-caption"[^>]*data-metric="pulse"/,
+ );
expect(html).toContain("Resting pulse over the last 30 days.");
});
@@ -259,8 +259,7 @@ describe("", () => {
expect(cards.length).toBe(3);
// None of these additive metrics carries an advisor annotation, so
// all three captions come through the standard description slot.
- const captions =
- html.match(/data-slot="trends-row-caption"/g) ?? [];
+ const captions = html.match(/data-slot="trends-row-caption"/g) ?? [];
expect(captions.length).toBe(3);
expect(html).toContain("Heart-rate variability over the last 30 days.");
expect(html).toContain("Daily step count over the last 30 days.");
diff --git a/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts b/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts
index 7828eabad..fdc2ddd1e 100644
--- a/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts
+++ b/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts
@@ -14,9 +14,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
*/
vi.mock("@/lib/ai/schema", async () => {
- const actual = await vi.importActual(
- "@/lib/ai/schema",
- );
+ const actual =
+ await vi.importActual("@/lib/ai/schema");
return actual;
});
@@ -84,10 +83,8 @@ describe("fetchAdvisor abort timeout — v1.4.31", () => {
// in-session despite the 1 h staleTime + focus-refetch-off defaults.
describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
it("returns the interval while revalidating is true and under the cap", async () => {
- const {
- nextAdvisorPollInterval,
- ADVISOR_REVALIDATE_POLL_MS,
- } = await import("../use-insights-advisor");
+ const { nextAdvisorPollInterval, ADVISOR_REVALIDATE_POLL_MS } =
+ await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
expect(nextAdvisorPollInterval(true, 5)).toBe(ADVISOR_REVALIDATE_POLL_MS);
});
@@ -99,10 +96,8 @@ describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
});
it("stops at the attempt ceiling even while still revalidating", async () => {
- const {
- nextAdvisorPollInterval,
- ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
- } = await import("../use-insights-advisor");
+ const { nextAdvisorPollInterval, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS } =
+ await import("../use-insights-advisor");
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS),
).toBe(false);
diff --git a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx
index d46c0e857..da352e3ac 100644
--- a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx
@@ -58,19 +58,34 @@ describe(" — rail-tray strip", () => {
expect(html).not.toContain('data-slot="coach-drawer-sources"');
});
- it("mounts an inline history column on lg+ when historyRail is passed (page surface)", () => {
+ it("mounts a collapsible inline history column on lg+ when historyRail is passed (page surface)", () => {
+ // v1.18.7 W-coach C-UI — the inline rail is now collapsible and
+ // collapsed by default; the page surface passes `onToggleHistory`.
+ // The sub-lg tray trigger keeps `lg:hidden` (the bottom tray covers
+ // small viewports); a separate lg+ toggle (`coach-history-toggle`)
+ // shows the rail on desktop.
const html = render(
- history
} />,
+ history }
+ onToggleHistory={() => undefined}
+ />,
);
expect(html).toMatch(
/]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).toContain("history");
- // The strip button collapses on lg+ where the inline column shows.
+ // Sub-lg tray trigger collapses on lg+ (the inline rail covers it).
const historyTrigger = html.match(
/]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger?.[0]).toContain("lg:hidden");
+ // lg+ inline-rail toggle is present and labelled as a show control.
+ const railToggle = html.match(
+ /]*data-slot="coach-history-toggle"[^>]*>/,
+ );
+ expect(railToggle).not.toBeNull();
+ expect(railToggle?.[0]).toContain('aria-expanded="false"');
});
it("renders the message thread + composer slots without a disclaimer line", () => {
diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
index 5fb65b000..ea73deed7 100644
--- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
@@ -11,21 +11,31 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("", () => {
- it("mounts the textarea + send button slots (mic dropped in W5)", () => {
+ it("mounts the textarea + send button slots", () => {
// v1.4.22 B4 — the disclaimer moved to the sources rail; the
// composer no longer renders its own paragraph below the input.
- // v1.4.25 W5 — the non-functional mic icon was removed.
const html = render(
{}} onSubmit={() => {}} />,
);
expect(html).toContain('data-slot="coach-input"');
expect(html).toContain('data-slot="coach-input-textarea"');
expect(html).toContain('data-slot="coach-input-send"');
- expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).not.toContain('data-slot="coach-input-disclaimer"');
expect(html).not.toContain("Coach replies are generated");
});
+ it("does not render the mic in SSR markup (Web Speech API is client-only)", () => {
+ // v1.18.7 W-coach C-UI — voice dictation returns, but the mic only
+ // mounts client-side once `SpeechRecognition` is confirmed present
+ // (a post-hydration effect flips support on). The SSR / unsupported
+ // path renders no button so the affordance is never a dead control.
+ const html = render(
+ {}} onSubmit={() => {}} />,
+ );
+ expect(html).not.toContain('data-slot="coach-input-mic"');
+ expect(html).not.toContain("Voice input arrives with the iOS app");
+ });
+
it("renders the localised placeholder without the retired shortcut hint", () => {
// v1.16.1 — the Enter/Shift+Enter hint (prose footer, later an
// Info-popover) is gone entirely; the behaviour itself is
@@ -48,18 +58,6 @@ describe("", () => {
expect(html).toContain("Frag mich etwas zu deinen Daten");
});
- it("no longer renders a mic button (W5 removed the placeholder)", () => {
- // v1.4.25 W5 — the mic icon used to be rendered + disabled with a
- // "voice arrives with iOS" tooltip. The maintainer flagged it as a click-
- // trap: nothing happened on tap. The composer now drops the icon
- // entirely; voice input remains a v1.5 iOS feature.
- const html = render(
- {}} onSubmit={() => {}} />,
- );
- expect(html).not.toMatch(/data-slot="coach-input-mic"/);
- expect(html).not.toContain("Voice input arrives with the iOS app");
- });
-
it("disables the send button when the value is empty", () => {
const html = render(
{}} onSubmit={() => {}} />,
@@ -177,6 +175,39 @@ describe("", () => {
expect(html).toContain("Stopp");
});
+ it("sizes the send control to the 44px tap-target floor on phones", () => {
+ // v1.18.7 — the composer controls (send / stop / mic) must clear the
+ // 44px WCAG 2.5.5 floor on phones and condense at `sm:` upward. Guard
+ // the `size-11 sm:size-9` pattern so a future restyle can't quietly
+ // drop the send button back to 36px.
+ const html = render(
+ {}} onSubmit={() => {}} />,
+ );
+ const sendTag = html.match(
+ /]*data-slot="coach-input-send"[^>]*>/,
+ );
+ expect(sendTag?.[0]).toMatch(/\bsize-11\b/);
+ expect(sendTag?.[0]).toMatch(/\bsm:size-9\b/);
+ });
+
+ it("sizes the stop control to the 44px tap-target floor on phones", () => {
+ const html = render(
+ {}}
+ onSubmit={() => {}}
+ onCancel={() => {}}
+ disabled
+ isStreaming
+ />,
+ );
+ const stopTag = html.match(
+ /]*data-slot="coach-input-stop"[^>]*>/,
+ );
+ expect(stopTag?.[0]).toMatch(/\bsize-11\b/);
+ expect(stopTag?.[0]).toMatch(/\bsm:size-9\b/);
+ });
+
it("invokes onChange when the parent passes a controlled handler", () => {
// SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly.
diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
index e09507a2f..cae800634 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -498,7 +498,10 @@ describe("", () => {
});
it("localises the remember control under 'de'", () => {
- const html = render( , "de");
+ const html = render(
+ ,
+ "de",
+ );
expect(html).toContain('data-slot="coach-remember-message"');
expect(html).toContain("Merken");
});
@@ -707,7 +710,10 @@ describe("errorCodeToI18nKey", () => {
* messages, the optimistic user bubble, and the thread tail.
*/
describe("placeInterleaved", () => {
- const item = (key: string, anchorAnswer: string | null): InterleavedThreadItem => ({
+ const item = (
+ key: string,
+ anchorAnswer: string | null,
+ ): InterleavedThreadItem => ({
key,
anchorAnswer,
node: ,
diff --git a/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx b/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx
index 6491e7586..3c1d17d45 100644
--- a/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx
@@ -98,11 +98,7 @@ describe("", () => {
// that contract so the carve-out stays gated on `historyOpen` /
// `sourcesOpen` and never paints orphan overlays.
const html = render(
- ,
+ ,
);
expect(html).not.toContain('data-slot="coach-drawer-history-tray"');
expect(html).not.toContain('data-slot="coach-drawer-sources-tray"');
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index 5b229441f..a6f785ab6 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -126,6 +126,10 @@ export function CoachConversation({
>(null);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
+ // v1.18.7 (W-coach C-UI) — page surface only: the inline conversation
+ // rail is collapsed by default for a calm, prompt-first surface. The
+ // rail-tray-strip toggle (lg+) opens it; the rail heading closes it.
+ const [historyRailOpen, setHistoryRailOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
// v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet
// offer to fold a clarifying-question answer back into the
@@ -261,14 +265,10 @@ export function CoachConversation({
current={item.current}
actionsDisabled={send.isStreaming || dismissQuestions.isPending}
onSkip={
- item.current
- ? () => dispatchGuided({ type: "SKIP" })
- : undefined
+ item.current ? () => dispatchGuided({ type: "SKIP" }) : undefined
}
onLater={
- item.current
- ? () => dispatchGuided({ type: "EXIT" })
- : undefined
+ item.current ? () => dispatchGuided({ type: "EXIT" }) : undefined
}
onDismissRemaining={
item.current
@@ -359,6 +359,12 @@ export function CoachConversation({
setHistoryRailOpen((open) => !open)
+ : undefined
+ }
historyRail={
surface === "page" ? (
void;
onHistoryClick: () => void;
onOpenSourcesTray: () => void;
}
@@ -46,49 +68,78 @@ export function CoachDrawerBody({
thread,
composer,
historyRail,
+ historyOpen = false,
+ onToggleHistory,
onHistoryClick,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
const { t } = useTranslations();
+ // The page surface (inline `historyRail`) carries the collapsible
+ // grid; without a rail the body is a single column (drawer surface).
+ const railExpanded = !!historyRail && historyOpen;
return (
- {/* Inline history column — page surface, lg+ only. */}
+ {/* Inline history column — page surface, lg+ only, collapsible. */}
{historyRail && (
- {/* v1.18.1 (W-COACH-UI C4) — the rail heading and the centre
- column's rail-tray strip share one fixed `h-14` header band
- with identical `border-b`, so the two dividers land on the
- same horizontal line across the columns (the asymmetry the
- maintainer flagged: a `p-3` text heading vs a `py-2` button
- strip put the lines at different heights). */}
-
- {t("insights.coach.historyTitle")}
-
- {/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here
- was the broken left scrollbar: `` is itself a
- `flex h-full flex-col` with its OWN `overflow-y-auto` list,
- so this nested it inside a second scroll container. The
- outer container scrolled the whole rail (search field
- included) against an `h-full` child whose height resolved to
- its content, leaving the inner list's `flex-1 min-h-0`
- without a bounded track — the scrollbar rendered against an
- indeterminate height and read as half-broken. Drop the outer
- scroll: the rail keeps a fixed search header and its single
- internal list-scroll is the only scroll region. */}
+ {/* v1.18.7 — the rail heading shares the `h-14` band + `border-b`
+ with the centre column's rail-tray strip so the two dividers
+ land on one horizontal line, and carries the collapse
+ control on its trailing edge. */}
+
+
+ {t("insights.coach.historyTitle")}
+
+ {onToggleHistory && (
+
+
+
+ )}
+
+ {/* v1.18.6 (CCH-01) — `` is itself a `flex h-full
+ flex-col` with its OWN `overflow-y-auto` list, so a single
+ bounded flex track here is all it needs; do not nest a
+ second scroll container. */}
{historyRail}
)}
@@ -96,41 +147,72 @@ export function CoachDrawerBody({
{/* Centre — message thread. */}
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the buttons
sit at a 44 px tap target and never overlay the first
message bubble.
- v1.18.1 (W-COACH-UI C4) — fixed `h-14` so the strip's
- bottom border aligns with the history rail heading divider
- in the adjacent column (one continuous line, no restless
- two-height split). */}
+ v1.18.7 — fixed `h-14` so the strip's bottom border aligns
+ with the history rail heading divider in the adjacent column.
+ On the page surface the left control is the rail TOGGLE
+ (clean show/hide) on lg+, falling back to the tray trigger
+ below lg; the drawer keeps the plain conversations button. */}
` (px-4) all share the same horizontal divider
- // line at sm+ rather than stepping in/out by a pixel.
className="border-border/70 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-3 sm:px-4"
>
-
-
- {t("insights.coach.historyTitle")}
-
+ {/* Left control(s). On the page surface (inline rail) the
+ affordance forks by breakpoint: below lg a tray trigger
+ opens the bottom sheet; on lg+ a clean toggle shows/hides
+ the inline rail (and the lg+ toggle hides once the rail is
+ expanded, since the rail heading then owns the close
+ control). Without an inline rail (drawer surface) the
+ single button hands off via `onHistoryClick`. */}
+ {historyRail && onToggleHistory ? (
+ <>
+ {/* Sub-lg: open the bottom tray. */}
+
+
+ {t("insights.coach.historyTitle")}
+
+ {/* lg+: toggle the inline rail (hidden while expanded). */}
+
+
+ {t("insights.coach.historyTitle")}
+
+ >
+ ) : (
+
+
+ {t("insights.coach.historyTitle")}
+
+ )}
{/* Sources toggle — the rail is hidden by default on every
viewport and opens as the right-edge tray. */}
{/* v1.18.6.1 — the thread wrapper must be a flex column itself so the
- `` scroll region resolves a bounded height. The
- wrapper used to be a plain block, so the thread's `h-full` resolved
- against an auto-height parent and the scroll container grew with its
- content instead of scrolling — pushing the composer off-screen and
- leaving a stray scrollbar on the page. `flex min-h-0 flex-1 flex-col`
- gives the thread a definite track to scroll within. */}
+ `` scroll region resolves a bounded height. */}
{thread}
{/* Composer pinned to the bottom. */}
void;
+ stop: () => void;
+ abort: () => void;
+ onresult: ((event: SpeechRecognitionResultEventLike) => void) | null;
+ onerror: (() => void) | null;
+ onend: (() => void) | null;
+};
+type SpeechRecognitionResultEventLike = {
+ resultIndex: number;
+ results: ArrayLike
& { isFinal: boolean }>;
+};
+type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
+
+function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null {
+ if (typeof window === "undefined") return null;
+ const w = window as unknown as {
+ SpeechRecognition?: SpeechRecognitionConstructor;
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
+ };
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
+}
+
+// v1.18.7 — `useSyncExternalStore` resolves Web Speech API support
+// without a setState-in-effect: the server snapshot is `false` (no
+// mic at SSR) and the client snapshot reflects whether the constructor
+// exists. The store never changes after hydration, so the subscribe
+// callback is a no-op.
+const noopSubscribe = () => () => {};
+function useVoiceSupported(): boolean {
+ return useSyncExternalStore(
+ noopSubscribe,
+ () => getSpeechRecognitionCtor() !== null,
+ () => false,
+ );
+}
+
export function CoachInput({
value,
onChange,
@@ -113,7 +168,7 @@ export function CoachInput({
autoFocusOnOpen = false,
placeholder,
}: CoachInputProps) {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
const textareaRef = useRef(null);
// v1.4.27 MB3 / CF-30 — focus the textarea once on mount when the
@@ -162,6 +217,80 @@ export function CoachInput({
el.style.height = `${height}px`;
}, [value]);
+ // v1.18.7 — voice dictation. The mic control only mounts once the
+ // client confirms the Web Speech API exists (resolved through
+ // `useSyncExternalStore`, server snapshot `false`), so SSR +
+ // unsupported browsers render no button at all — never a dead
+ // affordance. `onChange` / `value` are mirrored into refs (in an
+ // effect, never during render) so the long-lived recognition handlers
+ // always read the freshest composer state without re-subscribing.
+ const voiceSupported = useVoiceSupported();
+ const [listening, setListening] = useState(false);
+ const recognitionRef = useRef(null);
+ const onChangeRef = useRef(onChange);
+ const valueRef = useRef(value);
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ valueRef.current = value;
+ });
+
+ // Tear the recogniser down on unmount so a live mic never outlives
+ // the composer (e.g. the drawer closing mid-dictation).
+ useEffect(() => {
+ return () => {
+ recognitionRef.current?.abort();
+ recognitionRef.current = null;
+ };
+ }, []);
+
+ const stopDictation = useCallback(() => {
+ recognitionRef.current?.stop();
+ }, []);
+
+ const startDictation = useCallback(() => {
+ const Ctor = getSpeechRecognitionCtor();
+ if (!Ctor) return;
+ const recognition = new Ctor();
+ recognition.lang = locale === "en" ? "en-US" : locale;
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ // Anchor every transcript onto the text present when dictation
+ // began so interim results replace cleanly rather than stacking.
+ const base = valueRef.current;
+ recognition.onresult = (event) => {
+ let finalText = "";
+ let interimText = "";
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
+ const result = event.results[i];
+ const transcript = result[0]?.transcript ?? "";
+ if (result.isFinal) finalText += transcript;
+ else interimText += transcript;
+ }
+ const spoken = (finalText + interimText).trim();
+ if (!spoken) return;
+ const joiner = base && !base.endsWith(" ") ? " " : "";
+ onChangeRef.current(`${base}${joiner}${spoken}`);
+ };
+ recognition.onerror = () => setListening(false);
+ recognition.onend = () => {
+ setListening(false);
+ recognitionRef.current = null;
+ };
+ recognitionRef.current = recognition;
+ try {
+ recognition.start();
+ setListening(true);
+ } catch {
+ // start() throws if already running; treat as a no-op.
+ setListening(false);
+ }
+ }, [locale]);
+
+ const toggleDictation = useCallback(() => {
+ if (listening) stopDictation();
+ else startDictation();
+ }, [listening, startDictation, stopDictation]);
+
const canSubmit = !disabled && value.trim().length > 0;
const handleKeyDown = useCallback(
@@ -188,20 +317,54 @@ export function CoachInput({
onSubmit={handleFormSubmit}
className="flex flex-col"
>
- {/* v1.16.1 — modern chat-app composer: a single row with the
- textarea and the send control on the same baseline. The
- textarea starts at one line and auto-grows; `items-end` keeps
- the button pinned to the input's last line as it grows. The
- old footer row (Info-popover hint + right-aligned button) is
- gone — Enter still sends and Shift+Enter still inserts a
- newline, the affordance just no longer needs explaining. */}
+ {/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded
+ field with the textarea flanked by a dictation mic (left) and
+ the send / stop control (right) on the same baseline.
+ `items-end` keeps the controls pinned to the input's last line
+ as it grows. Enter sends, Shift+Enter inserts a newline. */}
+ {voiceSupported && (
+
+
+
+ )}
@@ -251,7 +418,7 @@ export function CoachInput({
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
- className="size-9 shrink-0"
+ className="size-11 shrink-0 rounded-xl sm:size-9"
>
@@ -263,7 +430,7 @@ export function CoachInput({
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
- className="size-9 shrink-0"
+ className="size-11 shrink-0 rounded-xl sm:size-9"
>
{isStreaming ? (
current && "bg-muted-foreground/25",
)}
/>
@@ -203,10 +204,7 @@ export function GuidedSummaryBubble({
const { t } = useTranslations();
return (
-
+
{t("insights.coach.guided.summaryLink")}
diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx
index 0d1e86e59..0b7133e6c 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
+import { COACH_SCROLLBAR } from "./message-thread";
import { useCoachConversations, useDeleteCoachConversation } from "./use-coach";
/**
@@ -115,7 +116,11 @@ export function HistoryRail({
{isLoading && conversations.length === 0 ? (
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index d5fd5e4b5..65d26a33b 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -135,6 +135,28 @@ export function placeInterleaved(
return { before, beforeOptimistic, tail };
}
+/**
+ * v1.18.7 — shared thin/rounded/subtle scrollbar styling for the Coach
+ * scroll regions (the message thread + the history list). Kept as a
+ * Tailwind-arbitrary class string so the styling is component-scoped —
+ * the parallel agent owns `globals.css` and we must not touch it.
+ *
+ * Firefox: `scrollbar-width: thin` + a tinted thumb on a transparent
+ * track. WebKit: an 8 px overlay-style thumb with a fully rounded
+ * radius and no arrow buttons, brightening on hover. The Dracula purple
+ * is mixed down so the bar reads as a hairline accent, not a hard edge.
+ */
+export const COACH_SCROLLBAR = cn(
+ "[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_30%,transparent)_transparent] [scrollbar-width:thin]",
+ "[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2",
+ "[&::-webkit-scrollbar-track]:bg-transparent",
+ "[&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-button]:size-0",
+ "[&::-webkit-scrollbar-thumb]:rounded-full",
+ "[&::-webkit-scrollbar-thumb]:border-[3px] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-clip-content",
+ "[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_30%,transparent)]",
+ "hover:[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_45%,transparent)]",
+);
+
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= slack;
}
@@ -365,15 +387,22 @@ export function MessageThread({
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
- "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
- // v1.18.6 (CCH-01) — on the now full-width page surface a thread
- // stretched edge-to-edge sprawled the prose to an unreadable
- // measure. Centre the content on a comfortable max width (Claude-
- // like) via an `mx-auto` inner gutter; the scrollbar still rides
- // the surface edge. The drawer surface is already narrow, so the
- // cap only bites on the wide page surface.
- "[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl",
+ // v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
+ // bottom breathing room so the conversation reads like a document,
+ // not a chat log packed against the chrome.
+ "flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
+ // v1.18.6 (CCH-01) / v1.18.7 — on the wide page surface an edge-to-
+ // edge thread sprawled the prose past a readable measure. Centre the
+ // content on a narrower, calmer Claude/ChatGPT-like column
+ // (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
+ // rides the surface edge. The drawer surface is already narrow, so
+ // the cap only bites on the wide page surface.
+ "[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
+ // v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
+ // component-scoped via Tailwind arbitrary variants so globals.css
+ // stays untouched. Replaces the default boxy/angular track.
+ COACH_SCROLLBAR,
)}
>
{messages.map((m) => {
@@ -418,7 +447,15 @@ export function MessageThread({
// assistant prose announce as tokens land. aria-relevant=text
// limits announcements to the streamed content; additions
// covers the bubble-mount edge case.
-
+
{label}
- {[0, 1, 2].map((i) => (
-
- ))}
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ {label}
+
);
}
diff --git a/src/components/insights/coach-panel/self-context-adopt-offer.tsx b/src/components/insights/coach-panel/self-context-adopt-offer.tsx
index 7378589a9..b5f6632a8 100644
--- a/src/components/insights/coach-panel/self-context-adopt-offer.tsx
+++ b/src/components/insights/coach-panel/self-context-adopt-offer.tsx
@@ -51,7 +51,10 @@ export function SelfContextAdoptOffer({
const adopt = useMutation({
mutationFn: async () => {
- return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { question, answer });
+ return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
+ question,
+ answer,
+ });
},
onSuccess: (data) => {
setSettled(data.adopted ? "adopted" : "duplicate");
diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts
index 3aa7058c9..11c462516 100644
--- a/src/components/insights/coach-panel/use-coach.ts
+++ b/src/components/insights/coach-panel/use-coach.ts
@@ -13,6 +13,7 @@ import type {
CoachSuggestion,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.20 phase B2b — TanStack Query + SSE client for the AI Coach
@@ -32,8 +33,8 @@ import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
*/
const QUERY_KEYS = {
- list: () => ["coachConversations"] as const,
- one: (id: string) => ["coachConversation", id] as const,
+ list: () => queryKeys.coachConversations(),
+ one: (id: string) => queryKeys.coachConversation(id),
};
/**
@@ -74,7 +75,7 @@ export function useCoachConversation(id: string | null) {
// never fires when `id` is null. Key directly on `id` (TanStack
// Query accepts `null` in the array) — the previous "null"
// placeholder branch was structurally equivalent dead code.
- queryKey: ["coachConversation", id] as const,
+ queryKey: queryKeys.coachConversation(id),
queryFn: async (): Promise => {
if (!id) throw new Error("missing id");
return apiGet(`/api/insights/chat/${id}`, {
@@ -299,9 +300,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}, []);
const send = useCallback(
- async (
- params: SendCoachMessageParams,
- ): Promise => {
+ async (params: SendCoachMessageParams): Promise => {
// Cancel any prior in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index ab156cc2e..7af72f65f 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -22,6 +22,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
+import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations } from "@/lib/i18n/context";
@@ -31,6 +32,7 @@ import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
DailyBriefingKeyFinding,
+ DailyBriefingSignal,
} from "@/lib/ai/schema";
/**
@@ -178,68 +180,112 @@ function DeltaBadge({
);
}
-function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
- const Icon = METRIC_ICON[finding.sourceMetric];
- const href = METRIC_HREF[finding.sourceMetric];
- // v1.4.27 MB7 / CF-68 — when the briefing's `sourceMetric` maps to
- // a routed sub-page, wrap the entire row in a ` ` so the
- // whole card is tappable on mobile instead of the user having to
- // hit the small headline text. Hover + focus paint a subtle bg
- // shift; metrics that don't have a sub-page yet render the
- // original static row.
+/**
+ * v1.18.7 — shared row layout for the key-finding row and the signal-of-the-day
+ * row. Both render the same tone bar + metric icon + headline/body/delta and
+ * the same "wrap in a ` ` when the metric owns a sub-page" behaviour; the
+ * two only differ in their `data-slot` and the body copy they pass.
+ */
+function BriefingRow({
+ sourceMetric,
+ tone,
+ headline,
+ body,
+ delta,
+ dataSlot,
+}: {
+ sourceMetric: DailyBriefingKeyFinding["sourceMetric"];
+ tone: DailyBriefingKeyFinding["tone"];
+ headline: string;
+ body: string;
+ delta: string | null;
+ dataSlot: "daily-briefing-finding" | "daily-briefing-signal";
+}) {
+ const Icon = METRIC_ICON[sourceMetric];
+ const href = METRIC_HREF[sourceMetric];
const rowContent = (
<>
- {stripChartTokens(finding.headline)}
+ {stripChartTokens(headline)}
-
+
- {stripChartTokens(finding.detail)}
+ {stripChartTokens(body)}
>
);
if (href) {
return (
-
- {rowContent}
-
+ {rowContent}
+
);
}
return (
-
{rowContent}
-
+
+ );
+}
+
+// v1.4.27 MB7 / CF-68 — the whole row is tappable on mobile (wrapped in a
+// ` ` by `BriefingRow`) so the user need not hit the small headline text.
+function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
+ return (
+
+ );
+}
+
+/**
+ * v1.18.7 — "Signals of the day" row. The present-focused lead of the
+ * briefing: a NOW-anchored headline + a concrete nudge the user can act on.
+ * Shares `BriefingRow` with the key-finding row.
+ */
+function SignalRow({ signal }: { signal: DailyBriefingSignal }) {
+ return (
+
);
}
@@ -317,8 +363,29 @@ export function DailyBriefing({
the same `briefing.paragraph` text directly above this
card, so the user used to read the same string twice
within 200 px. The card now opens straight on the
- structured key-findings list, which is the part the
- hero subtitle cannot surface. */}
+ structured signals + key-findings list, which is the part
+ the hero subtitle cannot surface. */}
+ {/* v1.18.7 — "Signals of the day": the present-focused lead.
+ Renders above the longer-horizon key-findings list when the
+ briefing carries fresh now-signals. */}
+ {briefing.signalsOfDay && briefing.signalsOfDay.length > 0 && (
+
+
+ {t("insights.dailyBriefing.signalsTitle")}
+
+
+ {briefing.signalsOfDay.map((signal, index) => (
+
+ ))}
+
+
+ )}
{briefing.keyFindings.length > 0 && (
", () => {
it("meets the 44px hit-target floor on the trigger", () => {
const html = render(
- ,
+ ,
);
expect(html).toContain("min-h-11");
expect(html).toContain("min-w-11");
diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx
index e5cd9ba76..7329346b2 100644
--- a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx
+++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx
@@ -1,7 +1,10 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
-import { ScoreAnatomyView, type AnatomyContributor } from "../score-anatomy-view";
+import {
+ ScoreAnatomyView,
+ type AnatomyContributor,
+} from "../score-anatomy-view";
import { I18nProvider } from "@/lib/i18n/context";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
index 5b20d2efd..479f26fb8 100644
--- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
+++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
@@ -23,7 +23,12 @@ function ok(value: Record): Resp {
metric: "X",
status: "ok",
value,
- coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 1,
+ historyDays: 30,
+ missing: [],
+ },
confidence: { score: 90, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
reason: null,
@@ -71,8 +76,19 @@ beforeEach(() => vi.clearAllMocks());
describe("", () => {
it("renders an available baseline vital as an ok tile", () => {
const batch = mockBatch((token) => {
- if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") {
- return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 });
+ if (
+ token.metric === "VITALS_BASELINE" &&
+ token.type === "RESTING_HEART_RATE"
+ ) {
+ return ok({
+ type: "RESTING_HEART_RATE",
+ center: 55,
+ low: 48,
+ high: 62,
+ spread: 7,
+ sampleDays: 30,
+ k: 3,
+ });
}
return insufficient("no_readings_in_window");
});
@@ -96,7 +112,9 @@ describe("", () => {
});
it("renders a skeleton row (no tiles, no heading strand) while loading", () => {
- const batch = mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true });
+ const batch = mockBatch(() => insufficient("no_readings_in_window"), {
+ isLoading: true,
+ });
const html = render( );
// Heading + grid present so the section reserves its final height, with a
// busy/live region and skeleton placeholders — but no real tiles yet.
@@ -108,7 +126,9 @@ describe("", () => {
});
it("shows an inline error + retry when the batch fails", () => {
- const batch = mockBatch(() => insufficient("no_readings_in_window"), { isError: true });
+ const batch = mockBatch(() => insufficient("no_readings_in_window"), {
+ isError: true,
+ });
const html = render( );
// The surface must not silently vanish — a compact error + a Retry button.
expect(html).toContain('data-slot="vitals-dashboard-error"');
@@ -135,7 +155,14 @@ describe("", () => {
it("renders the fitness-age tile with its age framing", () => {
const batch = mockBatch((token) => {
if (token.metric === "FITNESS_AGE") {
- return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 });
+ return ok({
+ vo2Max: 46,
+ band: "green",
+ fitnessAgeDeltaYears: -6,
+ referenceBand: { low: 35, high: 45 },
+ trendDelta: 2,
+ readingCount: 4,
+ });
}
return insufficient("no_readings_in_window");
});
@@ -147,7 +174,13 @@ describe("", () => {
it("renders the BMI tile with its WHO category", () => {
const batch = mockBatch((token) => {
if (token.metric === "BMI") {
- return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 });
+ return ok({
+ bmi: 24.7,
+ category: "normal",
+ band: "green",
+ weightKg: 80,
+ heightCm: 180,
+ });
}
return insufficient("no_readings_in_window");
});
@@ -210,7 +243,16 @@ describe("", () => {
it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => {
const batch = mockBatch((token) => {
if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") {
- return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] });
+ return ok({
+ type: "STAIR_ASCENT_SPEED",
+ center: 0.4,
+ low: 0.3,
+ high: 0.5,
+ spread: 0.05,
+ sampleDays: 21,
+ k: 3,
+ series: [0.38, 0.4, 0.42],
+ });
}
return insufficient("no_readings_in_window");
});
diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx
index 2b0db86ab..b8c04c856 100644
--- a/src/components/insights/derived/__tests__/wellness-scores.test.tsx
+++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx
@@ -21,7 +21,12 @@ function ok(value: Record): Resp {
metric: "X",
status: "ok",
value,
- coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 1,
+ historyDays: 30,
+ missing: [],
+ },
confidence: { score: 90, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" },
reason: null,
@@ -33,16 +38,21 @@ function absent(): Resp {
metric: "X",
status: "insufficient",
value: null,
- coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] },
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 0,
+ historyDays: 0,
+ missing: [],
+ },
confidence: null,
provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" },
reason: "no_readings_in_window",
};
}
-function readFrom(map: Record): (
- token: DerivedBatchToken,
-) => DerivedMetricResponse | null {
+function readFrom(
+ map: Record,
+): (token: DerivedBatchToken) => DerivedMetricResponse | null {
return (token: DerivedBatchToken) =>
(map[token.metric] ?? absent()) as unknown as DerivedMetricResponse;
}
diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx
index d1154dc70..4432f8eab 100644
--- a/src/components/insights/derived/composite-score-anatomy.tsx
+++ b/src/components/insights/derived/composite-score-anatomy.tsx
@@ -99,11 +99,7 @@ export function CompositeScoreAnatomy({
// the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no
// cache row yet) renders nothing.
let anchorLine: string | null = null;
- if (
- metric === "STRAIN_SCORE" &&
- data?.status === "ok" &&
- data.value
- ) {
+ if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) {
const anchor = (data.value as WellnessScoreValue).anchor;
if (anchor === "personal") {
anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal");
@@ -238,10 +234,7 @@ export function CompositeScoreAnatomy({
/** Localised title per metric — composites use their existing copy keys;
* the persisted scores reuse the wellness-strip labels. */
-function titleFor(
- metric: AnatomyMetricId,
- t: (key: string) => string,
-): string {
+function titleFor(metric: AnatomyMetricId, t: (key: string) => string): string {
switch (metric) {
case "SLEEP_SCORE":
case "READINESS":
diff --git a/src/components/insights/derived/coverage-meter.tsx b/src/components/insights/derived/coverage-meter.tsx
index 500913833..872abb2e5 100644
--- a/src/components/insights/derived/coverage-meter.tsx
+++ b/src/components/insights/derived/coverage-meter.tsx
@@ -123,7 +123,7 @@ export function CoverageMeter({
// 44px hit target via padding while the dots stay optically
// small; negative margin collapses the row back so the meter
// doesn't inflate its host row.
- "inline-flex min-h-11 items-center gap-2 rounded -my-3 px-1 py-3",
+ "-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
className,
)}
diff --git a/src/components/insights/derived/provenance-explainer.tsx b/src/components/insights/derived/provenance-explainer.tsx
index 6112c9211..c3058e167 100644
--- a/src/components/insights/derived/provenance-explainer.tsx
+++ b/src/components/insights/derived/provenance-explainer.tsx
@@ -162,7 +162,7 @@ export function ProvenanceExplainer({
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
- "-my-3 -mx-2",
+ "-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
diff --git a/src/components/insights/derived/ring-hues.ts b/src/components/insights/derived/ring-hues.ts
index cbbbd48e2..0bba33290 100644
--- a/src/components/insights/derived/ring-hues.ts
+++ b/src/components/insights/derived/ring-hues.ts
@@ -1,22 +1,19 @@
/**
* v1.14.0 — per-metric ring hues for the wellness-score strip.
*
- * The premium signature is a SINGLE-hue, TWO-stop gradient per ring
- * (dark → light tones of the *same* colour), never a multi-hue rainbow.
- * Each metric leans toward one intentional, distinct hue (Oura's per-metric
- * move): readiness green, recovery cyan, sleep purple, stress orange,
- * strain pink — five clearly different hues so the strip never reads as
- * "a wash of green/cyan". In the dark theme every `to` stop sits exactly
- * on the matching Dracula palette colour.
+ * Each metric leans toward one intentional, distinct hue: readiness green,
+ * recovery cyan, sleep purple, stress orange, strain pink — five clearly
+ * different hues so the strip never reads as "a wash of green/cyan".
*
- * The actual colours live as CSS custom properties in `globals.css`
+ * v1.18.7 — CALM. The arc was a dark→neon two-stop gradient (the `to` stop
+ * sat on the raw Dracula palette colour) plus a bloom + sheen, which glowed
+ * against the flat charts. The two stops are now MUTED and sit a hair apart,
+ * so each arc reads as a near-flat single tone — quiet as a chart series. The
+ * actual colours live as CSS custom properties in `globals.css`
* (`--ring--from` / `--ring--to` / `--tile-`) with a Dracula
* dark set on `:root` and an Alucard light set on `:root.light`, so each hue
- * is HAND-TUNED per theme — deep + saturated enough to hold contrast on the
- * white Alucard card, vivid on the dark Dracula card. (The v1.13.x version
- * mixed through ambiguous semantic tokens — `--info` == `--dracula-cyan` —
- * which collapsed three of the five rings to cyan in the dark theme and
- * washed recovery/strain out on white. Dedicated per-theme vars fix that.)
+ * is HAND-TUNED per theme — deep enough to hold contrast on the white Alucard
+ * card without going neon on the dark Dracula card.
*
* The band semantic is never carried by the hue: it still rides `data-band`
* + the band word + the aria-label, so a red-band readiness still reads "low"
@@ -29,11 +26,11 @@ import type { ScoreBand } from "./band-tokens";
export type RingHue = "readiness" | "sleep" | "recovery" | "stress" | "strain";
/**
- * Single-hue two-stop gradient `[from, to]` (dark → light) per metric, plus a
- * band fallback for the anatomy detail view (which passes no `hue`, so the
- * ring keeps its green/yellow/red band semantics there). Values are CSS var
- * references resolved per theme — consumed straight by the SVG
- * `` stops.
+ * Near-flat single-hue arc `[from, to]` per metric, plus a band fallback for
+ * the anatomy detail view (which passes no `hue`, so the ring keeps its
+ * green/yellow/red band semantics there). The two stops are a hair apart for a
+ * whisper of depth, not a neon sweep. Values are CSS var references resolved
+ * per theme — consumed straight by the SVG `` stops.
*/
export const RING_GRADIENT: Record = {
readiness: ["var(--ring-readiness-from)", "var(--ring-readiness-to)"],
@@ -47,21 +44,6 @@ export const RING_GRADIENT: Record = {
red: ["var(--ring-red-from)", "var(--ring-red-to)"],
};
-/**
- * The `to` (light) stop per key — used as the ring's glow colour
- * (`--ring-glow`) so the bloom matches the brighter end of the arc.
- */
-export const RING_GLOW: Record = {
- readiness: "var(--ring-readiness-to)",
- recovery: "var(--ring-recovery-to)",
- sleep: "var(--ring-sleep-to)",
- stress: "var(--ring-stress-to)",
- strain: "var(--ring-strain-to)",
- green: "var(--ring-green-to)",
- yellow: "var(--ring-yellow-to)",
- red: "var(--ring-red-to)",
-};
-
/**
* The surface tint per metric — the `--tile-hue` the `.wellness-tile` CSS
* mixes (gently) over the theme `--card`. Set inline by each `RingTile`.
diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx
index 885f24d2c..8bac63efb 100644
--- a/src/components/insights/derived/score-anatomy-view.tsx
+++ b/src/components/insights/derived/score-anatomy-view.tsx
@@ -127,9 +127,7 @@ export function ScoreAnatomyView({
}
className={cn(
"relative flex flex-col gap-5 rounded-xl border p-5",
- hue
- ? "wellness-detail-card"
- : "border-border bg-card",
+ hue ? "wellness-detail-card" : "border-border bg-card",
className,
)}
aria-label={typeof title === "string" ? title : undefined}
@@ -212,7 +210,8 @@ export function ScoreAnatomyView({
/** One ranked contributor row — label + impact bar + value + weight share. */
function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
const { t } = useTranslations();
- const present = contributor.value != null && Number.isFinite(contributor.value);
+ const present =
+ contributor.value != null && Number.isFinite(contributor.value);
const clamped = present ? clampScore(contributor.value as number) : 0;
const rowBand: ScoreBand = bandForScore(clamped);
const weightPct = Math.round(contributor.weight * 100);
diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx
index b7bbfe337..50d281e8f 100644
--- a/src/components/insights/derived/score-ring.tsx
+++ b/src/components/insights/derived/score-ring.tsx
@@ -6,25 +6,31 @@ import { cn } from "@/lib/utils";
import { useCountUp } from "@/hooks/use-count-up";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { bandForScore, clampScore, type ScoreBand } from "./band-tokens";
-import { RING_GRADIENT, RING_GLOW, type RingHue } from "./ring-hues";
+import { RING_GRADIENT, type RingHue } from "./ring-hues";
/**
- * v1.14.0 — the composite-score dial: a hand-rolled SVG arc gauge with a
- * premium "signature reveal" (Oura/WHOOP-tier), 0 KB runtime, no animation
- * library. A score dial is a fixed-geometry gauge, not a data chart, so SVG
- * gives exact control over a thin round-cap arc, a segmented same-hue track,
- * a single-hue two-stop gradient, a faint "vs your baseline" ghost arc, and a
- * token-tinted bloom — all CSS-driven.
+ * v1.14.0 — the composite-score dial: a hand-rolled SVG arc gauge, 0 KB
+ * runtime, no animation library. A score dial is a fixed-geometry gauge, not a
+ * data chart, so SVG gives exact control over a thin round-cap arc, a
+ * segmented same-hue track, a near-flat single-hue arc, and a faint "vs your
+ * baseline" ghost arc.
*
- * Motion (all CSS-first, all gated on `prefers-reduced-motion`):
+ * v1.18.7 — CALM by default. The earlier dial wore a token-tinted bloom, a
+ * conic specular sheen, and a breathing pulse on the green ring; against the
+ * flat 2-D Recharts charts that read as a glowing 3-D gauge. All three are
+ * gone everywhere — the ring now paints a plain stroke in a muted single hue,
+ * as quiet as a chart series. The only motion left is a subtle spring fill on
+ * first reveal + a count-up, both gated on `prefers-reduced-motion`.
+ *
+ * Motion (CSS-first, gated on `prefers-reduced-motion`):
* • the arc sweeps from empty on mount via a `linear()` SPRING that
* overshoots ~6% then settles (`--ring-spring`); the spring is swapped for
* a plain ease-out above score 94 so a high arc never wraps past full;
* • `delayMs` staggers the sweep + the count-up so the strip reveals
* left-to-right and each number trails its arc;
- * • a conic SHEEN + the tile rise + glow live in `globals.css`, gated on the
- * strip's `data-revealed` flag (set once per session) so a background
- * refetch never re-triggers the moment.
+ * • the quiet tile rise lives in `globals.css`, gated on the strip's
+ * `data-revealed` flag (set once per session) so a background refetch
+ * never re-triggers it.
*
* The arc/track/ghost are real SVG JSX (`` is NOT
* `dangerouslySetInnerHTML`). The `` is rotated -90° so the arc starts at
@@ -78,10 +84,10 @@ export interface ScoreRingProps {
*/
delayMs?: number;
/**
- * Flat 2-D treatment: drops the drop-shadow bloom, the breathing pulse, the
- * conic sheen, and the sweep/count-up entrance. Used on the dashboard hero so
- * the ring sits as calmly as the surrounding chart + tile surfaces. The
- * /insights wellness strip + anatomy view keep the full premium reveal.
+ * v1.18.7 — the bloom / sheen / pulse are gone for every ring, so the only
+ * thing `flat` still does is drop the spring-fill sweep + the count-up: the
+ * dashboard-hero ring paints its final value at once, with no entrance
+ * motion. The /insights strip + anatomy view keep the subtle sweep.
*/
flat?: boolean;
/**
@@ -121,10 +127,10 @@ export function ScoreRing({
const resolvedBand: ScoreBand = band ?? bandForScore(clamped);
const hueKey = hue ?? resolvedBand;
- // Single-hue two-stop gradient. The per-metric `hue` leans the colour
- // (Oura's move); with no `hue` the anatomy view falls back to the band token.
+ // Near-flat single-hue arc. The two stops sit a hair apart for a whisper of
+ // depth; the per-metric `hue` leans the colour, with no `hue` the anatomy
+ // view falls back to the muted band token.
const [from, to] = RING_GRADIENT[hueKey];
- const glow = RING_GLOW[hueKey];
const reduced = prefersReducedMotion();
// `flat` forces the calm static treatment regardless of the `animate` prop:
@@ -175,18 +181,15 @@ export function ScoreRing({
{
width: dims.px,
height: dims.px,
- "--ring-glow": glow,
} as React.CSSProperties
}
>
- {/* `overflow-visible`: the arc's drop-shadow bloom (`.wellness-ring-arc`)
- paints outside the viewBox — the arc fills the 0 0 100 100 box, so the
- default `overflow: hidden` on SVG clipped the glow flat at the circle's
- edge. The -90° rotation is unaffected (the bloom is radially symmetric)
- and the parent div never clips, so the glow now feathers out fully. */}
+ {/* The -90° rotation starts the arc at 12 o'clock so it sweeps clockwise
+ without per-point trig. The arc now carries no bloom, so the SVG needs
+ no `overflow-visible`. */}
@@ -227,15 +230,10 @@ export function ScoreRing({
}}
/>
) : null}
- {/* The progress arc — single-hue gradient, round cap, spring sweep, bloom. */}
+ {/* The progress arc — muted single hue, round cap, subtle spring sweep.
+ No bloom / pulse: `.wellness-ring-arc` is a plain flat stroke. */}
- {/* Conic specular sheen — sweeps the arc once on the strip reveal.
- Omitted entirely in the flat treatment (dashboard hero). */}
- {flat ? null :
}
{/* Centred number as real DOM text — design tokens + tabular-nums. */}
= 2
- ? series.map((v, i) => ({ i, v }))
- : null;
+ series && series.length >= 2 ? series.map((v, i) => ({ i, v })) : null;
// The sparkline tints with the trend sentiment so it reads "same signal"
// as the arrow; neutral metrics ride the muted-foreground line.
@@ -130,10 +128,7 @@ export function SparklineDeltaTile({
the old muted-uppercase caption. Kept on one line for the dense
grid; provenance trails to the right edge. */}
-
+
+
= {
- VITALS_BASELINE: {
- methodKey: "insights.derived.composite.VITALS_BASELINE.method",
- standard: {
- // Robust central-tendency / dispersion via the median ± k·MAD.
- name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
- url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+export const METRIC_PROVENANCE: Record =
+ {
+ VITALS_BASELINE: {
+ methodKey: "insights.derived.composite.VITALS_BASELINE.method",
+ standard: {
+ // Robust central-tendency / dispersion via the median ± k·MAD.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
},
- },
- HRV_BALANCE: {
- methodKey: "insights.derived.composite.HRV_BALANCE.method",
- standard: {
- // Task Force of the ESC / NASPE — HRV standards of measurement.
- name: "Task Force ESC/NASPE 1996, Circulation",
- url: "https://doi.org/10.1161/01.CIR.93.5.1043",
+ HRV_BALANCE: {
+ methodKey: "insights.derived.composite.HRV_BALANCE.method",
+ standard: {
+ // Task Force of the ESC / NASPE — HRV standards of measurement.
+ name: "Task Force ESC/NASPE 1996, Circulation",
+ url: "https://doi.org/10.1161/01.CIR.93.5.1043",
+ },
},
- },
- BMI: {
- methodKey: "insights.derived.composite.BMI.method",
- standard: {
- // WHO Technical Report Series 894 — BMI classification.
- name: "WHO Technical Report Series 894",
- url: "https://iris.who.int/handle/10665/42330",
+ BMI: {
+ methodKey: "insights.derived.composite.BMI.method",
+ standard: {
+ // WHO Technical Report Series 894 — BMI classification.
+ name: "WHO Technical Report Series 894",
+ url: "https://iris.who.int/handle/10665/42330",
+ },
},
- },
- FITNESS_AGE: {
- methodKey: "insights.derived.composite.FITNESS_AGE.method",
- standard: {
- // FRIEND registry CRF reference standards (Kaminsky et al.).
- name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
- url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
+ FITNESS_AGE: {
+ methodKey: "insights.derived.composite.FITNESS_AGE.method",
+ standard: {
+ // FRIEND registry CRF reference standards (Kaminsky et al.).
+ name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)",
+ url: "https://doi.org/10.1016/j.mayocp.2015.07.026",
+ },
},
- },
- VASCULAR_AGE_DELTA: {
- methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
- standard: {
- // Reference values for arterial stiffness (PWV) collaboration.
- name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
- url: "https://doi.org/10.1093/eurheartj/ehq165",
+ VASCULAR_AGE_DELTA: {
+ methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method",
+ standard: {
+ // Reference values for arterial stiffness (PWV) collaboration.
+ name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.",
+ url: "https://doi.org/10.1093/eurheartj/ehq165",
+ },
+ caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
},
- caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat",
- },
- COINCIDENT_DEVIATION: {
- methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
- standard: {
- // Hampel-identifier / median ± k·MAD outlier flagging.
- name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
- url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ COINCIDENT_DEVIATION: {
+ methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method",
+ standard: {
+ // Hampel-identifier / median ± k·MAD outlier flagging.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
+ caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
},
- caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat",
- },
- SLEEP_SCORE: {
- methodKey: "insights.derived.composite.SLEEP_SCORE.method",
- standard: {
- name: "Hirshkowitz et al. 2015, Sleep Health",
- url: "https://doi.org/10.1016/j.sleh.2014.12.010",
+ SLEEP_SCORE: {
+ methodKey: "insights.derived.composite.SLEEP_SCORE.method",
+ standard: {
+ name: "Hirshkowitz et al. 2015, Sleep Health",
+ url: "https://doi.org/10.1016/j.sleh.2014.12.010",
+ },
},
- },
- READINESS: {
- methodKey: "insights.derived.composite.READINESS.method",
- standard: {
- name: "Plews et al. 2013, Sports Medicine",
- url: "https://doi.org/10.1007/s40279-013-0071-8",
+ READINESS: {
+ methodKey: "insights.derived.composite.READINESS.method",
+ standard: {
+ name: "Plews et al. 2013, Sports Medicine",
+ url: "https://doi.org/10.1007/s40279-013-0071-8",
+ },
+ caveatKey: "insights.derived.composite.READINESS.caveat",
},
- caveatKey: "insights.derived.composite.READINESS.caveat",
- },
- RECOVERY_SCORE: {
- methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
- standard: {
- // HRV-guided recovery / training-readiness framing.
- name: "Buchheit 2014, Front. Physiol.",
- url: "https://doi.org/10.3389/fphys.2014.00073",
+ RECOVERY_SCORE: {
+ methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
+ standard: {
+ // HRV-guided recovery / training-readiness framing.
+ name: "Buchheit 2014, Front. Physiol.",
+ url: "https://doi.org/10.3389/fphys.2014.00073",
+ },
+ caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
},
- caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
- },
- STRESS_SCORE: {
- methodKey: "insights.derived.composite.STRESS_SCORE.method",
- standard: {
- // Neurovisceral-integration / HRV-as-stress-index basis.
- name: "Kim et al. 2018, Psychiatry Investig.",
- url: "https://doi.org/10.30773/pi.2017.08.17",
+ STRESS_SCORE: {
+ methodKey: "insights.derived.composite.STRESS_SCORE.method",
+ standard: {
+ // Neurovisceral-integration / HRV-as-stress-index basis.
+ name: "Kim et al. 2018, Psychiatry Investig.",
+ url: "https://doi.org/10.30773/pi.2017.08.17",
+ },
+ caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
},
- caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
- },
- STRAIN_SCORE: {
- methodKey: "insights.derived.composite.STRAIN_SCORE.method",
- standard: {
- // Banister TRIMP training-load model (HR-reserve weighting).
- name: "Banister 1991, TRIMP model",
- url: "https://en.wikipedia.org/wiki/Training_Impulse",
+ STRAIN_SCORE: {
+ methodKey: "insights.derived.composite.STRAIN_SCORE.method",
+ standard: {
+ // Banister TRIMP training-load model (HR-reserve weighting).
+ name: "Banister 1991, TRIMP model",
+ url: "https://en.wikipedia.org/wiki/Training_Impulse",
+ },
+ caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
- caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
- },
- WRIST_TEMPERATURE_BASELINE: {
- methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
- standard: {
- // Robust personal-deviation band via the median ± k·MAD.
- name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
- url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ WRIST_TEMPERATURE_BASELINE: {
+ methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
+ standard: {
+ // Robust personal-deviation band via the median ± k·MAD.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
+ caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
},
- caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
- },
- STAIR_ASCENT_SPEED_BASELINE: {
- methodKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
- standard: {
- // Robust personal-trend band via the median ± k·MAD.
- name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
- url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ STAIR_ASCENT_SPEED_BASELINE: {
+ methodKey:
+ "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
+ standard: {
+ // Robust personal-trend band via the median ± k·MAD.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
+ caveatKey:
+ "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
},
- caveatKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
- },
- STAIR_DESCENT_SPEED_BASELINE: {
- methodKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
- standard: {
- // Robust personal-trend band via the median ± k·MAD.
- name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
- url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ STAIR_DESCENT_SPEED_BASELINE: {
+ methodKey:
+ "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
+ standard: {
+ // Robust personal-trend band via the median ± k·MAD.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
+ caveatKey:
+ "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
},
- caveatKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
- },
- SIX_MINUTE_WALK_BAND: {
- methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
- standard: {
- // Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
- name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
- url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
+ SIX_MINUTE_WALK_BAND: {
+ methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
+ standard: {
+ // Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
+ name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
+ url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
+ },
+ caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
},
- caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
- },
- TRAJECTORY: {
- methodKey: "insights.derived.composite.TRAJECTORY.method",
- standard: {
- // Ordinary least squares + the textbook OLS prediction interval.
- name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis",
- url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811",
+ TRAJECTORY: {
+ methodKey: "insights.derived.composite.TRAJECTORY.method",
+ standard: {
+ // Ordinary least squares + the textbook OLS prediction interval.
+ name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis",
+ url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811",
+ },
+ caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
},
- caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
- },
-};
+ };
diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx
index 9a55f9f4a..d9cc97903 100644
--- a/src/components/insights/derived/trajectory-forecast-card.tsx
+++ b/src/components/insights/derived/trajectory-forecast-card.tsx
@@ -169,7 +169,9 @@ function CardSkeleton({ compact = false }: { compact?: boolean }) {
-
+
);
}
@@ -211,7 +213,10 @@ export function TrajectoryForecastCard({
base: scaled(v.lastValue, valueScale),
range: 0,
};
- return { points: [anchor, ...points], lastValue: scaled(v.lastValue, valueScale) };
+ return {
+ points: [anchor, ...points],
+ lastValue: scaled(v.lastValue, valueScale),
+ };
}, [data, valueScale]);
if (!data) {
@@ -258,7 +263,11 @@ export function TrajectoryForecastCard({
const v = data.value!;
const DirectionIcon =
- v.direction === "up" ? TrendingUp : v.direction === "down" ? TrendingDown : Minus;
+ v.direction === "up"
+ ? TrendingUp
+ : v.direction === "down"
+ ? TrendingDown
+ : Minus;
const last = chart.points[chart.points.length - 1];
const projectedLow = last.base;
const projectedHigh = last.base + last.range;
diff --git a/src/components/insights/derived/use-dashboard-derived.ts b/src/components/insights/derived/use-dashboard-derived.ts
index c86c1f8ce..02fdc00c0 100644
--- a/src/components/insights/derived/use-dashboard-derived.ts
+++ b/src/components/insights/derived/use-dashboard-derived.ts
@@ -2,10 +2,7 @@
import { useMemo } from "react";
-import {
- useDerivedBatch,
- type DerivedBatchToken,
-} from "./use-derived-metric";
+import { useDerivedBatch, type DerivedBatchToken } from "./use-derived-metric";
// Type-only — keeps the derived registry's server graph out of the bundle.
import type { DerivedMetricId } from "@/lib/insights/derived/registry";
diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts
index 5357703eb..42826b4e1 100644
--- a/src/components/insights/derived/use-derived-metric.ts
+++ b/src/components/insights/derived/use-derived-metric.ts
@@ -85,7 +85,10 @@ export function useDerivedMetric(
try {
const params = new URLSearchParams({ metric });
if (type) params.set("type", type);
- return apiGet>(`/api/insights/derived?${params.toString()}`, { signal: controller.signal });
+ return apiGet>(
+ `/api/insights/derived?${params.toString()}`,
+ { signal: controller.signal },
+ );
} finally {
clearTimeout(timeoutHandle);
}
@@ -154,7 +157,10 @@ export function useDerivedBatch(
);
try {
const params = new URLSearchParams({ metrics: wireTokens.join(",") });
- return apiGet(`/api/insights/derived/batch?${params.toString()}`, { signal: controller.signal });
+ return apiGet(
+ `/api/insights/derived/batch?${params.toString()}`,
+ { signal: controller.signal },
+ );
} finally {
clearTimeout(timeoutHandle);
}
diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx
index 1710af3df..2aed2b17b 100644
--- a/src/components/insights/derived/vitals-dashboard.tsx
+++ b/src/components/insights/derived/vitals-dashboard.tsx
@@ -23,10 +23,7 @@ import {
SECTION_MOBILITY,
type DashboardDerived,
} from "./use-dashboard-derived";
-import {
- resolveTileLayout,
- type InsightsLayout,
-} from "@/lib/insights-layout";
+import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
@@ -153,7 +150,10 @@ const MOBILITY_TILE_LAYOUT_ID: Record = {
* `null` layout (caller passed none) is treated as everything-visible so the
* default behaviour is unchanged.
*/
-function tileVisible(layout: InsightsLayout | undefined, tileId: string): boolean {
+function tileVisible(
+ layout: InsightsLayout | undefined,
+ tileId: string,
+): boolean {
if (!layout) return true;
return resolveTileLayout(layout, tileId).visible;
}
@@ -653,23 +653,23 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
order: tileOrder(layout, "bmi"),
node: ,
},
- ...SECTION_VITALS.filter(
- (type) => type !== "HEART_RATE_VARIABILITY",
- ).map((type) => {
- const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type;
- return {
- id,
- order: tileOrder(layout, id),
- node: (
-
- ),
- };
- }),
+ ...SECTION_VITALS.filter((type) => type !== "HEART_RATE_VARIABILITY").map(
+ (type) => {
+ const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type;
+ return {
+ id,
+ order: tileOrder(layout, id),
+ node: (
+
+ ),
+ };
+ },
+ ),
];
return entries
.filter((e) => tileVisible(layout, e.id))
diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx
index 8c01db054..178617bb1 100644
--- a/src/components/insights/device-score-tile.tsx
+++ b/src/components/insights/device-score-tile.tsx
@@ -156,8 +156,10 @@ export function DeviceScoreTile({
);
}
-export interface ConnectedDeviceScoreTileProps
- extends Omit {
+export interface ConnectedDeviceScoreTileProps extends Omit<
+ DeviceScoreTileProps,
+ "summary"
+> {
/** Optional section heading rendered above the tile when it has data. */
sectionTitle?: string;
/** Optional Lucide glyph for the section heading. */
@@ -187,9 +189,7 @@ export function ConnectedDeviceScoreTile({
if ((summary?.count ?? 0) === 0) return null;
- const tile = (
-
- );
+ const tile = ;
if (!sectionTitle || !sectionIcon) return tile;
diff --git a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx
index 5cc49eed5..51b5b8359 100644
--- a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx
+++ b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx
@@ -73,7 +73,9 @@ describe(" learning state", () => {
measuredAt: new Date(NOW.getTime() - (4 - i) * DAY),
mgdl,
}));
- const glucoseClinical = computeGlucoseClinicalMetrics(readings, { now: NOW });
+ const glucoseClinical = computeGlucoseClinicalMetrics(readings, {
+ now: NOW,
+ });
expect(glucoseClinical.stillLearning).toBe(true);
analyticsMock.mockReturnValue({
@@ -85,7 +87,7 @@ describe(" learning state", () => {
expect(html).toContain('data-state="learning"');
expect(html).toContain("Still learning your glucose");
// the asserted blocks must NOT be present in the learning state
- expect(html).not.toContain("data-slot=\"glucose-tir-bar\"");
+ expect(html).not.toContain('data-slot="glucose-tir-bar"');
expect(html).not.toContain("Glucose Management Indicator");
// the spot-reading caveat is always carried
expect(html).toContain("spot-reading estimates");
@@ -115,7 +117,10 @@ describe(" asserted state", () => {
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
mgdl,
}));
- return computeGlucoseClinicalMetrics(readings, { now: NOW, windowDays: 30 });
+ return computeGlucoseClinicalMetrics(readings, {
+ now: NOW,
+ windowDays: 30,
+ });
}
it("renders TIR bar, GMI, eA1C, a CV badge, and the advanced disclosure", () => {
@@ -140,7 +145,7 @@ describe(" asserted state", () => {
expect(html).toContain('data-slot="glucose-cv-badge"');
// advanced disclosure trigger is present (region collapsed by default in SSR)
expect(html).toContain('data-slot="glucose-advanced-toggle"');
- expect(html).toContain("aria-expanded=\"false\"");
+ expect(html).toContain('aria-expanded="false"');
expect(html).toContain("J-index");
});
@@ -169,7 +174,10 @@ describe(" asserted state", () => {
});
it("renders nothing when the thick slice carries no glucoseClinical", () => {
- analyticsMock.mockReturnValue({ isLoading: false, data: { summaries: {} } });
+ analyticsMock.mockReturnValue({
+ isLoading: false,
+ data: { summaries: {} },
+ });
const html = render( );
expect(html).toBe("");
});
diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx
index 66d74d63d..362dfcc29 100644
--- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx
+++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx
@@ -26,7 +26,8 @@ import type { DataSummary } from "@/lib/analytics/trends";
const CONTEXT_LABEL_KEYS: Record = {
FASTING: "insights.bloodGlucose.clinical.advanced.byContext.fasting",
- POSTPRANDIAL: "insights.bloodGlucose.clinical.advanced.byContext.postprandial",
+ POSTPRANDIAL:
+ "insights.bloodGlucose.clinical.advanced.byContext.postprandial",
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
};
@@ -68,10 +69,7 @@ export function GlucoseAdvancedDisclosure({
{t("insights.bloodGlucose.clinical.advanced.toggle")}
diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx
index 3e233c593..e71a86bc1 100644
--- a/src/components/insights/glucose/glucose-clinical-panel.tsx
+++ b/src/components/insights/glucose/glucose-clinical-panel.tsx
@@ -159,7 +159,10 @@ export function GlucoseClinicalPanel() {
) : null}
{/* Headline metrics */}
-
+
{clinical.meanMgdl !== null ? (
}
@@ -217,7 +220,10 @@ export function GlucoseClinicalPanel() {
and reports `isSpotEstimate: false`, so the caveat would undersell
the data. */}
{clinical.isSpotEstimate ? (
-
+
{t("insights.bloodGlucose.clinical.spotCaveat")}
) : null}
diff --git a/src/components/insights/glucose/glucose-tir-bar.tsx b/src/components/insights/glucose/glucose-tir-bar.tsx
index 25602a3f0..704a7eefd 100644
--- a/src/components/insights/glucose/glucose-tir-bar.tsx
+++ b/src/components/insights/glucose/glucose-tir-bar.tsx
@@ -41,11 +41,31 @@ export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
const veryHigh = distribution.tarLevel2;
const segments: Array<{ key: BandKey; label: string; fraction: number }> = [
- { key: "veryLow", label: t("insights.bloodGlucose.clinical.tir.veryLow"), fraction: veryLow },
- { key: "low", label: t("insights.bloodGlucose.clinical.tir.low"), fraction: low },
- { key: "inRange", label: t("insights.bloodGlucose.clinical.tir.inRange"), fraction: inRange },
- { key: "high", label: t("insights.bloodGlucose.clinical.tir.high"), fraction: high },
- { key: "veryHigh", label: t("insights.bloodGlucose.clinical.tir.veryHigh"), fraction: veryHigh },
+ {
+ key: "veryLow",
+ label: t("insights.bloodGlucose.clinical.tir.veryLow"),
+ fraction: veryLow,
+ },
+ {
+ key: "low",
+ label: t("insights.bloodGlucose.clinical.tir.low"),
+ fraction: low,
+ },
+ {
+ key: "inRange",
+ label: t("insights.bloodGlucose.clinical.tir.inRange"),
+ fraction: inRange,
+ },
+ {
+ key: "high",
+ label: t("insights.bloodGlucose.clinical.tir.high"),
+ fraction: high,
+ },
+ {
+ key: "veryHigh",
+ label: t("insights.bloodGlucose.clinical.tir.veryHigh"),
+ fraction: veryHigh,
+ },
];
const pct = (f: number) => Math.round(f * 100);
@@ -91,7 +111,7 @@ export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
/>
{s.label}
-
+
{pct(s.fraction)}%
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index 3e058eed6..558955bf8 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -228,8 +228,9 @@ export function HealthScoreCard({
// descending so the biggest contributor sits first. Components with
// null values sink to the bottom (`weight * 0 === 0`); the tie-break
// is the alphabetical key order so determinism holds across renders.
- const provenanceRows = COMPONENT_ORDER
- .filter((key) => moodEnabled || key !== "mood")
+ const provenanceRows = COMPONENT_ORDER.filter(
+ (key) => moodEnabled || key !== "mood",
+ )
.map((key) => {
const c = components[key];
const inferredSource: HealthScoreComponentSource =
@@ -244,10 +245,7 @@ export function HealthScoreCard({
effective: c.weight * (c.value !== null ? 1 : 0),
};
})
- .sort(
- (a, b) =>
- b.effective - a.effective || a.key.localeCompare(b.key),
- );
+ .sort((a, b) => b.effective - a.effective || a.key.localeCompare(b.key));
const presentCount = provenanceRows.filter((r) => r.value !== null).length;
const totalCount = provenanceRows.length;
@@ -273,7 +271,7 @@ export function HealthScoreCard({
// gutter). The basis values still bias the column generous
// (~36 % at md, ~40 % at xl) but cede headroom when the parent
// narrows.
- "w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
+ "w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
// v1.4.28 R3c-Insights — stretch to match the parent's
// `items-stretch` row height (FB-H1/H2). `h-full` + an inner
// flex column lets the disclaimer ride to the bottom with
@@ -377,10 +375,7 @@ export function HealthScoreCard({
) : (
<>
{delta > 0 && (
-
+
)}
{delta < 0 && (
0 ? `+${delta}` : `${delta}` },
- )}
+ description={t("insights.healthScore.deltaExplainer.description", {
+ delta: delta > 0 ? `+${delta}` : `${delta}`,
+ })}
>
" meta row
- * - action row: single "Ask the coach" affordance
- * (v1.4.25 W3 dropped the "Re-run analysis" button — the
- * regenerate affordance moved to ``. v1.4.28
- * retired the weekly-report path, leaving Coach as the sole
- * hero-row action.)
- * - chip strip below the action band
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
* + `.glow-purple` utilities in `globals.css`
*
- * Mobile-first: the action row wraps + the suggested-prompt chips
- * wrap. Right-side Health Score panel is *not* part of B1 — that
- * lands in B5; for B1 the right side just keeps its meta band.
+ * v1.18.7 — the overview "Ask the coach" action row + guided-questions
+ * chip strip were removed from the band. The Coach lives in the
+ * bottom-right drawer (mounted by the insights layout shell); the
+ * overview now leads with the present-focused daily briefing, not a
+ * coach prompt. The hero is greeting + subtitle + baseline meta + the
+ * optional right-side Health Score panel.
*
* Pure presentational — the page owns the briefing data.
*/
@@ -53,25 +46,6 @@ interface HeroStripProps {
* morning, undefined".
*/
userName?: string | null;
- /**
- * Click handler for the suggested prompts. The Coach drawer (B2b)
- * routes a chip click into the drawer's input; the parent owns the
- * drawer state. When omitted the chip click is a no-op.
- */
- onPickPrompt?: (prompt: string) => void;
- /**
- * Click handler for the "Ask the coach" action button. When supplied,
- * the button is enabled and the coming-soon tooltip drops; clicking
- * opens the drawer (B2b). When omitted the button stays disabled —
- * see the v1.4.20 phase B1 dispatch where the drawer was deferred.
- *
- * v1.4.20 phase B5 — the same handler powers the Health Score
- * panel's "Ask the Coach" button, with an optional `prefill` string
- * that opens the drawer with a score-aware question. The action-row
- * button calls it without an argument (drawer opens blank); the HSC
- * panel calls it with "Why is my health score X out of 100?".
- */
- onAskCoach?: (prefill?: string) => void;
/**
* Now() override for tests so the greeting bucket is deterministic.
* Defaults to `new Date()`. Production callers omit this.
@@ -79,11 +53,8 @@ interface HeroStripProps {
now?: Date;
/**
* v1.4.20 phase B5 — Personal Health Score panel data. When supplied
- * the right side of the hero band paints the score card. The
- * `onAskCoach` handler is intentionally re-used from the action-row
- * "Ask the coach" button — same drawer state, different prefill
- * string. When the parent omits the field the right side stays empty
- * (current B1 behaviour).
+ * the right side of the hero band paints the score card. When the
+ * parent omits the field the right side stays empty.
*/
healthScore?: {
score: HealthScoreCardProps["score"];
@@ -134,28 +105,11 @@ export function HeroStrip({
briefing,
updatedAt,
userName,
- onPickPrompt,
- onAskCoach,
now,
healthScore,
healthScorePending = false,
}: HeroStripProps) {
const { t } = useTranslations();
- // v1.4.37 W5 — operator-level Coach gate. When the global Coach flag
- // is off every Coach affordance must vanish from this band (the
- // action-row button, the suggested-prompts chip strip, and the
- // HealthScoreCard's `onAskCoach` prop). The button and chips both
- // open the Coach drawer, so leaving them visible while the drawer
- // mount is suppressed would surface dead controls.
- const flags = useFeatureFlags();
- // v1.4.47 W3 — per-user "Hide Coach" toggle joins the operator's
- // master flag. Either-off resolves to `coachEnabled === false` so
- // the hero action button + suggested-prompts strip + the
- // ` ` prop all vanish together. The
- // hook is SSR-safe — returns `false` when the legacy snapshot
- // tests render the strip without a QueryClient.
- const disableCoach = useDisableCoach();
- const coachEnabled = flags.coach && !disableCoach;
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
// when the account turned the module off (the server already drops the
// pillar from the number itself). SSR-safe + default-on, matching the
@@ -168,7 +122,6 @@ export function HeroStrip({
const generatedLine = updatedAt
? t("insights.heroGenerated", { time: formatRelativeTime(updatedAt, t) })
: null;
- const comingSoon = t("insights.heroComingSoonTooltip");
return (
{/*
- * v1.4.37 W5 — the action row only carries the Coach button
- * today (v1.4.28 retired the weekly-report affordance). When
- * the operator turns the global Coach flag off the whole row
- * disappears rather than collapsing to an empty flex shell.
+ * v1.18.7 — the overview "Ask the coach" action row + the
+ * guided-questions chip strip were removed from the hero band.
+ * The Coach is the bottom-right drawer (mounted in the insights
+ * layout shell); the overview leads with the present-focused
+ * daily briefing, not a coach prompt. The drawer itself is
+ * untouched — only the hero entry points are gone, so the band
+ * is now greeting + subtitle + baseline meta.
*/}
- {coachEnabled && (
-
- {/* B2b wires this into the Coach drawer. The button is
- enabled whenever the parent supplies an `onAskCoach`
- handler; older parents that haven't adopted B2b yet
- still get the disabled "Coming soon" affordance so the
- hero doesn't break. v1.4.28 retired the weekly-report
- button, leaving Coach as the only hero-row action. */}
- onAskCoach() : undefined}
- disabled={!onAskCoach}
- title={onAskCoach ? undefined : comingSoon}
- data-slot="insights-hero-strip-action-coach"
- className="gap-1.5"
- >
-
- {t("insights.heroActionAskCoach")}
-
- {/*
- * v1.4.25 W3 — the regenerate button moved to the new
- * `` (icon-only RefreshCw, sticky next
- * to the pill nav) so the user can re-run the analysis
- * without scrolling back to the hero band.
- */}
-
- )}
-
- {coachEnabled && (
-
- {/*
- * v1.4.20 phase B2b — chip clicks open the Coach drawer
- * with the localised prompt pre-filled in the composer.
- * The parent owns drawer state so the chip strip stays
- * presentational.
- *
- * v1.4.37 W5 — the strip is a Coach-only affordance
- * (every chip seeds a Coach turn), so it is gated on the
- * same flag as the action-row button above. Without the
- * gate the chips would still paint while the drawer is
- * suppressed, leaving inert controls in the band.
- */}
- undefined)} />
-
- )}
{!healthScore && healthScorePending && (
@@ -329,7 +230,7 @@ export function HeroStrip({
aria-hidden="true"
className={cn(
"bg-card/65 border-border/60 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
- "w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
+ "w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
"flex h-full min-h-64 flex-col gap-3.5",
)}
>
diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx
index fa0fc2e54..7ac41f6b1 100644
--- a/src/components/insights/insights-edit-mode.tsx
+++ b/src/components/insights/insights-edit-mode.tsx
@@ -116,10 +116,10 @@ export function InsightsEditMode({
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
return apiPut("/api/insights/layout", {
- version: 2,
- sections: next.sections,
- tiles: next.tiles,
- });
+ version: 2,
+ sections: next.sections,
+ tiles: next.tiles,
+ });
},
onSuccess: (saved) => {
// Optimistic-style settle: write the server-resolved layout into the
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index 027f9dd02..f8a76fc3b 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -115,8 +115,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// `.planning/research/v15-insights-blocking-bug.md` fix 2.
const summaries = analyticsQuery.data?.summaries;
const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0;
- const hasMedication =
- (comprehensiveQuery.data?.medications?.length ?? 0) > 0;
+ const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0;
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
@@ -138,7 +137,9 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const visibleTileIds = useMemo(() => {
if (!layoutSettled) return undefined;
return new Set(
- insightsLayout.tiles.filter((tile) => tile.visible).map((tile) => tile.id),
+ insightsLayout.tiles
+ .filter((tile) => tile.visible)
+ .map((tile) => tile.id),
);
}, [layoutSettled, insightsLayout]);
const tileOrder = useMemo(() => {
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index 01577f8eb..fdcbda4b0 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -709,7 +709,7 @@ function InsightsTabStripImpl({
// gives that room back to the layout so the strip height is
// unchanged. `px-1 -mx-1` does the same horizontally so the
// first/last pill's focus ring isn't clipped at the row edges.
- className="-mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
+ className="scroll-touch -mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
>
{tabs.map((tab) => {
if (tab.kind === "link") {
diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index ac0c877e6..7eb903ad5 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -238,7 +238,7 @@ export function LayoutCoachFab() {
"[body:has([data-testid=onboarding-tour])_&]:invisible",
)}
>
-
+
{unread ? (
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
// Deliberately NOT an alarming red (the medication-card rule:
@@ -254,11 +254,7 @@ export function LayoutCoachFab() {
{/* Polite announcement for the unread-nudge arrival — see the
rising-edge effect above. */}
-
+
{liveAnnouncement}
>
diff --git a/src/components/insights/measurement-diversity-nudge.tsx b/src/components/insights/measurement-diversity-nudge.tsx
index 24be94321..46f5bada6 100644
--- a/src/components/insights/measurement-diversity-nudge.tsx
+++ b/src/components/insights/measurement-diversity-nudge.tsx
@@ -102,7 +102,7 @@ export function MeasurementDiversityNudge({
type="button"
data-slot="measurement-diversity-nudge"
aria-label={triggerLabel}
- className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -my-3 -mx-2 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
+ className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
diff --git a/src/components/insights/metric-correlation-card.tsx b/src/components/insights/metric-correlation-card.tsx
index c0f4677f6..d09c8f00a 100644
--- a/src/components/insights/metric-correlation-card.tsx
+++ b/src/components/insights/metric-correlation-card.tsx
@@ -62,7 +62,9 @@ export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
// The query is gated by the hook on `isAuthenticated`, and disabled
// entirely when this slug owns no correlation so a target-less metric
// never fires the thick fetch.
- const { data } = useAnalyticsQuery({ enabled: key != null ? undefined : false });
+ const { data } = useAnalyticsQuery({
+ enabled: key != null ? undefined : false,
+ });
const analytics = data as AnalyticsWithCorrelations | undefined;
if (!flags.correlations || !key) return null;
diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx
index 296ec1b05..fe270ac1c 100644
--- a/src/components/insights/metric-target-summary.tsx
+++ b/src/components/insights/metric-target-summary.tsx
@@ -335,69 +335,69 @@ function TargetReferencePanel({
{/* Row 2: range bar — where today's value sits inside the band. */}
- {target.current != null ? (
-
- ) : null}
-
- {/* Row 2b: diastolic range bar for blood pressure. */}
- {isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
-
-
- {t("targets.diastolic")}
-
+ {target.current != null ? (
-
- ) : null}
+ ) : null}
- {/* Row 3: 30-day average (blood pressure only — every other metric
+ {/* Row 2b: diastolic range bar for blood pressure. */}
+ {isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
+
+
+ {t("targets.diastolic")}
+
+
+
+ ) : null}
+
+ {/* Row 3: 30-day average (blood pressure only — every other metric
surfaces its 30-day average in `` above the
chart, so repeating it here would duplicate the figure). */}
- {averageLabel ? (
-
- {averageLabel}
-
- ) : null}
+ {averageLabel ? (
+
+ {averageLabel}
+
+ ) : null}
- {/* Row 4: 7-day consistency strip. */}
- {showConsistency && target.consistency7d ? (
-
- ) : null}
+ {/* Row 4: 7-day consistency strip. */}
+ {showConsistency && target.consistency7d ? (
+
+ ) : null}
- {/* Footer: guideline source link. The adjust-target affordance
+ {/* Footer: guideline source link. The adjust-target affordance
moved to the header gear (see `TargetAdjustProvider`); this
panel registers itself as that gear's edit target above. */}
-
- {sourceLink ? (
-
- {t("targets.sourceLabel", { source: target.source })}
-
-
- ) : (
-
- {t("targets.sourceLabel", { source: target.source })}
-
- )}
+
diff --git a/src/components/insights/mood/__tests__/mood-better-days.test.tsx b/src/components/insights/mood/__tests__/mood-better-days.test.tsx
index e39efb3aa..57fda5429 100644
--- a/src/components/insights/mood/__tests__/mood-better-days.test.tsx
+++ b/src/components/insights/mood/__tests__/mood-better-days.test.tsx
@@ -2,10 +2,7 @@ import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
-import {
- MoodBetterDays,
- type MoodBetterDayFactor,
-} from "../mood-better-days";
+import { MoodBetterDays, type MoodBetterDayFactor } from "../mood-better-days";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
@@ -100,16 +97,14 @@ describe("
", () => {
});
it("renders the description once and no local generic disclaimer", () => {
- const html = render(
- ,
- );
+ const html = render( );
expect((html.match(/ranked by how strongly/g) ?? []).length).toBe(1);
// The generic "associations only, not causes" disclaimer now lives once
// in the page-level Insights footer, not on this board.
expect(html).not.toContain("Associations only");
// one row per factor
- expect((html.match(/data-slot="mood-better-day-factor"/g) ?? []).length).toBe(
- 2,
- );
+ expect(
+ (html.match(/data-slot="mood-better-day-factor"/g) ?? []).length,
+ ).toBe(2);
});
});
diff --git a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts
index cf734acb4..6ec2c1718 100644
--- a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts
+++ b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts
@@ -3,10 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const COMPONENT_DIR = join(__dirname, "..");
-const MOOD_LIST = join(
- __dirname,
- "../../../mood/mood-list.tsx",
-);
+const MOOD_LIST = join(__dirname, "../../../mood/mood-list.tsx");
/**
* v1.8.6 — the notes timeline display is removed from the mood insights
diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx
index 8247b6ef8..a22c98e96 100644
--- a/src/components/insights/mood/mood-better-days.tsx
+++ b/src/components/insights/mood/mood-better-days.tsx
@@ -102,9 +102,7 @@ export function MoodBetterDays({
// read-out is COLORED TEXT, so it needs the Alucard light-mode
// override to clear AA. The arrow icon shares the same token so the
// good→down ramp stays coherent across both themes.
- const directionColor = up
- ? "var(--success)"
- : "var(--destructive)";
+ const directionColor = up ? "var(--success)" : "var(--destructive)";
// The effect read-out: tag factors show the mood-point delta,
// metric factors show the correlation coefficient.
diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx
index 302c784a5..713d50e23 100644
--- a/src/components/insights/mood/mood-distribution-chart.tsx
+++ b/src/components/insights/mood/mood-distribution-chart.tsx
@@ -58,7 +58,10 @@ export function MoodDistributionChart({
// tidy card rather than a dominant block, and matches the weekday sibling.
-
+
-
{t("insights.mood.correlationsDescription")}
diff --git a/src/components/insights/mood/mood-tag-breakdown.tsx b/src/components/insights/mood/mood-tag-breakdown.tsx
index ec01d3075..9e47132e5 100644
--- a/src/components/insights/mood/mood-tag-breakdown.tsx
+++ b/src/components/insights/mood/mood-tag-breakdown.tsx
@@ -34,7 +34,10 @@ export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
{tags.map((row) => (
-
+
{row.tag}
diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx
index 99cf293ce..1830c7e6a 100644
--- a/src/components/insights/mood/mood-tag-influence.tsx
+++ b/src/components/insights/mood/mood-tag-influence.tsx
@@ -60,11 +60,7 @@ function deltaColor(delta: number): string {
return delta >= 0 ? "var(--success)" : "var(--destructive)";
}
-export function MoodTagInfluence({
- rows,
-}: {
- rows: MoodTagInfluenceRow[];
-}) {
+export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) {
const { t } = useTranslations();
if (rows.length === 0) return null;
@@ -73,8 +69,7 @@ export function MoodTagInfluence({
{rows.map((row) => {
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
- const label =
- row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
+ const label = row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
return (
diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx
index f347c0df3..035445e31 100644
--- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx
+++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx
@@ -103,9 +103,7 @@ export function MoodTagMetricCrosstab({
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
// Semantic feedback tokens (not raw `--dracula-*`): the delta read-out
// is COLORED TEXT, so it needs the Alucard light-mode override for AA.
- const deltaColor = up
- ? "var(--success)"
- : "var(--destructive)";
+ const deltaColor = up ? "var(--success)" : "var(--destructive)";
return (
-
+
{hasNarratives && }
diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx
index fa87dd6a4..949c1cdb2 100644
--- a/src/components/insights/personal-record-badge.tsx
+++ b/src/components/insights/personal-record-badge.tsx
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
@@ -75,7 +76,7 @@ export function PersonalRecordBadge({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
- queryKey: ["personal-records", "by-metric", metricType],
+ queryKey: queryKeys.personalRecordsByMetric(metricType),
queryFn: async () => {
try {
const data = await apiGet(
@@ -103,7 +104,8 @@ export function PersonalRecordBadge({
// until the response lands — desired behaviour for the SSR path.
const fresh = (data ?? []).find(
(row) =>
- row.metricSlot === null && isPRWithin30Days(row.achievedAt, dataUpdatedAt),
+ row.metricSlot === null &&
+ isPRWithin30Days(row.achievedAt, dataUpdatedAt),
);
if (!fresh) return null;
@@ -118,7 +120,7 @@ export function PersonalRecordBadge({
// green on the dark card (#50fa7b). Both clear 4.5:1 against
// their card background — the previous `text-dracula-green` on
// `bg-dracula-green/10` measured ~2.6:1 in dark mode.
- "border-success/40 bg-success/15 text-success inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide tabular-nums uppercase",
+ "border-success/40 bg-success/15 text-success inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide uppercase tabular-nums",
className,
)}
aria-label={t("insights.personalRecord.badge")}
diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx
index ce99c669e..41f759414 100644
--- a/src/components/insights/recommendation-card.tsx
+++ b/src/components/insights/recommendation-card.tsx
@@ -30,10 +30,8 @@ import { ConfidenceMeter } from "./confidence-meter";
const SEVERITY_BADGE_STYLES: Record = {
info: "bg-info/10 text-info border border-info/25",
- suggestion:
- "bg-dose-accent/10 text-dose-accent border border-dose-accent/25",
- important:
- "bg-warning/10 text-warning border border-warning/25",
+ suggestion: "bg-dose-accent/10 text-dose-accent border border-dose-accent/25",
+ important: "bg-warning/10 text-warning border border-warning/25",
urgent: "bg-destructive/10 text-destructive border border-destructive/25",
};
diff --git a/src/components/insights/recommendation-feedback.tsx b/src/components/insights/recommendation-feedback.tsx
index 87f08c7b4..df8a73fcb 100644
--- a/src/components/insights/recommendation-feedback.tsx
+++ b/src/components/insights/recommendation-feedback.tsx
@@ -233,15 +233,9 @@ export function RecommendationFeedback({
data-slot="rec-feedback-already-rated"
>
{verdict === "up" ? (
-
+
) : (
-
+
)}
{t("insights.recommendation.feedbackAlreadyRated")}
@@ -266,7 +260,10 @@ export function RecommendationFeedback({
className="text-muted-foreground hover:text-success disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingUp ? (
-
+
) : (
)}
@@ -281,7 +278,10 @@ export function RecommendationFeedback({
className="text-muted-foreground hover:text-warning disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingDown ? (
-
+
) : (
)}
diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx
index 701442858..6fdf36cf6 100644
--- a/src/components/insights/recovery/recovery-section.tsx
+++ b/src/components/insights/recovery/recovery-section.tsx
@@ -68,9 +68,9 @@ export function RecoverySection() {
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
- const restMode =
- ((analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null)
- ?.restMode ?? null) as RestModeAnnotation | null;
+ const restMode = ((
+ analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
+ )?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
const unitScore = t("insights.deviceScore.unitScore");
diff --git a/src/components/insights/rest-mode-banner.tsx b/src/components/insights/rest-mode-banner.tsx
index 856e1ce99..d48216b51 100644
--- a/src/components/insights/rest-mode-banner.tsx
+++ b/src/components/insights/rest-mode-banner.tsx
@@ -43,7 +43,9 @@ export function RestModeBanner({
// start).
const since = annotation.since;
const sinceLabel =
- since && !Number.isNaN(Date.parse(since)) ? fmt.date(new Date(since)) : null;
+ since && !Number.isNaN(Date.parse(since))
+ ? fmt.date(new Date(since))
+ : null;
return (
{
- return apiGet<{
- night: string | null;
- main: SleepHypnogramSession | null;
- }>("/api/sleep/night");
- },
- enabled: isAuthenticated && totalCount > 0,
- staleTime: 60 * 1000,
- });
- const lastNight = nightQuery.data?.main ?? null;
-
if (!isLoading && totalCount === 0) {
return (
- {lastNight && }
-
{sleepStages ? (
) : (
diff --git a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx
new file mode 100644
index 000000000..7d09a89d0
--- /dev/null
+++ b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { SleepRhythmDto } from "../use-sleep-rhythm";
+
+/**
+ * v1.18.7 W-D — chronotype SECTION states (the prominent bottom block).
+ *
+ * Mirrors the sleep-rhythm section guard: error → quiet inline notice,
+ * loading → skeleton, settled → the large band treatment. `useAuth` +
+ * `useSleepRhythm` are mocked so each branch is deterministic.
+ */
+
+const authMock = vi.fn();
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => authMock(),
+}));
+
+const rhythmMock = vi.fn();
+vi.mock("../use-sleep-rhythm", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useSleepRhythm: () => rhythmMock() };
+});
+
+const { ChronotypeSection } = await import("../chronotype-section");
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+const READY_DTO: SleepRhythmDto = {
+ sleepDebt: {
+ state: "ready",
+ debtMinutes: 600,
+ needMinutes: 420,
+ nightsCounted: 10,
+ windowNights: 14,
+ nightsUntilReady: 0,
+ },
+ chronotype: {
+ state: "ready",
+ msfMinutes: 5 * 60 + 41, // 05:41
+ msfScMinutes: 5 * 60,
+ band: "late",
+ socialJetlagMinutes: 75,
+ freeNightsCounted: 6,
+ workNightsCounted: 14,
+ freeNightsUntilReady: 0,
+ },
+};
+
+beforeEach(() => {
+ authMock.mockReturnValue({ isAuthenticated: true });
+ rhythmMock.mockReset();
+});
+
+describe("", () => {
+ it("renders the quiet inline notice on a query error", () => {
+ rhythmMock.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ });
+ const html = render( );
+ expect(html).toContain('data-slot="chronotype-error"');
+ expect(html).not.toContain('data-slot="chronotype-loading"');
+ });
+
+ it("shows the skeleton while loading", () => {
+ rhythmMock.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ });
+ const html = render( );
+ expect(html).toContain('data-slot="chronotype-loading"');
+ expect(html).not.toContain('data-slot="chronotype-error"');
+ });
+
+ it("renders the large band treatment + midpoint clock on a settled DTO", () => {
+ rhythmMock.mockReturnValue({
+ data: READY_DTO,
+ isLoading: false,
+ isError: false,
+ });
+ const html = render( );
+ expect(html).toContain('data-slot="chronotype-band"');
+ expect(html).toContain("Evening type");
+ expect(html).toContain("05:41");
+ // The section heading carries the chronotype title.
+ expect(html).toContain('data-slot="section-heading"');
+ });
+
+ it("renders nothing when disabled", () => {
+ rhythmMock.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ const html = render( );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
index ffa8e2572..c2c0bc53f 100644
--- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
+++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
@@ -110,20 +110,20 @@ describe("", () => {
expect(html).toContain("04:30");
});
- it("renders the band as a labelled top-right corner readout, not a badge", () => {
+ it("v1.18.7 W-D — renders the band as the large hero label, not a corner readout", () => {
const html = render( );
- // The labelled corner carries both the "Chronotype" label and the value.
- expect(html).toContain('data-slot="chronotype-corner"');
- expect(html).toContain("Chronotype");
+ // The reworked bottom treatment leads with a large band label + the
+ // natural-midpoint headline. The retired top-right corner readout is gone.
+ expect(html).not.toContain('data-slot="chronotype-corner"');
+ expect(html).toContain('data-slot="chronotype-band"');
expect(html).toContain("Intermediate type");
+ expect(html).toContain("text-2xl");
});
- it("inherits the standard Card rhythm (no compact override)", () => {
+ it("uses the semantic info token, never the raw Dracula cyan", () => {
const learning = render( );
const ready = render( );
for (const html of [learning, ready]) {
- expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
- expect(html).toContain("md:py-6");
expect(html).toContain("text-info");
expect(html).not.toContain("text-dracula-cyan");
}
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 639992829..2d254ed3e 100644
--- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
+++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
@@ -12,8 +12,11 @@ import type { SleepRhythmDto } from "../use-sleep-rhythm";
* clinical panel's treatment:
* - a query error renders a quiet inline notice, NOT an endless skeleton
* - loading (and settled-but-undefined) shows the skeletons
- * - a settled DTO renders both cards
+ * - a settled DTO renders the sleep-debt card
* `useAuth` + `useSleepRhythm` are mocked so each branch is deterministic.
+ *
+ * v1.18.7 W-D — the chronotype card moved out to `` (the
+ * prominent bottom block); this section now owns the sleep-debt headline alone.
*/
const authMock = vi.fn();
@@ -87,54 +90,17 @@ describe("", () => {
expect(html).not.toContain('data-slot="sleep-rhythm-error"');
});
- it("renders both cards on a settled DTO with the standard Card rhythm", () => {
+ it("renders the sleep-debt card on a settled DTO and NOT the chronotype", () => {
rhythmMock.mockReturnValue({
data: READY_DTO,
isLoading: false,
isError: false,
});
const html = render( );
- expect(html).toContain("Intermediate type");
expect(html).toContain("10h 0m short");
- // The cards inherit the house Card rhythm (gap-4 py-4 md:gap-6 md:py-6) —
- // the old compact override must NOT reappear.
- expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
- 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");
+ // The chronotype band lives in the separate bottom section now, never here.
+ expect(html).not.toContain("Intermediate type");
+ expect(html).not.toContain('data-slot="chronotype-band"');
});
it("renders nothing when disabled", () => {
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx
index e7ddfa94e..4918e9813 100644
--- a/src/components/insights/sleep/chronotype-card.tsx
+++ b/src/components/insights/sleep/chronotype-card.tsx
@@ -17,7 +17,10 @@ function clockLabel(minutesOfDay: number): string {
}
/** Whole hours + minutes from a minute total, for the social-jetlag readout. */
-function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
+function hoursMinutes(totalMinutes: number): {
+ hours: number;
+ minutes: number;
+} {
const t = Math.round(totalMinutes);
return { hours: Math.floor(t / 60), minutes: t - Math.floor(t / 60) * 60 };
}
@@ -25,20 +28,21 @@ function hoursMinutes(totalMinutes: number): { hours: number; minutes: number }
/**
* v1.17.0 — chronotype card (MCTQ MSF / MSFsc band + social jetlag).
*
- * Progressive disclosure: the band + mid-sleep clock time show by default; the
- * social-jetlag detail + the sleep-debt-corrected MSFsc sit behind an
- * "advanced" toggle. While `learning` it shows "still learning your rhythm —
- * N of M nights" and asserts NO type. Warm, grounded copy; mobile-first.
+ * v1.18.7 W-D — reworked from a small corner-label card into the PROMINENT
+ * bottom treatment of the Sleep view: a LARGE band label is the hero, with the
+ * natural sleep-midpoint clock beneath it, and an "advanced" disclosure that
+ * reveals social jetlag + the sleep-debt-corrected MSFsc. While `learning` it
+ * keeps the calm "still learning your rhythm — N of M nights" copy and asserts
+ * NO band. Warm, grounded copy; mobile-first.
*/
export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const { t } = useTranslations();
const [showAdvanced, setShowAdvanced] = useState(false);
if (chronotype.state === "learning" || chronotype.band == null) {
- const need =
- chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
+ const need = chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
return (
-
+
@@ -64,40 +68,31 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null;
return (
-
+
- {/* The resolved chronotype rides the top-right corner as a labelled
- readout — a small "Chronotyp" label over the band value — rather
- than a badge buried in the body. The title stays left; the value
- is the thing the eye should land on first. */}
-
-
-
-
- {t("insights.sleep.chronotype.title")}
-
-
-
-
- {t("insights.sleep.chronotype.cornerLabel")}
-
-
- {bandLabel}
-
-
+
+
+
+ {t("insights.sleep.chronotype.title")}
+
-
- {msfClock && (
-
- {t("insights.sleep.chronotype.midSleepCaption", {
- clock: msfClock,
- })}
+
+ {/* The band is the hero of the bottom treatment — a large label the
+ eye lands on first, with the natural sleep-midpoint clock as the
+ grounding caption beneath it. */}
+
+
+ {bandLabel}
- )}
+ {msfClock && (
+
+ {t("insights.sleep.chronotype.midpointHeadline", {
+ clock: msfClock,
+ })}
+
+ )}
+
` (which now owns only the sleep-debt
+ * headline) so the chronotype gets a full-width treatment at the end of the
+ * page: a large band label, the natural sleep-midpoint clock beneath it, and an
+ * expandable disclosure for social jetlag + MSFsc. Reads the SAME
+ * server-authoritative DTO via `useSleepRhythm`, so it shares the
+ * `["sleep-rhythm"]` cache the debt section already warmed — no extra
+ * round-trip. Gated on `enabled` so a source-less account never fetches.
+ *
+ * States mirror the debt section: loading → skeleton, error → quiet inline
+ * notice, settled → the card (which carries its own calm "still learning"
+ * copy for the no-nights-yet case, since the route always returns a full DTO).
+ */
+export function ChronotypeSection({ enabled }: { enabled: boolean }) {
+ const { isAuthenticated } = useAuth();
+ const { t } = useTranslations();
+ const { data, isLoading, isError } = useSleepRhythm(
+ isAuthenticated && enabled,
+ );
+
+ if (!enabled) return null;
+
+ return (
+
+
+ {isError ? (
+
+ {t("insights.sleep.rhythm.loadError")}
+
+ ) : isLoading || !data ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx
index 6a864d9ed..4b5954654 100644
--- a/src/components/insights/sleep/sleep-debt-card.tsx
+++ b/src/components/insights/sleep/sleep-debt-card.tsx
@@ -8,7 +8,10 @@ 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. */
-function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
+function hoursMinutes(totalMinutes: number): {
+ hours: number;
+ minutes: number;
+} {
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes - hours * 60);
return { hours, minutes };
diff --git a/src/components/insights/sleep/sleep-quality-grounded-note.tsx b/src/components/insights/sleep/sleep-quality-grounded-note.tsx
index 11a1000a3..0c8311e0b 100644
--- a/src/components/insights/sleep/sleep-quality-grounded-note.tsx
+++ b/src/components/insights/sleep/sleep-quality-grounded-note.tsx
@@ -39,8 +39,12 @@ function clauseFor(
finding: SleepQualityFinding,
t: (key: string, vars?: Record) => string,
): string {
- const metricLabel = t(`insights.sleepQuality.assessment.metric.${finding.type}`);
- const gradeLabel = t(`insights.sleepQuality.assessment.grade.${finding.grade}`);
+ const metricLabel = t(
+ `insights.sleepQuality.assessment.metric.${finding.type}`,
+ );
+ const gradeLabel = t(
+ `insights.sleepQuality.assessment.grade.${finding.grade}`,
+ );
// Efficiency / performance / consistency are percentages; the headline score
// is a 0–100 index. Both read naturally as a rounded integer.
const value = Math.round(finding.value);
diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx
index 51363ba9a..2da539098 100644
--- a/src/components/insights/sleep/sleep-quality-section.tsx
+++ b/src/components/insights/sleep/sleep-quality-section.tsx
@@ -134,16 +134,28 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
}
/**
- * The quality block's "Einschätzung". The richer AI narrative wins when it
- * exists; until then (or forever, on an account with no AI provider) a
- * deterministic note grounded in the user's own quality averages fills the
- * slot, so the assessment is never the bare "no analysis yet" line.
+ * The quality block's "Einschätzung".
*
- * Both reads are gated on the headline sleep score having data — the same gate
- * the AI route used — so the assessment only surfaces with a quality series to
- * describe. The grounded note reads the SLEEP_SCORE status query (already
- * warmed by ``) to know whether the AI text is absent, so it
- * adds no second round-trip and the two never stack.
+ * v1.18.7 W-D — the assessment slot was broken: the previous wiring disabled
+ * the status fetch whenever the AI narrative was absent, so the card could not
+ * read `hasProvider` and fell back to its `hasProvider: false` default — it
+ * showed the "connect an AI provider" CTA even for accounts that HAVE a
+ * provider connected (the text just hadn't been generated yet), AND it stacked
+ * that CTA under the grounded note. Two assessments, one of them wrong.
+ *
+ * The card now stays enabled (gated only on the metric having data), so it
+ * reports the truthful state from the route:
+ * - no provider → the route's `hasProvider: false` → the card's own
+ * clean "connect an AI provider → AI settings" CTA,
+ * ALONE (the grounded note stands down).
+ * - provider, no text yet → the grounded note fills the slot with a read
+ * built from the user's own quality averages, so the
+ * block is never the bare "no assessment yet" line.
+ * - provider, text/prep → the AI card owns the slot; the grounded note
+ * stands down so the two never stack.
+ *
+ * The grounded note reads the SAME SLEEP_SCORE status query the card warmed, so
+ * it adds no second round-trip.
*/
function SleepQualityAssessmentBlock({
summaries,
@@ -156,30 +168,35 @@ function SleepQualityAssessmentBlock({
hasSleepScore,
);
- // The AI narrative is "present" when there is text OR the worker is actively
- // assembling one (preparing / revalidating). In those cases the AI card owns
- // the slot. The grounded note only steps in once the status query has SETTLED
- // with no narrative — so it never flashes ahead of a warm AI assessment.
- const aiAbsent =
+ // The grounded note only steps in when a provider IS connected but the AI
+ // narrative has SETTLED with no text (and none is being assembled). When no
+ // provider is configured the AI card owns the slot with its own CTA, so the
+ // note never stacks under it; when the AI text exists or is preparing the AI
+ // card wins. Until the status query settles we show nothing extra, so the
+ // note never flashes ahead of a warm AI assessment.
+ const showGroundedNote =
+ hasSleepScore &&
+ status?.hasProvider === true &&
!statusLoading &&
!status?.text &&
status?.preparing !== true &&
status?.revalidating !== true;
+ // Exactly one block fills the assessment slot, so the two never stack: the
+ // grounded note when a provider is connected but the AI text has settled
+ // empty, otherwise the status card (which owns the no-provider CTA, the
+ // preparing state, and the real text). The card's hook fetched regardless of
+ // which branch renders — it runs at the top of this component — so swapping
+ // its render for the note loses no `hasProvider` knowledge.
+ if (showGroundedNote) {
+ return ;
+ }
+
return (
- <>
- }
- // Suppress the AI card's static "no analysis yet" / "no provider"
- // empty states — the grounded note covers that ground with real
- // content. The AI card still renders its text / preparing states.
- enabled={hasSleepScore && !aiAbsent}
- />
-
- >
+ }
+ enabled={hasSleepScore}
+ />
);
}
diff --git a/src/components/insights/sleep/sleep-rhythm-section.tsx b/src/components/insights/sleep/sleep-rhythm-section.tsx
index a88466965..05402d62a 100644
--- a/src/components/insights/sleep/sleep-rhythm-section.tsx
+++ b/src/components/insights/sleep/sleep-rhythm-section.tsx
@@ -2,26 +2,28 @@
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";
-import { ChronotypeCard } from "./chronotype-card";
/**
* v1.17.0 — sleep-rhythm section on the Sleep page.
*
* Reads the server-authoritative sleep-debt + chronotype DTO and renders the
- * sleep-debt headline + the chronotype card. Gated on `enabled` so a
- * source-less account never fetches. The values are identical to what the iOS
- * client renders (one server computation).
+ * sleep-debt headline. Gated on `enabled` so a source-less account never
+ * fetches. The values are identical to what the iOS client renders (one server
+ * computation).
+ *
+ * v1.18.7 W-D — the chronotype card moved out to ``, the
+ * prominent bottom treatment of the Sleep view; this section now owns the
+ * sleep-debt headline alone. Both read the same `["sleep-rhythm"]` cache.
*
* States, mirroring the glucose clinical panel's treatment:
- * - loading → skeletons (distinct from a settled-but-empty read)
+ * - loading → skeleton (distinct from a settled-but-empty read)
* - error → a quiet inline notice, never an endless skeleton
- * - settled → the two cards, which carry their OWN calm "still learning" /
+ * - settled → the debt card, which carries its OWN calm "still learning" /
* source-cue copy for the no-nights-yet case (the route always
- * returns a full DTO, so emptiness surfaces inside the cards).
+ * returns a full DTO, so emptiness surfaces inside the card).
*/
export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
@@ -48,29 +50,9 @@ export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
return (
-
);
}
- // 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 (
-
-
-
-
- );
+ return ;
}
diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx
index d658f3de9..95e44c0ea 100644
--- a/src/components/insights/sub-page-shell.tsx
+++ b/src/components/insights/sub-page-shell.tsx
@@ -315,7 +315,7 @@ export function SubPageShell({
data-slot="metric-reference-guidance"
className={cn(
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
- "align-middle focus-visible:ring-ring/50 rounded-full",
+ "focus-visible:ring-ring/50 rounded-full align-middle",
"focus-visible:ring-2 focus-visible:outline-none",
)}
aria-label={t(
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts
index fbc95a6cb..3ccf5e425 100644
--- a/src/components/insights/use-insights-advisor.ts
+++ b/src/components/insights/use-insights-advisor.ts
@@ -201,9 +201,8 @@ async function fetchAdvisor(
// Lazy schema load — see the type-only import note at the top. The
// module is cached after the first call, so this await is free from
// the second fetch on.
- const { dailyBriefingSchema, trendAnnotationsSchema } = await import(
- "@/lib/ai/schema"
- );
+ const { dailyBriefingSchema, trendAnnotationsSchema } =
+ await import("@/lib/ai/schema");
// The cached `insights` blob may carry a `dailyBriefing` from a fresh
// PROMPT_VERSION 4.20.x generation. Lift it onto the payload so
// consumers don't have to know the legacy shape.
diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx
index 496c9d0ec..2ac0f7cb5 100644
--- a/src/components/insights/workout-detail.tsx
+++ b/src/components/insights/workout-detail.tsx
@@ -75,14 +75,22 @@ function formatDistanceKm(meters: number, locale: string): string {
}).format(km);
}
-function formatNumber(value: number, locale: string, fractionDigits = 0): string {
+function formatNumber(
+ value: number,
+ locale: string,
+ fractionDigits = 0,
+): string {
return new Intl.NumberFormat(locale, {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
}).format(value);
}
-function formatPaceMinPerKm(durationSec: number, meters: number, locale: string): string {
+function formatPaceMinPerKm(
+ durationSec: number,
+ meters: number,
+ locale: string,
+): string {
// Pace is the seconds-per-kilometre value. Only meaningful for
// run / walk / hike. The caller already gated on `meters > 0`.
const secPerKm = (durationSec / meters) * 1000;
@@ -91,7 +99,11 @@ function formatPaceMinPerKm(durationSec: number, meters: number, locale: string)
return `${m}:${s.toString().padStart(2, "0")} ${locale === "en" ? "/km" : "/km"}`;
}
-function formatDateRange(startedAt: string, endedAt: string, locale: string): string {
+function formatDateRange(
+ startedAt: string,
+ endedAt: string,
+ locale: string,
+): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
const dateFmt = new Intl.DateTimeFormat(locale, {
@@ -139,7 +151,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
>
{renderSportIconBadge(workout.sportType)}
-
+
{workout.source}
@@ -262,12 +274,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
// it when the workout has both a distance and a sport with a
// canonical pace unit; the fallback covers the long-tail HK enum
// values without lighting up a misleading number.
- const PACE_SPORTS = new Set([
- "walking",
- "running",
- "hiking",
- "cycling",
- ]);
+ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
if (
workout.distanceM != null &&
workout.distanceM > 100 &&
@@ -401,7 +408,11 @@ export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
// ── Heart-rate chart ─────────────────────────────────────────────────
-export function WorkoutDetailHRChart({ workout }: { workout: WorkoutDetailPayload }) {
+export function WorkoutDetailHRChart({
+ workout,
+}: {
+ workout: WorkoutDetailPayload;
+}) {
const { t } = useTranslations();
// Per-second HR samples are not persisted on the Workout row today —
diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx
index 44d0dce3f..b43bbfb42 100644
--- a/src/components/insights/workout-list.tsx
+++ b/src/components/insights/workout-list.tsx
@@ -98,7 +98,7 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
return (
{workouts.map((workout) => {
const Icon = iconForSport(workout.sportType);
@@ -107,9 +107,8 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
// Translation keys missing from one locale fall back to the
// canonical sport-type string so a new HK enum value never
// renders the raw `insights.workouts.sport.x` placeholder.
- const sportName = sportLabel === sportLabelKey
- ? workout.sportType
- : sportLabel;
+ const sportName =
+ sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
@@ -123,7 +122,7 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
>
diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx
index 056de1a3c..1d88d2a29 100644
--- a/src/components/labs/biomarker-form.tsx
+++ b/src/components/labs/biomarker-form.tsx
@@ -132,7 +132,9 @@ export function BiomarkerForm({
onSuccess?.(saved);
} catch (err) {
const message =
- err instanceof ApiError ? err.message : t("labs.biomarker.form.saveError");
+ err instanceof ApiError
+ ? err.message
+ : t("labs.biomarker.form.saveError");
setError(message);
} finally {
setSubmitting(false);
diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx
index 10e509a08..1bcfce316 100644
--- a/src/components/labs/biomarker-manager.tsx
+++ b/src/components/labs/biomarker-manager.tsx
@@ -167,7 +167,10 @@ export function BiomarkerManager() {
}
description={t("labs.biomarker.defineDescription")}
footer={
-
+
}
>
(() => {
const sorted = [...readings].sort(
- (a, b) =>
- new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
+ (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const days = RANGE_DAYS.find((r) => r.key === range)?.days ?? 0;
const filtered =
diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx
index 64ccb3f2a..80e5f43da 100644
--- a/src/components/labs/lab-biomarker-detail.tsx
+++ b/src/components/labs/lab-biomarker-detail.tsx
@@ -228,7 +228,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.biomarker.editTitle")}
description={t("labs.biomarker.defineDescription")}
footer={
-
+
}
>
{marker ? (
diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx
index b36ceb170..1e8117cfb 100644
--- a/src/components/labs/lab-form.tsx
+++ b/src/components/labs/lab-form.tsx
@@ -194,9 +194,7 @@ export function LabForm({
disabled={!!lockedBiomarkerId || catalogLoading}
>
-
+
{markers.map((m) => (
diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx
index 8254f1429..c094bb45a 100644
--- a/src/components/labs/lab-history-list.tsx
+++ b/src/components/labs/lab-history-list.tsx
@@ -12,7 +12,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Textarea } from "@/components/ui/textarea";
-import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import {
+ ApiError,
+ apiDelete,
+ apiGet,
+ apiPost,
+ apiPut,
+} from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
@@ -110,9 +116,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
closeEdit();
},
onError: (err) => {
- setEditError(
- err instanceof ApiError ? err.message : t("labs.editError"),
- );
+ setEditError(err instanceof ApiError ? err.message : t("labs.editError"));
},
});
@@ -242,9 +246,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
/>
-
- {t("labs.form.takenAt")}
-
+ {t("labs.form.takenAt")}
- new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
+ (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const latest = ordered[ordered.length - 1];
groups.push({
@@ -184,8 +178,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
- {formatLabValue(group.latest.value)}{" "}
- {group.latest.unit}
+ {formatLabValue(group.latest.value)} {group.latest.unit}
{formatDate(group.latest.takenAt)}
diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx
index 176dec090..6fc7982f4 100644
--- a/src/components/labs/labs-settings.tsx
+++ b/src/components/labs/labs-settings.tsx
@@ -81,9 +81,7 @@ export function LabsSettings() {
{t("labs.sort.recentAsc")}
{t("labs.sort.manual")}
-
- {t("labs.sort.hint")}
-
+ {t("labs.sort.hint")}
diff --git a/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts b/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts
index 67893ea11..2ce3fbc61 100644
--- a/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts
+++ b/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts
@@ -17,11 +17,7 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
-const AUTH_SHELL_PATH = resolve(
- __dirname,
- "..",
- "auth-shell.tsx",
-);
+const AUTH_SHELL_PATH = resolve(__dirname, "..", "auth-shell.tsx");
const INSIGHTS_LAYOUT_PATH = resolve(
__dirname,
"..",
@@ -35,9 +31,7 @@ const INSIGHTS_LAYOUT_PATH = resolve(
describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
it("imports the CoachLaunchProvider in the auth-shell", () => {
const source = readFileSync(AUTH_SHELL_PATH, "utf8");
- expect(source).toContain(
- 'from "@/lib/insights/coach-launch-context"',
- );
+ expect(source).toContain('from "@/lib/insights/coach-launch-context"');
expect(source).toContain("CoachLaunchProvider");
});
@@ -62,9 +56,7 @@ describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
it("no longer mounts any Coach surface in the routed insights layout", () => {
const source = readFileSync(INSIGHTS_LAYOUT_PATH, "utf8");
// The provider import drops; the drawer mount lives on the shell.
- expect(source).not.toContain(
- 'from "@/lib/insights/coach-launch-context"',
- );
+ expect(source).not.toContain('from "@/lib/insights/coach-launch-context"');
expect(source).not.toContain("import { LayoutCoachMount }");
// v1.16.8 — the FAB moved to the shell too; the routed layout must
// not double-mount it. (The docblock may still narrate the move, so
diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts
index a694b42b5..0ee21814b 100644
--- a/src/components/layout/__tests__/nav-model.test.ts
+++ b/src/components/layout/__tests__/nav-model.test.ts
@@ -35,12 +35,8 @@ describe("nav-model destination list", () => {
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"),
- );
+ 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", () => {
@@ -65,7 +61,6 @@ describe("nav-model destination list", () => {
const hrefs = NAV_DESTINATIONS.map((d) => d.href);
expect(hrefs).not.toContain("/insights/recovery");
});
-
});
describe("nav-model utility tail (N-1 — one shared list)", () => {
@@ -184,9 +179,9 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
const expectedFeatures = visibleNavDestinations(opts.modules)
.map((d) => d.href)
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
- const expectedTail = visibleUtilityDestinations(
- opts.bugReportEnabled,
- ).map((d) => d.href);
+ const expectedTail = visibleUtilityDestinations(opts.bugReportEnabled).map(
+ (d) => d.href,
+ );
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
});
diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx
index 8e3eba3da..521864e44 100644
--- a/src/components/layout/auth-shell.tsx
+++ b/src/components/layout/auth-shell.tsx
@@ -257,7 +257,7 @@ export function AuthShell({
// wrapper then recentres and the whole column, including the
// admin/settings sidebar, shifts a few px sideways on every
// toggle. Reserving the gutter holds the layout still.
- className="flex-1 overflow-y-auto [scrollbar-gutter:stable] pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
+ className="flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx
index 2ffd8c6a1..f2653962d 100644
--- a/src/components/layout/bottom-nav.tsx
+++ b/src/components/layout/bottom-nav.tsx
@@ -107,7 +107,8 @@ export function BottomNav() {
() =>
PRIMARY_RIGHT.filter(
(item) =>
- !item.requiresModule || user?.modules?.[item.requiresModule] !== false,
+ !item.requiresModule ||
+ user?.modules?.[item.requiresModule] !== false,
),
[user?.modules],
);
@@ -134,7 +135,7 @@ export function BottomNav() {
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
className={cn(
- "relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
+ "focus-visible:ring-ring/50 relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
@@ -180,7 +181,7 @@ export function BottomNav() {
aria-expanded={captureOpen}
onClick={() => setCaptureOpen(true)}
data-testid="bottom-nav-capture"
- className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
+ className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
@@ -196,7 +197,7 @@ export function BottomNav() {
aria-current={moreActive ? "page" : undefined}
onClick={() => setMoreOpen(true)}
className={cn(
- "relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
+ "focus-visible:ring-ring/50 relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
moreActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
@@ -254,7 +255,7 @@ export function BottomNav() {
lets the flex child shrink below its content width,
`break-words` introduces a break inside the long word, and
the auto-height tile (`min-h-14`) absorbs the second line. */}
-
+
{t(item.tKey)}
diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx
index 7228221b4..71b68fa82 100644
--- a/src/components/layout/capture-picker.tsx
+++ b/src/components/layout/capture-picker.tsx
@@ -98,14 +98,17 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
title={t("nav.capture.title")}
description={t("nav.capture.description")}
>
-
+
{options.map((opt) => (
chooseKind(opt.kind)}
- className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2"
+ className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts
index 76fb2f5ed..c8f083d31 100644
--- a/src/components/layout/nav-model.ts
+++ b/src/components/layout/nav-model.ts
@@ -191,7 +191,12 @@ export interface NavUtilityDestination {
}
export const NAV_UTILITY_DESTINATIONS: ReadonlyArray = [
- { href: "/bugreport", tKey: "nav.bugreport", icon: Bug, bugReportGated: true },
+ {
+ href: "/bugreport",
+ tKey: "nav.bugreport",
+ icon: Bug,
+ bugReportGated: true,
+ },
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
];
diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx
index 205107c52..99215c2a4 100644
--- a/src/components/layout/sidebar-nav.tsx
+++ b/src/components/layout/sidebar-nav.tsx
@@ -289,12 +289,15 @@ export function SidebarNav() {
: pathname === href;
}
- function renderUtilityLink(
- item: { href: string; tKey: string; icon: typeof Settings },
- ) {
+ function renderUtilityLink(item: {
+ href: string;
+ tKey: string;
+ icon: typeof Settings;
+ }) {
const Icon = item.icon;
const isActive = isUtilityActive(item.href);
- const tourId = item.href === "/settings/account" ? "nav-settings" : undefined;
+ const tourId =
+ item.href === "/settings/account" ? "nav-settings" : undefined;
if (collapsed) {
return (
diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
index 748b7999e..d9ad228cd 100644
--- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
+++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
@@ -27,8 +28,16 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
import { VorsorgeSection } from "../vorsorge-section";
function render(node: React.ReactNode) {
+ // v1.18.7 (Wave E) — a measurement-linked card now mounts the 7-day
+ // trend strip, which reads via TanStack Query; wrap in a client so the
+ // static-markup render resolves (the query stays idle on the server).
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
return renderToStaticMarkup(
- {node} ,
+
+ {node}
+ ,
);
}
diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
index 817439aab..c10525f20 100644
--- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
+++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
@@ -22,9 +22,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { ListRow } from "@/components/ui/list-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
+import { VorsorgeTrendStrip } from "@/components/measurement-reminders/vorsorge-trend-strip";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -63,8 +65,9 @@ export function VorsorgeDashboardCard() {
const { satisfy } = useMeasurementReminderMutations();
const [now] = useState(() => Date.now());
const [capturing, setCapturing] = useState(null);
- const [captureFooterEl, setCaptureFooterEl] =
- useState(null);
+ const [captureFooterEl, setCaptureFooterEl] = useState(
+ null,
+ );
// Active reminders only (the manage toggles hide disabled ones from the
// summary), most-urgent first as the API already sorts them.
@@ -118,38 +121,49 @@ export function VorsorgeDashboardCard() {
const due = relativeDueKey(reminder.nextDueAt, now);
const isLinked = reminder.measurementType != null;
return (
-
-
-
- {resolveLabel(reminder, t)}
-
-
- {t(`measurementReminders.${due.key}`, { days: due.days })}
-
-
- onPrimaryAction(reminder)}
- disabled={satisfy.isPending}
- >
- {isLinked ? (
- <>
-
- {t("measurementReminders.captureValue")}
- >
- ) : (
- <>
-
- {t("measurementReminders.markDone")}
- >
- )}
-
-
+
+
+
+ {resolveLabel(reminder, t)}
+
+
+ {t(`measurementReminders.${due.key}`, {
+ days: due.days,
+ })}
+
+ {/* v1.18.7 (Wave E) — discreet 7-day strip under the
+ metric context; renders nothing for a free-text
+ reminder or a too-thin window. */}
+
+
+ onPrimaryAction(reminder)}
+ disabled={satisfy.isPending}
+ >
+ {isLinked ? (
+ <>
+
+ {t("measurementReminders.captureValue")}
+ >
+ ) : (
+ <>
+
+ {t("measurementReminders.markDone")}
+ >
+ )}
+
+
+
);
})}
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 0ade64e1e..4002f1cd5 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -65,13 +65,14 @@ import {
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { MoreVertical, Pencil, Trash2 } from "lucide-react";
+import { LineChart, MoreVertical, Pencil, Trash2 } from "lucide-react";
import { NativeSelect } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
-import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
+import { VorsorgeTrendStrip } from "@/components/measurement-reminders/vorsorge-trend-strip";
+import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -209,8 +210,7 @@ export function VorsorgeSection({
}) {
const { t } = useTranslations();
const { data: reminders, isLoading } = useMeasurementReminders(enabled);
- const { create, update, remove, satisfy } =
- useMeasurementReminderMutations();
+ const { create, update, remove, satisfy } = useMeasurementReminderMutations();
// v1.18.6 (MOD-03) — page view (cards/list) + manual order persist
// client-side; the settings page writes them, the page reads them.
const { prefs } = useModuleListPrefs("vorsorge");
@@ -223,8 +223,9 @@ export function VorsorgeSection({
// The value-capture sheet: holds the reminder whose measurement we are
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
const [capturing, setCapturing] = useState(null);
- const [captureFooterEl, setCaptureFooterEl] =
- useState(null);
+ const [captureFooterEl, setCaptureFooterEl] = useState(
+ null,
+ );
// 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
@@ -639,10 +640,7 @@ export function VorsorgeSection({
{isLoading && (
-
+
{Array.from({ length: 4 }, (_, i) => (
@@ -754,8 +752,7 @@ function VorsorgeCard({
// Due now / overdue ⇒ the action button takes the green "do it now" tone.
// The CARD stays neutral (no tint) per the project rule — only the action
// button goes green; the surface follows the medication-card grammar.
- const isDue =
- due.key === "nextDue.today" || due.key === "overdueByDays";
+ const isDue = due.key === "nextDue.today" || due.key === "overdueByDays";
const progress = intervalProgress(reminder, now);
// Category-style header badge: the measurement label, or "self-planned"
@@ -793,8 +790,8 @@ function VorsorgeCard({
@@ -805,6 +802,21 @@ function VorsorgeCard({
{t("measurementReminders.edit")}
+ {/* v1.18.7 (Wave E) — jump to the measurements list pre-filtered to
+ this reminder's type. Only for measurement-linked reminders; a
+ free-text reminder has no readings to show. */}
+ {isLinked && (
+
+
+
+ {t("measurementReminders.showMeasurements")}
+
+
+ )}
{nextLastSlot}
+ {/* v1.18.7 (Wave E) — discreet 7-day strip of the metric's last
+ readings, sitting under the metric context like the dashboard
+ tiles. Renders nothing for a free-text reminder or a too-thin
+ window, so the card height stays stable when absent. */}
+
+
{/* v1.18.6 (MOD-06) — light gamification: progress through the
current interval toward next-due, reusing the medication card's
Progress building block. Honest "on track / due" signal without
diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx
index 1d2820fa0..e710f983c 100644
--- a/src/components/measurement-reminders/vorsorge-settings.tsx
+++ b/src/components/measurement-reminders/vorsorge-settings.tsx
@@ -56,7 +56,9 @@ export function VorsorgeSettings() {
-
{t("moduleList.viewHeading")}
+
+ {t("moduleList.viewHeading")}
+
diff --git a/src/components/measurement-reminders/vorsorge-trend-strip.tsx b/src/components/measurement-reminders/vorsorge-trend-strip.tsx
new file mode 100644
index 000000000..8250e273d
--- /dev/null
+++ b/src/components/measurement-reminders/vorsorge-trend-strip.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+/**
+ * v1.18.7 (Wave E) — a small, DISCREET 7-day trend strip for a Vorsorge
+ * card. Renders the reminder metric's last seven readings as seven quiet
+ * bars (newest → rightmost), heights normalised across the window. The
+ * colour is intentionally muted (`bg-muted-foreground/30`) so the strip
+ * reads as calm context sitting under the metric — never an alarming
+ * status signal, in keeping with the no-loud-colour card ethos.
+ *
+ * Pulls the bounded last-7 readings for one `MeasurementType` through the
+ * existing measurements list endpoint (a single `limit=7` read per card,
+ * cached by type) — no new heavy aggregate query. A free-text reminder
+ * (no `measurementType`) renders nothing; a metric with fewer than two
+ * readings in the window renders nothing (a single dot is no trend).
+ */
+import { useQuery } from "@tanstack/react-query";
+
+import { apiGet } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+interface RecentMeasurement {
+ value: number;
+ measuredAt: string;
+}
+
+/**
+ * Bounded last-7 read for one measurement type, sorted newest-first by the
+ * server. Re-sorted oldest → newest here so the bars read left (older) to
+ * right (newest). Shares the `measurements` invalidation prefix so a fresh
+ * reading (e.g. completing this very reminder) repaints the strip.
+ */
+function useRecentValues(measurementType: string | null, enabled: boolean) {
+ return useQuery({
+ queryKey: queryKeys.measurementRecentValues(measurementType ?? ""),
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ params.set("type", measurementType as string);
+ params.set("limit", "7");
+ params.set("sortBy", "measuredAt");
+ params.set("sortDir", "desc");
+ const res = await apiGet<{ measurements: RecentMeasurement[] }>(
+ `/api/measurements?${params}`,
+ );
+ return res.measurements.slice().reverse();
+ },
+ enabled: enabled && measurementType != null,
+ });
+}
+
+export function VorsorgeTrendStrip({
+ measurementType,
+ enabled = true,
+}: {
+ measurementType: string | null;
+ enabled?: boolean;
+}) {
+ const { t } = useTranslations();
+ const { data } = useRecentValues(measurementType, enabled);
+
+ // Nothing to show for a free-text reminder or a too-thin window.
+ if (measurementType == null) return null;
+ const readings = data ?? [];
+ if (readings.length < 2) return null;
+
+ const values = readings.map((r) => r.value);
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = max - min || 1;
+
+ // 7-day direction from first → last reading in the window. Latest value
+ // and direction feed the sr-only summary so a screen reader gets the
+ // trend, not just the generic "Last 7 days" label.
+ const latest = values[values.length - 1];
+ const delta = latest - values[0];
+ const direction =
+ delta > 0
+ ? t("measurementReminders.trendStrip.rising")
+ : delta < 0
+ ? t("measurementReminders.trendStrip.falling")
+ : t("measurementReminders.trendStrip.steady");
+
+ return (
+
+
+ {t("measurementReminders.trendStrip.summary", {
+ value: latest,
+ direction,
+ })}
+
+ {readings.map((r, i) => {
+ // Floor at ~18% so a flat series still reads as bars, not a line.
+ const pct = 0.18 + ((r.value - min) / span) * 0.82;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx
index b836fb2d8..b7615f19d 100644
--- a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx
+++ b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx
@@ -94,7 +94,7 @@ describe("MeasurementList — filter bar + multi-select chrome", () => {
// The shared Checkbox primitive carries data-slot="checkbox".
const boxes = html.match(/data-slot="checkbox"/g);
// 1 header select-all + 2 selectable rows (desktop) + 2 rows (mobile).
- expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3);
+ expect(boxes?.length ?? 0).toBeGreaterThanOrEqual(3);
expect(html).toContain('aria-label="Select all on this page"');
expect(html).toContain('aria-label="Select row"');
});
diff --git a/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx
index fdd67168f..3eb426017 100644
--- a/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx
+++ b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx
@@ -30,10 +30,7 @@ const collapsed = [
// triggers).
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
- if (
- Array.isArray(queryKey) &&
- queryKey[0] === "measurement-drilldown"
- ) {
+ if (Array.isArray(queryKey) && queryKey[0] === "measurement-drilldown") {
return { data: undefined, isLoading: false };
}
return {
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx
index f8d40ddb2..49e368be3 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
+import { ListRow } from "@/components/ui/list-row";
import {
FilterBar,
FilterBarDateRange,
@@ -179,6 +180,13 @@ interface MeasurementListProps {
* scoped to this type; the count caption still shows.
*/
lockedType?: string;
+ /**
+ * v1.18.7 (Wave E) — seed the (still user-editable) type filter from a
+ * `?type=` deep link, e.g. the Vorsorge card's "Show
+ * measurements" action. Unlike `lockedType` the full filter rail stays
+ * visible so the user can widen or change the filter afterwards.
+ */
+ initialType?: string;
}
const PAGE_SIZE = 25;
@@ -263,6 +271,7 @@ export function MeasurementList({
onEdit,
onAddFirst,
lockedType,
+ initialType,
}: MeasurementListProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
@@ -270,7 +279,11 @@ export function MeasurementList({
const queryClient = useQueryClient();
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
// and the type selector is hidden; the filter state seeds from it.
- const [typeFilter, setTypeFilterRaw] = useState(lockedType ?? "ALL");
+ // v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
+ // same filter but keeps the rail interactive so the user can change it.
+ const [typeFilter, setTypeFilterRaw] = useState(
+ lockedType ?? initialType ?? "ALL",
+ );
// v1.15.13 — management-list source filter + optional date range.
// `ALL` clears the source filter; empty date strings clear the bound.
const [sourceFilter, setSourceFilterRaw] = useState("ALL");
@@ -1068,10 +1081,10 @@ export function MeasurementList({
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
const isSelected = selectedIds.has(m.id);
return (
-
@@ -1231,7 +1244,7 @@ export function MeasurementList({
/>
)}
-
+
);
})}
@@ -1496,13 +1509,15 @@ function DayDrillDown({
const { t, locale } = useTranslations();
const { isAuthenticated } = useAuth();
const { data, isLoading, error } = useQuery({
- queryKey: ["measurement-drilldown", type, dayKey],
+ queryKey: queryKeys.measurementDrilldown(type, dayKey),
queryFn: async () => {
const params = new URLSearchParams();
params.set("type", type);
params.set("dayKey", dayKey);
params.set("sortDir", "asc");
- return apiGet<{ measurements: Measurement[] }>(`/api/measurements?${params}`);
+ return apiGet<{ measurements: Measurement[] }>(
+ `/api/measurements?${params}`,
+ );
},
enabled: isAuthenticated,
// The drill-down is per-day — once fetched it rarely needs to
diff --git a/src/components/medications/__tests__/add-inventory-dialog.test.tsx b/src/components/medications/__tests__/add-inventory-dialog.test.tsx
index ce4c209b8..949394327 100644
--- a/src/components/medications/__tests__/add-inventory-dialog.test.tsx
+++ b/src/components/medications/__tests__/add-inventory-dialog.test.tsx
@@ -68,9 +68,7 @@ vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
-const { AddInventoryDialog } = await import(
- "../sections/inventory-section"
-);
+const { AddInventoryDialog } = await import("../sections/inventory-section");
function render(node: React.ReactNode, locale: "en" | "de" = "de"): string {
return renderToStaticMarkup(
@@ -178,7 +176,7 @@ describe("", () => {
it("bounds the quantity to 1000 units (dose mode scales the max down)", () => {
expect(src).toContain("units >= 1 && units <= 1000");
- expect(src).toContain('Math.floor(1000 / unitsPerDose)');
+ expect(src).toContain("Math.floor(1000 / unitsPerDose)");
});
it("maps the delivery form onto the register default (PEN for INJECTION, BLISTER for ORAL, OTHER otherwise)", () => {
diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx
index bd7f62106..3e91933c3 100644
--- a/src/components/medications/__tests__/card-parts.test.tsx
+++ b/src/components/medications/__tests__/card-parts.test.tsx
@@ -45,7 +45,9 @@ function render(node: React.ReactNode, client?: QueryClient) {
) : (
node
);
- return renderToStaticMarkup({tree} );
+ return renderToStaticMarkup(
+ {tree} ,
+ );
}
describe("medication card-parts — shared presentational components", () => {
@@ -170,7 +172,10 @@ describe("medication card-parts — shared presentational components", () => {
it("intake actions row carries exactly the take + skip buttons", () => {
const html = render(
- {}} />,
+ {}}
+ />,
);
expect(html).toContain("lucide-check");
expect(html).toContain("lucide-skip-forward");
@@ -253,7 +258,11 @@ describe("medication cycle status — open-cycle line", () => {
// The calm on_track "next dose in N days" phrasing duplicates the
// next-intake slot at the top of the card, so the open-cycle line stays
// silent for an on-track med; only due / overdue earn a line here.
- for (const nextDueAt of [inDays(4), inDays(1), new Date(Date.now() + 6 * 60 * 60 * 1000)]) {
+ for (const nextDueAt of [
+ inDays(4),
+ inDays(1),
+ new Date(Date.now() + 6 * 60 * 60 * 1000),
+ ]) {
const html = render(
{}}
diff --git a/src/components/medications/__tests__/detail-page-cascade.test.tsx b/src/components/medications/__tests__/detail-page-cascade.test.tsx
index 6e3a17c95..2d7d433b4 100644
--- a/src/components/medications/__tests__/detail-page-cascade.test.tsx
+++ b/src/components/medications/__tests__/detail-page-cascade.test.tsx
@@ -23,10 +23,7 @@
*/
import { describe, it, expect } from "vitest";
-import {
- medicationDependentKeys,
- queryKeys,
-} from "@/lib/query-keys";
+import { medicationDependentKeys, queryKeys } from "@/lib/query-keys";
function asStrings(keys: readonly unknown[]): string[] {
return keys.map((k) => JSON.stringify(k));
@@ -41,7 +38,7 @@ function isUnderPrefix(key: readonly unknown[], prefix: readonly unknown[]) {
}
describe("medication-detail-page query-key cascade (D-3 §9)", () => {
- it("§9.1 header reads sit under the `[\"medications\"]` prefix so the bundle catches them", () => {
+ it('§9.1 header reads sit under the `["medications"]` prefix so the bundle catches them', () => {
const detail = queryKeys.medicationDetail("med-1");
expect(isUnderPrefix(detail, queryKeys.medications())).toBe(true);
});
@@ -68,7 +65,7 @@ describe("medication-detail-page query-key cascade (D-3 §9)", () => {
expect(phaseKey).toEqual(["phase-config", "med-1"]);
});
- it("§9.5 intake-history list keys ride under `[\"medications\", id, \"intake\", \"list\"]`", () => {
+ it('§9.5 intake-history list keys ride under `["medications", id, "intake", "list"]`', () => {
const listKey = queryKeys.medicationIntakeList("med-1", {
sortBy: "takenAt",
sortDir: "desc",
@@ -86,7 +83,7 @@ describe("medication-detail-page query-key cascade (D-3 §9)", () => {
expect(isUnderPrefix(listKey, queryKeys.medications())).toBe(true);
});
- it("§9.7 api-endpoint per-medication slot rides under the `[\"medications\"]` prefix", () => {
+ it('§9.7 api-endpoint per-medication slot rides under the `["medications"]` prefix', () => {
// The `` uses a per-medication key
// `["medications", id, "api-endpoint"]` so the bundle catches it
// alongside every other medication-scoped tile.
diff --git a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts
index b61b2bdf0..420d73a1b 100644
--- a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts
+++ b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts
@@ -22,7 +22,13 @@ function slotRow(
status: LedgerRow["status"],
intake?: LedgerRow["intake"],
): LedgerRow {
- return { kind: "slot", at, timeOfDay: "07:00", status, intake: intake ?? null };
+ return {
+ kind: "slot",
+ at,
+ timeOfDay: "07:00",
+ status,
+ intake: intake ?? null,
+ };
}
describe("complianceFromLedger", () => {
@@ -156,9 +162,7 @@ describe("applyOptimisticSlotMark", () => {
"2026-06-01T05:00:00.000Z",
"taken",
);
- const marked = next.rows.find(
- (r) => r.at === "2026-06-01T05:00:00.000Z",
- )!;
+ const marked = next.rows.find((r) => r.at === "2026-06-01T05:00:00.000Z")!;
expect(marked.status).toBe("taken_on_time");
expect(marked.intake?.takenAt).toBe("2026-06-01T05:00:00.000Z");
expect(marked.intake?.skipped).toBe(false);
@@ -171,9 +175,7 @@ describe("applyOptimisticSlotMark", () => {
"2026-06-01T05:00:00.000Z",
"skipped",
);
- const marked = next.rows.find(
- (r) => r.at === "2026-06-01T05:00:00.000Z",
- )!;
+ const marked = next.rows.find((r) => r.at === "2026-06-01T05:00:00.000Z")!;
expect(marked.status).toBe("skipped");
expect(marked.intake?.skipped).toBe(true);
// skip excluded → denominator drops to 0 → null rate.
diff --git a/src/components/medications/__tests__/dose-strength-curve.test.tsx b/src/components/medications/__tests__/dose-strength-curve.test.tsx
index 601a446bb..a3eeae814 100644
--- a/src/components/medications/__tests__/dose-strength-curve.test.tsx
+++ b/src/components/medications/__tests__/dose-strength-curve.test.tsx
@@ -30,10 +30,7 @@ vi.mock("@tanstack/react-query", () => ({
},
}));
-import {
- DoseStrengthCurve,
- buildCurvePoints,
-} from "../dose-strength-curve";
+import { DoseStrengthCurve, buildCurvePoints } from "../dose-strength-curve";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -125,9 +122,7 @@ describe("", () => {
it("renders the empty state with no current dose for zero rows", () => {
setQueryResult(KEY, { doseChanges: [] });
- const html = render(
- ,
- );
+ const html = render( );
expect(html).toContain('data-slot="dose-strength-curve-empty"');
expect(html).toContain("No dose recorded yet.");
@@ -147,9 +142,7 @@ describe("", () => {
],
});
- const html = render(
- ,
- );
+ const html = render( );
expect(html).toContain('data-slot="dose-strength-curve-empty"');
expect(html).toContain("Current dose: 5 mg");
@@ -174,9 +167,7 @@ describe("", () => {
],
});
- const html = render(
- ,
- );
+ const html = render( );
expect(html).toContain('data-slot="dose-strength-curve-area"');
expect(html).toContain('data-slot="dose-strength-curve-caption"');
diff --git a/src/components/medications/__tests__/DrugLevelChart.test.tsx b/src/components/medications/__tests__/drug-level-chart.test.tsx
similarity index 98%
rename from src/components/medications/__tests__/DrugLevelChart.test.tsx
rename to src/components/medications/__tests__/drug-level-chart.test.tsx
index 684ce4671..a509dbcd3 100644
--- a/src/components/medications/__tests__/DrugLevelChart.test.tsx
+++ b/src/components/medications/__tests__/drug-level-chart.test.tsx
@@ -56,10 +56,9 @@ const pkCalls: Array<{
}> = [];
vi.mock("@/lib/medications/glp1-pk", async () => {
- const actual =
- await vi.importActual(
- "@/lib/medications/glp1-pk",
- );
+ const actual = await vi.importActual<
+ typeof import("@/lib/medications/glp1-pk")
+ >("@/lib/medications/glp1-pk");
return {
...actual,
computeOneCompartment: (
@@ -84,7 +83,7 @@ import {
DrugLevelChart,
parseDoseMg,
resolveDoseMg,
-} from "../DrugLevelChart";
+} from "../drug-level-chart";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
diff --git a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx
index 51cebe654..efd0f2435 100644
--- a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx
+++ b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx
@@ -102,7 +102,12 @@ describe("IntakeEditDialog guardrails (P0-4)", () => {
const html = renderToStaticMarkup(
{}}
/>,
);
@@ -120,7 +125,12 @@ describe("IntakeEditDialog guardrails (P0-4)", () => {
const html = renderToStaticMarkup(
{}}
/>,
);
diff --git a/src/components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx b/src/components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx
index b55223064..e336ca125 100644
--- a/src/components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx
+++ b/src/components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx
@@ -86,10 +86,7 @@ describe(" — F-1 C-2 row actions", () => {
it("does NOT render the kebab when no callbacks are passed (back-compat)", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
- const html = render(
- ,
- client,
- );
+ const html = render( , client);
expect(html).not.toContain("intake-history-row-kebab");
});
@@ -97,10 +94,7 @@ describe(" — F-1 C-2 row actions", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
const html = render(
- {}}
- />,
+ {}} />,
client,
);
expect(html).toContain("intake-history-row-kebab");
@@ -110,10 +104,7 @@ describe(" — F-1 C-2 row actions", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
const html = render(
- {}}
- />,
+ {}} />,
client,
);
expect(html).toContain("intake-history-row-kebab");
@@ -122,10 +113,7 @@ describe(" — F-1 C-2 row actions", () => {
it("does NOT render the selection column when selection prop is absent", () => {
const client = makeClient();
seed(client, "med-1", [ROW]);
- const html = render(
- ,
- client,
- );
+ const html = render( , client);
expect(html).not.toContain("intake-history-row-select");
});
diff --git a/src/components/medications/__tests__/log-injection-site-dialog.test.tsx b/src/components/medications/__tests__/log-injection-site-dialog.test.tsx
index 2dd543e20..7e7ba9dee 100644
--- a/src/components/medications/__tests__/log-injection-site-dialog.test.tsx
+++ b/src/components/medications/__tests__/log-injection-site-dialog.test.tsx
@@ -30,8 +30,13 @@ vi.mock("@/components/ui/dialog", () => {
{children}
);
return {
- Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
- open ? {children}
: null,
+ Dialog: ({
+ open,
+ children,
+ }: {
+ open: boolean;
+ children: React.ReactNode;
+ }) => (open ? {children}
: null),
DialogContent: Pass,
DialogDescription: Pass,
DialogFooter: Pass,
@@ -74,7 +79,9 @@ describe("", () => {
});
it("awaits onConfirm behind a submitting guard and re-enables in finally", () => {
- expect(src).toContain("const [submitting, setSubmitting] = useState(false)");
+ expect(src).toContain(
+ "const [submitting, setSubmitting] = useState(false)",
+ );
expect(src).toMatch(
/async function handleConfirm\(\) \{[\s\S]*setSubmitting\(true\)[\s\S]*await onConfirm\(selected\)[\s\S]*finally \{[\s\S]*setSubmitting\(false\)/,
);
diff --git a/src/components/medications/__tests__/MedicationCardHeader.test.tsx b/src/components/medications/__tests__/medication-card-header.test.tsx
similarity index 95%
rename from src/components/medications/__tests__/MedicationCardHeader.test.tsx
rename to src/components/medications/__tests__/medication-card-header.test.tsx
index baa0d60ba..f38b5ff0d 100644
--- a/src/components/medications/__tests__/MedicationCardHeader.test.tsx
+++ b/src/components/medications/__tests__/medication-card-header.test.tsx
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
-import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
+import { MedicationCardHeader } from "@/components/medications/medication-card-header";
/**
* v1.4.28 — narrow-viewport contract for the shared medication-list
@@ -75,9 +75,7 @@ describe("", () => {
}
/>,
);
- expect(html).toContain(
- 'data-slot="medication-card-header-state-badges"',
- );
+ expect(html).toContain('data-slot="medication-card-header-state-badges"');
// Both badges land inside the state row, not adjacent to the
// category badge. Verifying via document order: the state-badges
// wrapper must paint AFTER the category badge.
diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx
index 5059cda57..c0bc21fc6 100644
--- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx
+++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx
@@ -185,9 +185,7 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
expect(html).toContain('aria-label="More options"');
expect(html).not.toContain("lucide-chevron-right");
// The card body itself navigates to the detail page.
- expect(html).toContain(
- 'data-slot="medication-card-header-link"',
- );
+ expect(html).toContain('data-slot="medication-card-header-link"');
expect(html).toContain(`href="${href}"`);
}
});
diff --git a/src/components/medications/__tests__/medication-table.test.tsx b/src/components/medications/__tests__/medication-table.test.tsx
index 39e207b61..30cb0d91b 100644
--- a/src/components/medications/__tests__/medication-table.test.tsx
+++ b/src/components/medications/__tests__/medication-table.test.tsx
@@ -90,7 +90,9 @@ const pastWindow = {
dose: null,
};
-function med(partial: Partial & { id: string; name: string }): TableMedication {
+function med(
+ partial: Partial & { id: string; name: string },
+): TableMedication {
return {
dose: "5 mg",
active: true,
@@ -442,7 +444,12 @@ describe("nextSortState — tri-state cycle", () => {
});
describe("sortMedicationRows — null-last semantics", () => {
- const a = med({ id: "a", name: "Alpha", nextDueAt: null, stockDosesRemaining: null });
+ const a = med({
+ id: "a",
+ name: "Alpha",
+ nextDueAt: null,
+ stockDosesRemaining: null,
+ });
const b = med({
id: "b",
name: "Beta",
@@ -457,9 +464,11 @@ describe("sortMedicationRows — null-last semantics", () => {
});
it("returns the input order untouched for a null sort", () => {
- expect(
- sortMedicationRows([b, a, c], null).map((m) => m.id),
- ).toEqual(["b", "a", "c"]);
+ expect(sortMedicationRows([b, a, c], null).map((m) => m.id)).toEqual([
+ "b",
+ "a",
+ "c",
+ ]);
});
it("sorts by next due ascending, rows without a due date last", () => {
diff --git a/src/components/medications/__tests__/ResearchModeAcknowledgmentDialog.test.tsx b/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx
similarity index 99%
rename from src/components/medications/__tests__/ResearchModeAcknowledgmentDialog.test.tsx
rename to src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx
index 9b6d70da1..a1d4f0da0 100644
--- a/src/components/medications/__tests__/ResearchModeAcknowledgmentDialog.test.tsx
+++ b/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx
@@ -94,7 +94,7 @@ vi.mock("@/components/ui/responsive-sheet", () => ({
) : null,
}));
-import { ResearchModeAcknowledgmentDialog } from "../ResearchModeAcknowledgmentDialog";
+import { ResearchModeAcknowledgmentDialog } from "../research-mode-acknowledgment-dialog";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
diff --git a/src/components/medications/__tests__/SideEffectsSection.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx
similarity index 98%
rename from src/components/medications/__tests__/SideEffectsSection.test.tsx
rename to src/components/medications/__tests__/side-effects-section.test.tsx
index d33b133c9..fb1db7141 100644
--- a/src/components/medications/__tests__/SideEffectsSection.test.tsx
+++ b/src/components/medications/__tests__/side-effects-section.test.tsx
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
-import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
+import { SideEffectsSection } from "@/components/medications/side-effects-section";
/**
* v1.4.25 W19d — SideEffectsSection SSR smoke tests.
diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts
index e30ead1f3..d1c11ad56 100644
--- a/src/components/medications/__tests__/supply-runway.test.ts
+++ b/src/components/medications/__tests__/supply-runway.test.ts
@@ -26,9 +26,7 @@ function schedule(partial: Partial): RunwaySchedule {
describe("estimateDailyDoseCount", () => {
it("counts a plain daily schedule as its times-of-day per day", () => {
expect(
- estimateDailyDoseCount([
- schedule({ timesOfDay: ["08:00", "20:00"] }),
- ]),
+ estimateDailyDoseCount([schedule({ timesOfDay: ["08:00", "20:00"] })]),
).toBe(2);
});
@@ -88,9 +86,9 @@ describe("estimateRunwayDays", () => {
});
it("floors to whole days", () => {
- expect(estimateRunwayDays(5, [schedule({ timesOfDay: ["08:00", "20:00"] })])).toBe(
- 2,
- );
+ expect(
+ estimateRunwayDays(5, [schedule({ timesOfDay: ["08:00", "20:00"] })]),
+ ).toBe(2);
});
it("returns null when no supply remains", () => {
@@ -159,26 +157,42 @@ describe("lowStockTriggerDays — v1.17.0", () => {
it("keeps a daily med with no lead at the bare floor (never shrinks)", () => {
expect(
- lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 0, schedules: daily }),
+ lowStockTriggerDays({
+ lowStockRunwayDays: 7,
+ leadDays: 0,
+ schedules: daily,
+ }),
).toBe(7);
});
it("widens a weekly med to cover the reorder lead PLUS one dose-interval", () => {
// max(7, 10 lead + 7 interval) = 17 → fires ~10 days before the last dose.
expect(
- lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: weekly }),
+ lowStockTriggerDays({
+ lowStockRunwayDays: 7,
+ leadDays: 10,
+ schedules: weekly,
+ }),
).toBe(17);
});
it("never drops below the user floor even when lead + cadence are small", () => {
expect(
- lowStockTriggerDays({ lowStockRunwayDays: 14, leadDays: 2, schedules: daily }),
+ lowStockTriggerDays({
+ lowStockRunwayDays: 14,
+ leadDays: 2,
+ schedules: daily,
+ }),
).toBe(14);
});
it("falls back to the bare floor for a schedule-less medication", () => {
expect(
- lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: [] }),
+ lowStockTriggerDays({
+ lowStockRunwayDays: 7,
+ leadDays: 10,
+ schedules: [],
+ }),
).toBe(7);
});
});
@@ -188,19 +202,31 @@ describe("classifyLowStockState — v1.17.0", () => {
it("returns null when comfortably above the trigger", () => {
expect(
- classifyLowStockState({ runwayDays: 20, triggerDays: 17, schedules: weekly }),
+ classifyLowStockState({
+ runwayDays: 20,
+ triggerDays: 17,
+ schedules: weekly,
+ }),
).toBeNull();
});
it("flags running_low within the trigger but above one cadence interval", () => {
expect(
- classifyLowStockState({ runwayDays: 14, triggerDays: 17, schedules: weekly }),
+ classifyLowStockState({
+ runwayDays: 14,
+ triggerDays: 17,
+ schedules: weekly,
+ }),
).toBe("running_low");
});
it("flags last_dose at one cadence interval", () => {
expect(
- classifyLowStockState({ runwayDays: 7, triggerDays: 17, schedules: weekly }),
+ classifyLowStockState({
+ runwayDays: 7,
+ triggerDays: 17,
+ schedules: weekly,
+ }),
).toBe("last_dose");
});
});
diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx
index 827b87084..252558c71 100644
--- a/src/components/medications/__tests__/take-all-due.test.tsx
+++ b/src/components/medications/__tests__/take-all-due.test.tsx
@@ -386,7 +386,13 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
const slotA = new Date(Date.now() - 60 * 60 * 1000);
const slotB = new Date(Date.now() - 30 * 60 * 1000);
const dueTwo: DueMedication[] = [
- { id: "med-a", name: "A", dose: "10 mg", window: null, scheduledFor: slotA },
+ {
+ id: "med-a",
+ name: "A",
+ dose: "10 mg",
+ window: null,
+ scheduledFor: slotA,
+ },
{ id: "med-b", name: "B", dose: "5 mg", window: null, scheduledFor: slotB },
];
@@ -489,10 +495,7 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
// The bundle includes the dashboard snapshot key (v1.16.11), so a
// batch take refreshes the hero dose tally too. Pin the source so a
// refactor to a hand-rolled key list cannot slip through.
- const src = readFileSync(
- resolve(__dirname, "../take-all-due.ts"),
- "utf8",
- );
+ const src = readFileSync(resolve(__dirname, "../take-all-due.ts"), "utf8");
expect(src).toContain(
"await invalidateKeys(queryClient, medicationDependentKeys)",
);
diff --git a/src/components/medications/__tests__/titration-timeline.test.ts b/src/components/medications/__tests__/titration-timeline.test.ts
index 4ffb3669c..7727d4917 100644
--- a/src/components/medications/__tests__/titration-timeline.test.ts
+++ b/src/components/medications/__tests__/titration-timeline.test.ts
@@ -64,7 +64,12 @@ describe("buildTitrationSteps", () => {
[
change("a", "2026-04-01T00:00:00Z", 2.5),
change("bad", "not-a-date", 5),
- { id: "nan", effectiveFrom: "2026-05-01T00:00:00Z", doseValue: NaN, doseUnit: "mg" },
+ {
+ id: "nan",
+ effectiveFrom: "2026-05-01T00:00:00Z",
+ doseValue: NaN,
+ doseUnit: "mg",
+ },
],
ASOF,
);
diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts
index f87903eeb..a7bf9078e 100644
--- a/src/components/medications/__tests__/use-medication-intake.test.ts
+++ b/src/components/medications/__tests__/use-medication-intake.test.ts
@@ -181,7 +181,9 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
- text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id: "evt-am" } })),
+ text: vi
+ .fn()
+ .mockResolvedValue(JSON.stringify({ data: { id: "evt-am" } })),
});
vi.stubGlobal("fetch", fetchMock);
@@ -319,7 +321,10 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
expect(url).toBe("/api/medications/med-1/intake");
const body = JSON.parse((init as RequestInit).body as string);
// Backdated instant is carried verbatim; no slot → no scheduledFor.
- expect(body).toEqual({ skipped: false, takenAt: "2026-06-01T08:30:00.000Z" });
+ expect(body).toEqual({
+ skipped: false,
+ takenAt: "2026-06-01T08:30:00.000Z",
+ });
expect(body).not.toHaveProperty("scheduledFor");
expect(queryClient.invalidateQueries).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith(
@@ -381,7 +386,10 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
});
it("surfaces the failure toast and returns false on a non-ok POST", async () => {
- vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, json: vi.fn() }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({ ok: false, json: vi.fn() }),
+ );
const queryClient = fakeQueryClient();
const ok = await runLogIntake({
diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts
index 1f7d77f56..667ad028f 100644
--- a/src/components/medications/card-parts/displayed-slot-instant.ts
+++ b/src/components/medications/card-parts/displayed-slot-instant.ts
@@ -134,8 +134,12 @@ function parseHm(value: string): { hour: number; minute: number } | null {
export function resolveDisplayedSlotInstant(
input: ResolveDisplayedSlotInstantInput,
): Date | null {
- const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
- input;
+ const {
+ currentWindowStatus,
+ nextDueAt,
+ now,
+ timeZone = "Europe/Berlin",
+ } = input;
// The card surfaces the current/overdue dose band — record that band's
// slot on today's calendar day. The matched band's `timeOfDay` is the
diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx
index 58e883b47..38e290699 100644
--- a/src/components/medications/card-parts/medication-card-body.tsx
+++ b/src/components/medications/card-parts/medication-card-body.tsx
@@ -6,7 +6,7 @@ import { AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { formatTimeWindowRange } from "@/lib/time-window-format";
-import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
+import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import {
MedicationComplianceBars,
diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx
index 5b0253085..ed64eb5da 100644
--- a/src/components/medications/card-parts/medication-compliance-bars.tsx
+++ b/src/components/medications/card-parts/medication-compliance-bars.tsx
@@ -148,7 +148,10 @@ export function MedicationComplianceBars({
expiring) never changes the card height and the action row below
stays on one baseline across a grid row. The skeleton and error
fallbacks reserve the same slot through the same component. */}
-
+
);
}
diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
similarity index 97%
rename from src/components/medications/detail/MedicationDetailTabs.tsx
rename to src/components/medications/detail/medication-detail-tabs.tsx
index 03bf145e9..fd7febc1d 100644
--- a/src/components/medications/detail/MedicationDetailTabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -49,7 +49,7 @@ import { MedicationDetailSummary } from "@/components/medications/medication-det
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
-import { ScheduleTimesEditor } from "@/components/medications/scheduling/ScheduleTimesEditor";
+import { ScheduleTimesEditor } from "@/components/medications/scheduling/schedule-times-editor";
import { ScheduleHistoryTimeline } from "@/components/medications/scheduling/schedule-history-timeline";
import type { DoseWindowEntry } from "@/components/medications/scheduling/dose-window";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
@@ -68,12 +68,12 @@ import {
} from "@/components/medications/sections/destructive-zone-section";
import { SettingsGroup } from "@/components/medications/settings-group";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
-import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
+import { SideEffectsSection } from "@/components/medications/side-effects-section";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TitrationTimeline } from "@/components/medications/titration-timeline";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { summariseSupply } from "@/lib/medications/inventory/summary";
-import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
+import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
@@ -88,7 +88,7 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
// shared chart skeleton standing in while the chunk streams.
const DrugLevelChart = dynamic(
() =>
- import("@/components/medications/DrugLevelChart").then((mod) => ({
+ import("@/components/medications/drug-level-chart").then((mod) => ({
default: mod.DrugLevelChart,
})),
{ ssr: false, loading: () => },
@@ -505,25 +505,25 @@ export function MedicationDetailTabs({
{!asNeeded && (
-
- {compliance ? (
-
- ) : (
-
- {t("medications.detail.uebersicht.complianceEmpty")}
-
- )}
-
+
+ {compliance ? (
+
+ ) : (
+
+ {t("medications.detail.uebersicht.complianceEmpty")}
+
+ )}
+
)}
diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts
index f9b29f0cf..b4972e946 100644
--- a/src/components/medications/detail/supply-runway.ts
+++ b/src/components/medications/detail/supply-runway.ts
@@ -74,7 +74,9 @@ export function estimateRunwayDays(
* weekly injection, ≈30 for a monthly RRULE. Returns `null` for a
* schedule with no derivable consumption (as-needed / empty).
*/
-export function cadenceIntervalDays(schedules: RunwaySchedule[]): number | null {
+export function cadenceIntervalDays(
+ schedules: RunwaySchedule[],
+): number | null {
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) return null;
return Math.ceil(1 / perDay);
@@ -151,6 +153,7 @@ export function supplyRunwayDates(input: {
}): { runsOutOn: Date; reorderBy: Date } {
const runsOutOn = addDaysUtc(input.today, Math.max(0, input.runwayDays));
const reorderRaw = addDaysUtc(runsOutOn, -input.leadDays);
- const reorderBy = reorderRaw < input.today ? new Date(input.today) : reorderRaw;
+ const reorderBy =
+ reorderRaw < input.today ? new Date(input.today) : reorderRaw;
return { runsOutOn, reorderBy };
}
diff --git a/src/components/medications/dose-history-ledger-compute.ts b/src/components/medications/dose-history-ledger-compute.ts
index 9bc86f1fc..06f51e084 100644
--- a/src/components/medications/dose-history-ledger-compute.ts
+++ b/src/components/medications/dose-history-ledger-compute.ts
@@ -48,7 +48,8 @@ export interface LedgerRow {
* (+) / before (−) the slot". Sub-minute deltas collapse to "±0 min".
*/
export function formatSlotDelta(takenAtIso: string, slotAtIso: string): string {
- const deltaMs = new Date(takenAtIso).getTime() - new Date(slotAtIso).getTime();
+ const deltaMs =
+ new Date(takenAtIso).getTime() - new Date(slotAtIso).getTime();
const sign = deltaMs < 0 ? "-" : "+";
const totalMinutes = Math.round(Math.abs(deltaMs) / 60_000);
if (totalMinutes < 60) return `${sign}${totalMinutes} min`;
@@ -102,7 +103,9 @@ export function complianceFromLedger(rows: LedgerRow[]): LedgerCompliance {
const taken = takenOnTime + takenLate;
const denominator = taken + missed;
const rate =
- denominator > 0 ? Math.min(100, Math.round((taken / denominator) * 100)) : null;
+ denominator > 0
+ ? Math.min(100, Math.round((taken / denominator) * 100))
+ : null;
return { rate, takenOnTime, takenLate, missed, denominator };
}
@@ -146,7 +149,9 @@ export function groupLedgerByDay(
}),
);
// Most-recent day first; chronological within the day above.
- groups.sort((a, b) => (a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0));
+ groups.sort((a, b) =>
+ a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0,
+ );
return groups;
}
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index e94561ad5..09e95806b 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -185,13 +185,19 @@ export function DoseHistoryLedger({
};
});
- const queryKey = queryKeys.medicationDoseHistory(medicationId, fromIso, toIso);
+ const queryKey = queryKeys.medicationDoseHistory(
+ medicationId,
+ fromIso,
+ toIso,
+ );
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: async () => {
const search = new URLSearchParams({ from: fromIso, to: toIso });
- return apiGet(`/api/medications/${medicationId}/dose-history?${search.toString()}`);
+ return apiGet(
+ `/api/medications/${medicationId}/dose-history?${search.toString()}`,
+ );
},
staleTime: 15_000,
});
@@ -542,8 +548,7 @@ function LedgerRowItem({
const actionable = isSlotActionable(row);
// A missed slot from an earlier day stays markable, but only through the
// kebab — the visible button is reserved for doses still in play.
- const showTakenButton =
- actionable && (row.status === "upcoming" || isToday);
+ const showTakenButton = actionable && (row.status === "upcoming" || isToday);
const intake = row.intake;
const takeBusy = marking === `${row.at}:taken`;
const skipBusy = marking === `${row.at}:skipped`;
@@ -678,10 +683,7 @@ function LedgerRowItem({
-
+
{actionable && !showTakenButton && (
onMark(row, "taken")}
@@ -709,9 +711,7 @@ function LedgerRowItem({
)}
{canPin && (
- onPin(intake!.id as string, dueContext!.at)
- }
+ onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
@@ -761,10 +761,7 @@ function LedgerRowItem({
{intake.skipped ? (
) : (
-
+
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx
index 47875ead2..3c11d42b3 100644
--- a/src/components/medications/dose-strength-curve.tsx
+++ b/src/components/medications/dose-strength-curve.tsx
@@ -128,10 +128,7 @@ export function DoseStrengthCurve({
});
const now = useMemo(() => asOf ?? new Date(), [asOf]);
- const doseChanges = useMemo(
- () => details?.doseChanges ?? [],
- [details],
- );
+ const doseChanges = useMemo(() => details?.doseChanges ?? [], [details]);
const points = useMemo(
() => buildCurvePoints(doseChanges, now),
[doseChanges, now],
diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/drug-level-chart.tsx
similarity index 87%
rename from src/components/medications/DrugLevelChart.tsx
rename to src/components/medications/drug-level-chart.tsx
index a2c523862..444dd1173 100644
--- a/src/components/medications/DrugLevelChart.tsx
+++ b/src/components/medications/drug-level-chart.tsx
@@ -283,7 +283,6 @@ function GatedUnknownDrug({ medicationName }: { medicationName: string }) {
);
}
-
function EmptyState() {
const { t } = useTranslations();
return (
@@ -370,50 +369,46 @@ function ChartBody({
style={{ height: "240px" }}
data-slot="drug-level-chart-area"
>
-
-
-
-
-
-
-
-
-
- {/* v1.4.27 MB6 CF-16 — dropped a stray empty `` child
+
+
+
+
+
+
+
+
+
+ {/* v1.4.27 MB6 CF-16 — dropped a stray empty `` child
that previously declared no content and rendered an
invisible SVG node beneath the x-axis. */}
- (v === 0 ? "0" : `${v}d`)}
- tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
- tickLine={false}
- axisLine={false}
- />
- {/* Research §2.3 — y-axis is unit-less. We hide tick labels
+ (v === 0 ? "0" : `${v}d`)}
+ tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
+ tickLine={false}
+ axisLine={false}
+ />
+ {/* Research §2.3 — y-axis is unit-less. We hide tick labels
and the axis line entirely; the human-readable label sits
outside the chart frame, above. The `tick={false}` flag
suppresses the textual ticks per Recharts. v1.4.27 MB6
@@ -422,33 +417,33 @@ function ChartBody({
`width={1}` axis, where it could never be read. The
external `` above the chart is the single source of
truth for the caption. */}
-
- null}
- cursor={{
- stroke: "var(--muted-foreground)",
- strokeOpacity: 0.3,
- strokeDasharray: "3 3",
- }}
- />
-
-
-
+
+ null}
+ cursor={{
+ stroke: "var(--muted-foreground)",
+ strokeOpacity: 0.3,
+ strokeDasharray: "3 3",
+ }}
+ />
+
+
+
>
);
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index 9678ce3bd..fc9be5383 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -349,8 +349,7 @@ export function Glp1MedicationCard({
// (max(lowStockRunwayDays, lead + cadenceIntervalDays)), so the weekly
// GLP-1 cadence is warned before its last dose. Stock 0 with a consuming
// schedule is runway 0 (mirrors `evaluateMedicationRunway`).
- const lowStockFloor =
- thresholds == null ? 7 : thresholds.lowStockRunwayDays;
+ const lowStockFloor = thresholds == null ? 7 : thresholds.lowStockRunwayDays;
const leadDays =
medication.reorderLeadDays != null
? medication.reorderLeadDays
diff --git a/src/components/medications/injection-site-picker.tsx b/src/components/medications/injection-site-picker.tsx
index 1c6acdc00..a3f083f63 100644
--- a/src/components/medications/injection-site-picker.tsx
+++ b/src/components/medications/injection-site-picker.tsx
@@ -206,7 +206,13 @@ export function InjectionSitePicker({
strokeOpacity="0.35"
/>
{/* Navel reference */}
-
+
{/* Click targets */}
{INJECTION_SITE_KEYS.map((site) => {
diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx
index 01a906a65..4ee6ce5e3 100644
--- a/src/components/medications/intake-history-list-v2.tsx
+++ b/src/components/medications/intake-history-list-v2.tsx
@@ -188,7 +188,9 @@ export function IntakeHistoryListV2({
offset: String(offset),
status: STATUS_FILTER,
});
- return apiGet
(`/api/medications/${medicationId}/intake?${params.toString()}`);
+ return apiGet(
+ `/api/medications/${medicationId}/intake?${params.toString()}`,
+ );
},
enabled: isAuthenticated,
});
@@ -358,7 +360,7 @@ export function IntakeHistoryListV2({
{isTaken ? (
{t("medications.intakeHistoryStatusTaken")}
diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx
index 82916a4e2..251c6ed5a 100644
--- a/src/components/medications/log-intake-dialog.tsx
+++ b/src/components/medications/log-intake-dialog.tsx
@@ -225,127 +225,127 @@ export function LogIntakeDialog({
}}
className="space-y-4"
>
- {medications.length === 0 ? (
-
- {t("medications.logIntake.noMedications")}
-
- ) : (
-
-
-
- {t("medications.logIntake.medicationLabel")}
-
- handleMedicationChange(e.target.value)}
- >
- {medications.map((m) => (
-
- {m.dose ? `${m.name} — ${m.dose}` : m.name}
-
- ))}
-
-
-
- {slotTimes.length > 0 && (
+ {medications.length === 0 ? (
+
+ {t("medications.logIntake.noMedications")}
+
+ ) : (
+
-
- {t("medications.logIntake.slotLabel")}
+
+ {t("medications.logIntake.medicationLabel")}
{
- setSlot(e.target.value);
- // The configured baseline tracks the slot's schedule
- // dose, so an untouched field re-prefills on change.
- setDoseOverride(null);
- }}
+ id="log-intake-medication"
+ value={medicationId}
+ onChange={(e) => handleMedicationChange(e.target.value)}
>
-
- {t("medications.logIntake.slotNone")}
-
- {slotTimes.map((time) => (
-
- {time}
+ {medications.map((m) => (
+
+ {m.dose ? `${m.name} — ${m.dose}` : m.name}
))}
- )}
-
-
- {t("medications.logIntake.doseLabel")}
-
-
setDoseOverride(e.target.value)}
- placeholder={configuredDose}
- autoComplete="off"
- disabled={skipped}
- />
-
- {t("medications.logIntake.doseHint")}
-
+ {slotTimes.length > 0 && (
+
+
+ {t("medications.logIntake.slotLabel")}
+
+ {
+ setSlot(e.target.value);
+ // The configured baseline tracks the slot's schedule
+ // dose, so an untouched field re-prefills on change.
+ setDoseOverride(null);
+ }}
+ >
+
+ {t("medications.logIntake.slotNone")}
+
+ {slotTimes.map((time) => (
+
+ {time}
+
+ ))}
+
+
+ )}
+
+
+
+ {t("medications.logIntake.doseLabel")}
+
+
setDoseOverride(e.target.value)}
+ placeholder={configuredDose}
+ autoComplete="off"
+ disabled={skipped}
+ />
+
+ {t("medications.logIntake.doseHint")}
+
+
+
+
+
+ {t("medications.logIntake.takenAtLabel")}
+
+
setTakenAt(e.target.value)}
+ disabled={skipped}
+ />
+
+ {t("medications.logIntake.takenAtHint")}
+
+
+
+
+
+ {t("medications.logIntake.skippedLabel")}
+
+
+
+ )}
-
-
- {t("medications.logIntake.takenAtLabel")}
-
-
setTakenAt(e.target.value)}
- disabled={skipped}
- />
-
- {t("medications.logIntake.takenAtHint")}
-
-
-
-
+ onOpenChange(false)}
+ disabled={busy}
>
-
- {t("medications.logIntake.skippedLabel")}
-
-
-
-
- )}
-
-
- onOpenChange(false)}
- disabled={busy}
- >
- {t("medications.logIntake.cancel")}
-
-
- {busy && (
-
- )}
- {t("medications.logIntake.submit")}
-
-
+ {t("medications.logIntake.cancel")}
+
+
+ {busy && (
+
+ )}
+ {t("medications.logIntake.submit")}
+
+
diff --git a/src/components/medications/MedicationCardHeader.tsx b/src/components/medications/medication-card-header.tsx
similarity index 96%
rename from src/components/medications/MedicationCardHeader.tsx
rename to src/components/medications/medication-card-header.tsx
index a00f84b37..1a7af3740 100644
--- a/src/components/medications/MedicationCardHeader.tsx
+++ b/src/components/medications/medication-card-header.tsx
@@ -105,7 +105,7 @@ export function MedicationCardHeader({
href={href}
aria-label={linkLabel}
data-slot="medication-card-header-link"
- className="-m-1 min-w-0 space-y-1 rounded-md p-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
+ className="focus-visible:ring-ring -m-1 min-w-0 space-y-1 rounded-md p-1 focus-visible:ring-2 focus-visible:outline-none"
>
{body}
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index 693d1ef73..3f5aff828 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -299,8 +299,7 @@ export function MedicationCard({
// `reorderLeadDays` overrides the default. The notice lights at runway
// ≤ trigger (matching the daily cron), so a weekly med is warned before
// its last dose, not at it.
- const lowStockFloor =
- thresholds == null ? 7 : thresholds.lowStockRunwayDays;
+ const lowStockFloor = thresholds == null ? 7 : thresholds.lowStockRunwayDays;
const leadDays =
medication.reorderLeadDays != null
? medication.reorderLeadDays
@@ -378,7 +377,10 @@ export function MedicationCard({
// No due pill, no overdue escalation, ever (structurally there is no
// schedule, so the window status is already null).
const asNeededLine = medication.asNeeded ? (
-
+
{t("medications.asNeededMarker")}
) : null;
diff --git a/src/components/medications/medication-detail-section.tsx b/src/components/medications/medication-detail-section.tsx
index 86ee544b2..3898c3742 100644
--- a/src/components/medications/medication-detail-section.tsx
+++ b/src/components/medications/medication-detail-section.tsx
@@ -59,7 +59,7 @@ export function MedicationDetailSection({
{title}
diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx
index 1eccd66ed..7085498bc 100644
--- a/src/components/medications/medication-table.tsx
+++ b/src/components/medications/medication-table.tsx
@@ -261,14 +261,22 @@ export function MedicationTable({
className: "bg-card sticky left-0 z-10",
},
{ key: "status", label: t("medications.tableColStatus"), sortable: false },
- { key: "nextDue", label: t("medications.tableColNextDose"), sortable: true },
+ {
+ key: "nextDue",
+ label: t("medications.tableColNextDose"),
+ sortable: true,
+ },
{
key: "compliance",
label: t("medications.tableColCompliance"),
sortable: true,
},
{ key: "stock", label: t("medications.tableColStock"), sortable: true },
- { key: "actions", label: t("medications.tableColActions"), sortable: false },
+ {
+ key: "actions",
+ label: t("medications.tableColActions"),
+ sortable: false,
+ },
];
function ariaSort(
@@ -651,7 +659,13 @@ function MedicationTableRowItem({
{statusCell}
{nextCell}
- {medication.active && !medication.asNeeded ? complianceCell : – }
+
+ {medication.active && !medication.asNeeded ? (
+ complianceCell
+ ) : (
+ –
+ )}
+
{stockCell}
{medication.active ? (
diff --git a/src/components/medications/ResearchModeAcknowledgmentDialog.tsx b/src/components/medications/research-mode-acknowledgment-dialog.tsx
similarity index 100%
rename from src/components/medications/ResearchModeAcknowledgmentDialog.tsx
rename to src/components/medications/research-mode-acknowledgment-dialog.tsx
diff --git a/src/components/medications/scheduling/__tests__/CadencePicker.test.tsx b/src/components/medications/scheduling/__tests__/cadence-picker.test.tsx
similarity index 95%
rename from src/components/medications/scheduling/__tests__/CadencePicker.test.tsx
rename to src/components/medications/scheduling/__tests__/cadence-picker.test.tsx
index c553c9070..a1eedec3a 100644
--- a/src/components/medications/scheduling/__tests__/CadencePicker.test.tsx
+++ b/src/components/medications/scheduling/__tests__/cadence-picker.test.tsx
@@ -6,7 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import {
CadencePicker,
encodeCadence,
-} from "@/components/medications/scheduling/CadencePicker";
+} from "@/components/medications/scheduling/cadence-picker";
import {
type CadenceKind,
type CadenceSubControls,
@@ -28,7 +28,9 @@ import {
*/
function render(node: React.ReactNode) {
- return renderToStaticMarkup({node} );
+ return renderToStaticMarkup(
+ {node} ,
+ );
}
function makeValue(kind: CadenceKind): CadenceValue {
@@ -138,12 +140,12 @@ describe("encodeCadence", () => {
});
it("clamps dayOfMonth to [1, 31]", () => {
- expect(
- encodeCadence("monthly", { ...baseSub, dayOfMonth: 99 }).rrule,
- ).toBe("FREQ=MONTHLY;BYMONTHDAY=31");
- expect(
- encodeCadence("monthly", { ...baseSub, dayOfMonth: 0 }).rrule,
- ).toBe("FREQ=MONTHLY;BYMONTHDAY=1");
+ expect(encodeCadence("monthly", { ...baseSub, dayOfMonth: 99 }).rrule).toBe(
+ "FREQ=MONTHLY;BYMONTHDAY=31",
+ );
+ expect(encodeCadence("monthly", { ...baseSub, dayOfMonth: 0 }).rrule).toBe(
+ "FREQ=MONTHLY;BYMONTHDAY=1",
+ );
});
it("clamps rollingDays to [1, 365]", () => {
diff --git a/src/components/medications/scheduling/__tests__/CourseWindowRow.test.tsx b/src/components/medications/scheduling/__tests__/course-window-row.test.tsx
similarity index 89%
rename from src/components/medications/scheduling/__tests__/CourseWindowRow.test.tsx
rename to src/components/medications/scheduling/__tests__/course-window-row.test.tsx
index 3095d117d..59133c728 100644
--- a/src/components/medications/scheduling/__tests__/CourseWindowRow.test.tsx
+++ b/src/components/medications/scheduling/__tests__/course-window-row.test.tsx
@@ -8,7 +8,7 @@ import {
dateToIsoString,
isRangeValid,
isoStringToDate,
-} from "@/components/medications/scheduling/CourseWindowRow";
+} from "@/components/medications/scheduling/course-window-row";
/**
* v1.5.0 — CourseWindowRow tests.
@@ -19,7 +19,9 @@ import {
*/
function render(node: React.ReactNode) {
- return renderToStaticMarkup({node} );
+ return renderToStaticMarkup(
+ {node} ,
+ );
}
const NOOP = () => undefined;
@@ -70,11 +72,7 @@ describe(" — SSR render", () => {
it("renders both date inputs + the no-end Switch", () => {
const start = new Date(Date.UTC(2026, 4, 28));
const html = render(
- ,
+ ,
);
expect(html).toContain('data-slot="course-window-row"');
expect(html).toContain('data-slot="course-window-starts"');
@@ -86,11 +84,7 @@ describe(" — SSR render", () => {
it("disables the endsOn input when no-end-date is on (endsOn=null)", () => {
const start = new Date(Date.UTC(2026, 4, 28));
const html = render(
- ,
+ ,
);
expect(html).toMatch(/data-slot="course-window-ends"[^>]*disabled/);
});
@@ -117,11 +111,7 @@ describe(" — SSR render", () => {
const start = new Date(Date.UTC(2026, 4, 28));
const end = new Date(Date.UTC(2026, 4, 1));
const html = render(
- ,
+ ,
);
expect(html).toContain('data-slot="course-window-error"');
expect(html).toMatch(/aria-invalid="true"/);
diff --git a/src/components/medications/scheduling/__tests__/TimesOfDayChips.test.tsx b/src/components/medications/scheduling/__tests__/times-of-day-chips.test.tsx
similarity index 91%
rename from src/components/medications/scheduling/__tests__/TimesOfDayChips.test.tsx
rename to src/components/medications/scheduling/__tests__/times-of-day-chips.test.tsx
index 61a5be8fe..b5121034d 100644
--- a/src/components/medications/scheduling/__tests__/TimesOfDayChips.test.tsx
+++ b/src/components/medications/scheduling/__tests__/times-of-day-chips.test.tsx
@@ -9,7 +9,7 @@ import {
removeTime,
sortTimes,
togglePreset,
-} from "@/components/medications/scheduling/TimesOfDayChips";
+} from "@/components/medications/scheduling/times-of-day-chips";
/**
* v1.5.0 — TimesOfDayChips tests.
@@ -21,7 +21,9 @@ import {
*/
function render(node: React.ReactNode) {
- return renderToStaticMarkup({node} );
+ return renderToStaticMarkup(
+ {node} ,
+ );
}
const NOOP = () => undefined;
@@ -60,10 +62,7 @@ describe("addTime", () => {
});
it("refuses to add beyond maxChips", () => {
- expect(addTime(["08:00", "12:00"], "18:00", 2)).toEqual([
- "08:00",
- "12:00",
- ]);
+ expect(addTime(["08:00", "12:00"], "18:00", 2)).toEqual(["08:00", "12:00"]);
});
});
@@ -116,9 +115,7 @@ describe(" — SSR render", () => {
});
it("marks a preset chip active when its time is in value", () => {
- const html = render(
- ,
- );
+ const html = render( );
expect(html).toMatch(/data-preset="morning"[^>]*data-active="true"/);
expect(html).toMatch(/data-preset="evening"[^>]*data-active="false"/);
});
@@ -156,9 +153,7 @@ describe(" — SSR render", () => {
});
it("renders the preset row by default (showPresets defaults true)", () => {
- const html = render(
- ,
- );
+ const html = render( );
expect(html).toContain('data-slot="times-of-day-presets"');
});
});
diff --git a/src/components/medications/scheduling/CadencePicker.tsx b/src/components/medications/scheduling/cadence-picker.tsx
similarity index 98%
rename from src/components/medications/scheduling/CadencePicker.tsx
rename to src/components/medications/scheduling/cadence-picker.tsx
index 7a84ef0b5..aac443d72 100644
--- a/src/components/medications/scheduling/CadencePicker.tsx
+++ b/src/components/medications/scheduling/cadence-picker.tsx
@@ -196,7 +196,9 @@ function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, Math.trunc(n)));
}
-function parseIsoDate(s: string): { year: number; month: number; day: number } | null {
+function parseIsoDate(
+ s: string,
+): { year: number; month: number; day: number } | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const [y, m, d] = s.split("-").map(Number);
if (!y || !m || !d) return null;
@@ -322,7 +324,7 @@ function CadenceOption({
checked={selected}
disabled={disabled}
onChange={onPick}
- className="text-primary focus-visible:ring-ring h-4 w-4 cursor-pointer focus-visible:outline-none focus-visible:ring-2"
+ className="text-primary focus-visible:ring-ring h-4 w-4 cursor-pointer focus-visible:ring-2 focus-visible:outline-none"
data-slot="cadence-option-radio"
/>
{label}
@@ -483,7 +485,7 @@ function WeekdayChips({
data-active={isOn ? "true" : "false"}
onClick={() => toggle(tok)}
className={[
- "focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50",
+ "focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
@@ -587,7 +589,13 @@ interface YearlyDateProps {
t: (key: string) => string;
}
-function YearlyDate({ value, disabled, i18nPrefix, onChange, t }: YearlyDateProps) {
+function YearlyDate({
+ value,
+ disabled,
+ i18nPrefix,
+ onChange,
+ t,
+}: YearlyDateProps) {
const id = useId();
return (
diff --git a/src/components/medications/scheduling/CourseWindowRow.tsx b/src/components/medications/scheduling/course-window-row.tsx
similarity index 100%
rename from src/components/medications/scheduling/CourseWindowRow.tsx
rename to src/components/medications/scheduling/course-window-row.tsx
diff --git a/src/components/medications/scheduling/DoseWindowEditor.tsx b/src/components/medications/scheduling/dose-window-editor.tsx
similarity index 100%
rename from src/components/medications/scheduling/DoseWindowEditor.tsx
rename to src/components/medications/scheduling/dose-window-editor.tsx
diff --git a/src/components/medications/scheduling/dose-window.ts b/src/components/medications/scheduling/dose-window.ts
index 7e8b12367..c6caf558b 100644
--- a/src/components/medications/scheduling/dose-window.ts
+++ b/src/components/medications/scheduling/dose-window.ts
@@ -65,7 +65,10 @@ export function isValidHhmm(value: string): boolean {
* default range — the whole-day on-time tolerance is the engine's
* concern; the editor only ever stores a wall-clock HH:mm range.
*/
-export function defaultBandForTime(time: string): { start: string; end: string } {
+export function defaultBandForTime(time: string): {
+ start: string;
+ end: string;
+} {
if (!isValidHhmm(time)) return { start: time, end: time };
const half = DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes;
const centre = hhmmToMinutes(time);
diff --git a/src/components/medications/scheduling/legacy-bridge.ts b/src/components/medications/scheduling/legacy-bridge.ts
index c6b2e8ba6..5af04db0f 100644
--- a/src/components/medications/scheduling/legacy-bridge.ts
+++ b/src/components/medications/scheduling/legacy-bridge.ts
@@ -12,7 +12,7 @@
* Pure functions only — no React, no DOM — so the unit tests can pin
* the mapping table without spinning up a render tree.
*/
-import { encodeCadence } from "./CadencePicker";
+import { encodeCadence } from "./cadence-picker";
import {
type CadenceSubControls,
type CadenceValue,
@@ -90,9 +90,10 @@ export function weekdayIndexesToTokens(indexes: number[]): WeekdayToken[] {
* own sub-control is empty). The fallback branch covers pathological
* inputs (negative intervalWeeks, NaN, etc.) without throwing.
*/
-export function inferCadenceFromLegacy(
- schedule: LegacyScheduleSnapshot,
-): { value: CadenceValue; subControls: CadenceSubControls } {
+export function inferCadenceFromLegacy(schedule: LegacyScheduleSnapshot): {
+ value: CadenceValue;
+ subControls: CadenceSubControls;
+} {
const tokens = weekdayIndexesToTokens(schedule.daysOfWeek ?? []);
const interval = Number.isFinite(schedule.intervalWeeks)
? Math.trunc(schedule.intervalWeeks)
diff --git a/src/components/medications/scheduling/NaturalLanguageExtractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx
similarity index 95%
rename from src/components/medications/scheduling/NaturalLanguageExtractor.tsx
rename to src/components/medications/scheduling/natural-language-extractor.tsx
index cd9a2d067..29a55dd86 100644
--- a/src/components/medications/scheduling/NaturalLanguageExtractor.tsx
+++ b/src/components/medications/scheduling/natural-language-extractor.tsx
@@ -161,10 +161,7 @@ export function NaturalLanguageExtractor({
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
- if (
- body?.error &&
- /budget/i.test(body.error)
- ) {
+ if (body?.error && /budget/i.test(body.error)) {
setError({ kind: "budget" });
return;
}
@@ -247,9 +244,11 @@ export function NaturalLanguageExtractor({
maxLength={MAX_TEXT_LENGTH + 200}
disabled={busy}
aria-invalid={overLimit || error?.kind === "empty" || undefined}
- aria-describedby={errorMessage ? `${textareaId}-error` : undefined}
+ aria-describedby={
+ errorMessage ? `${textareaId}-error` : undefined
+ }
/>
-
+
{charCount} / {MAX_TEXT_LENGTH}
@@ -258,14 +257,14 @@ export function NaturalLanguageExtractor({
{errorMessage}
) : null}
-
+
{t("medications.scheduling.naturalLanguage.examples.label")}
@@ -341,7 +340,7 @@ function ExampleChip({ label, onPick, disabled }: ExampleChipProps) {
type="button"
onClick={() => onPick(label)}
disabled={disabled}
- className="min-h-11 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 sm:min-h-9"
+ className="border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground min-h-11 rounded-md border px-3 py-2 text-left text-sm transition-colors disabled:pointer-events-none disabled:opacity-50 sm:min-h-9"
>
{label}
diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx
index e09db0f24..9be638d5f 100644
--- a/src/components/medications/scheduling/schedule-history-timeline.tsx
+++ b/src/components/medications/scheduling/schedule-history-timeline.tsx
@@ -61,7 +61,7 @@ import {
} from "@/components/ui/dialog";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
-import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
+import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
diff --git a/src/components/medications/scheduling/ScheduleTimesEditor.tsx b/src/components/medications/scheduling/schedule-times-editor.tsx
similarity index 98%
rename from src/components/medications/scheduling/ScheduleTimesEditor.tsx
rename to src/components/medications/scheduling/schedule-times-editor.tsx
index 48b5fcdb2..58f24262e 100644
--- a/src/components/medications/scheduling/ScheduleTimesEditor.tsx
+++ b/src/components/medications/scheduling/schedule-times-editor.tsx
@@ -23,8 +23,8 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
-import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
-import { DoseWindowEditor } from "@/components/medications/scheduling/DoseWindowEditor";
+import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
+import { DoseWindowEditor } from "@/components/medications/scheduling/dose-window-editor";
import {
type DoseWindowEntry,
type DoseWindowScale,
@@ -205,9 +205,7 @@ export function ScheduleTimesEditor({
patchSchedule(i, { timesOfDay })}
- maxChips={
- typeof s.rollingIntervalDays === "number" ? 1 : 8
- }
+ maxChips={typeof s.rollingIntervalDays === "number" ? 1 : 8}
/>
diff --git a/src/components/medications/scheduling/TimesOfDayChips.tsx b/src/components/medications/scheduling/times-of-day-chips.tsx
similarity index 100%
rename from src/components/medications/scheduling/TimesOfDayChips.tsx
rename to src/components/medications/scheduling/times-of-day-chips.tsx
diff --git a/src/components/medications/sections/__tests__/inventory-section.test.tsx b/src/components/medications/sections/__tests__/inventory-section.test.tsx
new file mode 100644
index 000000000..e5f106c55
--- /dev/null
+++ b/src/components/medications/sections/__tests__/inventory-section.test.tsx
@@ -0,0 +1,43 @@
+/**
+ * Supply-staleness regression — the medications-list card / table read
+ * the dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`)
+ * straight from the `GET /api/medications` list payload. The add / adjust /
+ * delete supply mutations used to invalidate only the per-medication
+ * inventory query, leaving the list query (`queryKeys.medications()`)
+ * serving the pre-write stock — so the card kept showing the old supply
+ * until an unrelated refetch landed.
+ *
+ * `invalidateSupplyQueries` is the single helper all three mutations call;
+ * this pins that it drops BOTH the list key (the card / table source) and
+ * the per-medication inventory key. The repo's component-test convention
+ * avoids `@testing-library/react` DOM interaction, so the mutation's
+ * invalidation contract is asserted through the extracted helper against a
+ * minimal QueryClient stand-in.
+ */
+
+import { describe, expect, it, vi } from "vitest";
+
+import { invalidateSupplyQueries } from "@/components/medications/sections/inventory-section";
+import { queryKeys } from "@/lib/query-keys";
+
+describe("invalidateSupplyQueries — supply staleness regression", () => {
+ it("invalidates the medications LIST key AND the per-medication inventory key", async () => {
+ const invalidateQueries = vi.fn().mockResolvedValue(undefined);
+ const queryClient = { invalidateQueries } as unknown as Parameters<
+ typeof invalidateSupplyQueries
+ >[0];
+
+ await invalidateSupplyQueries(queryClient, "med-1");
+
+ const invalidatedKeys = invalidateQueries.mock.calls.map((call) =>
+ JSON.stringify((call[0] as { queryKey: unknown }).queryKey),
+ );
+
+ // The card / table source — the regression was this key being absent.
+ expect(invalidatedKeys).toContain(JSON.stringify(queryKeys.medications()));
+ // The per-medication supply tab still refetches too.
+ expect(invalidatedKeys).toContain(
+ JSON.stringify(queryKeys.medicationInventory("med-1")),
+ );
+ });
+});
diff --git a/src/components/medications/sections/__tests__/notifications-section.test.tsx b/src/components/medications/sections/__tests__/notifications-section.test.tsx
index 5bd69e5f1..34380a55e 100644
--- a/src/components/medications/sections/__tests__/notifications-section.test.tsx
+++ b/src/components/medications/sections/__tests__/notifications-section.test.tsx
@@ -49,10 +49,7 @@ describe(" — split title ids (F-1 H-1)", () => {
it("renders the section heading and the row label with distinct ids", () => {
const client = makeClient();
const html = render(
- ,
+ ,
client,
);
diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx
index d2815bcc4..617a17ea9 100644
--- a/src/components/medications/sections/api-tokens-row.tsx
+++ b/src/components/medications/sections/api-tokens-row.tsx
@@ -197,10 +197,7 @@ export function ApiTokensRow({
}, [mintedToken]);
return (
-
+
{t("medications.detail.api.caption", { name: medicationName })}
@@ -301,7 +298,10 @@ export function ApiTokensRow({
)}
{examplesOpen && (
-
+
{t("medications.requestExample")}
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index 8a3765451..c3ff9877f 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -415,4 +415,3 @@ export function DangerZoneBody({
);
}
-
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index 28a769047..9a54d11f9 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -37,7 +37,11 @@
import { useState, type FormEvent } from "react";
import Link from "next/link";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ useQuery,
+ useQueryClient,
+ type QueryClient,
+} from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, PackageOpen, Plus } from "lucide-react";
@@ -74,6 +78,31 @@ import {
} from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
+/**
+ * Invalidate every read key whose payload reflects a medication's supply
+ * after a container write (register / adjust / delete).
+ *
+ * The per-medication inventory read (`medicationInventory`) is the supply
+ * tab's own list. The medications LIST read (`medications`) carries the
+ * dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that
+ * the card and table render — without invalidating it, the card kept
+ * showing the pre-write stock until an unrelated refetch landed (the
+ * supply-staleness bug). Both keys must drop together.
+ */
+export async function invalidateSupplyQueries(
+ queryClient: QueryClient,
+ medicationId: string,
+): Promise
{
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.medicationInventory(medicationId),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.medications(),
+ }),
+ ]);
+}
+
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
const CONTAINER_TYPES = [
@@ -101,7 +130,10 @@ interface InventoryResponse {
meta?: { total: number };
}
-const STATE_BADGE: Record = {
+const STATE_BADGE: Record<
+ InventoryState,
+ "secondary" | "outline" | "destructive"
+> = {
ACTIVE: "secondary",
IN_USE: "secondary",
EXPIRED: "destructive",
@@ -138,9 +170,7 @@ export function InventorySection({
async function deleteItem(item: InventoryItem) {
try {
await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`);
- await queryClient.invalidateQueries({
- queryKey: queryKeys.medicationInventory(medicationId),
- });
+ await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.deleteSuccess"));
} catch {
toast.error(t("medications.detail.bestand.deleteFailed"));
@@ -154,7 +184,9 @@ export function InventorySection({
const { data, isLoading } = useQuery({
queryKey: queryKeys.medicationInventory(medicationId),
queryFn: async () => {
- return apiGet(`/api/medications/${medicationId}/inventory`);
+ return apiGet(
+ `/api/medications/${medicationId}/inventory`,
+ );
},
staleTime: 30_000,
// v1.16.12 (#316) — fresh on every mount so reopening the supply tab
@@ -474,8 +506,7 @@ export function AddInventoryDialog({
const parsed = Number(quantity);
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
- const units =
- effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
+ const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
@@ -492,9 +523,7 @@ export function AddInventoryDialog({
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
});
- await queryClient.invalidateQueries({
- queryKey: queryKeys.medicationInventory(medicationId),
- });
+ await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.addSuccess"));
onClose();
} catch {
@@ -582,7 +611,11 @@ export function AddInventoryDialog({
type="number"
inputMode="numeric"
min={1}
- max={effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000}
+ max={
+ effectiveMode === "doses"
+ ? Math.floor(1000 / unitsPerDose)
+ : 1000
+ }
step={1}
required
autoComplete="off"
@@ -694,9 +727,7 @@ function AdjustInventoryDialog({
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsRemaining: parsed,
});
- await queryClient.invalidateQueries({
- queryKey: queryKeys.medicationInventory(medicationId),
- });
+ await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.adjustSuccess"));
onClose();
} catch {
@@ -744,8 +775,7 @@ function AdjustInventoryDialog({
>
{t("medications.detail.bestand.adjustHelper", {
total:
- item.unitsTotal ??
- t("medications.detail.bestand.unknown"),
+ item.unitsTotal ?? t("medications.detail.bestand.unknown"),
})}
@@ -801,7 +831,9 @@ function PackagingDialog({
const parsedPerDose = Number(perDoseValue);
const perDoseValid =
- Number.isInteger(parsedPerDose) && parsedPerDose >= 1 && parsedPerDose <= 100;
+ Number.isInteger(parsedPerDose) &&
+ parsedPerDose >= 1 &&
+ parsedPerDose <= 100;
const parsedPack = packValue.trim() === "" ? null : Number(packValue);
const packValid =
parsedPack === null ||
@@ -910,7 +942,10 @@ function PackagingDialog({
>
{t("common.cancel")}
-
+
{busy && (
);
}
-
diff --git a/src/components/medications/settings-group.tsx b/src/components/medications/settings-group.tsx
index 001bebaf0..c533eb1a4 100644
--- a/src/components/medications/settings-group.tsx
+++ b/src/components/medications/settings-group.tsx
@@ -84,7 +84,7 @@ export function SettingsGroup({
{children}
diff --git a/src/components/medications/SideEffectsSection.tsx b/src/components/medications/side-effects-section.tsx
similarity index 99%
rename from src/components/medications/SideEffectsSection.tsx
rename to src/components/medications/side-effects-section.tsx
index b21ee6d25..834437e05 100644
--- a/src/components/medications/SideEffectsSection.tsx
+++ b/src/components/medications/side-effects-section.tsx
@@ -33,6 +33,7 @@ import {
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.25 W19d — GLP-1 side-effect section for the medication detail
@@ -114,12 +115,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const [footerEl, setFooterEl] = useState(null);
const formId = useId();
- const listKey = [
- "medications",
- medicationId,
- "side-effects",
- "list",
- ] as const;
+ const listKey = queryKeys.medicationSideEffects(medicationId);
const { data, isLoading } = useQuery({
queryKey: listKey,
diff --git a/src/components/medications/titration-timeline.tsx b/src/components/medications/titration-timeline.tsx
index 32dab66d3..9aac97a20 100644
--- a/src/components/medications/titration-timeline.tsx
+++ b/src/components/medications/titration-timeline.tsx
@@ -98,7 +98,10 @@ export function buildTitrationSteps(
}));
}
-export function TitrationTimeline({ medicationId, asOf }: TitrationTimelineProps) {
+export function TitrationTimeline({
+ medicationId,
+ asOf,
+}: TitrationTimelineProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -152,10 +155,14 @@ export function TitrationTimeline({ medicationId, asOf }: TitrationTimelineProps
first planned step (or at the top when nothing is yet
in effect). */}
{markerAfter === i - 1 && i > 0 && (
-
+
)}
{markerAfter === -1 && i === 0 && (
-
+
)}
diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts
index bde93e547..91d933f63 100644
--- a/src/components/medications/use-medication-intake.ts
+++ b/src/components/medications/use-medication-intake.ts
@@ -204,8 +204,15 @@ export async function runLogIntake(deps: {
t: Translator;
queryClient: QueryClient;
}): Promise
{
- const { medication, skipped, takenAt, scheduledFor, doseTaken, t, queryClient } =
- deps;
+ const {
+ medication,
+ skipped,
+ takenAt,
+ scheduledFor,
+ doseTaken,
+ t,
+ queryClient,
+ } = deps;
try {
const body: Record = { skipped };
if (!skipped) body.takenAt = takenAt;
diff --git a/src/components/medications/wizard/WizardStepper.tsx b/src/components/medications/wizard/WizardStepper.tsx
deleted file mode 100644
index d5ee67dc5..000000000
--- a/src/components/medications/wizard/WizardStepper.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-"use client";
-
-/**
- * v1.8.6 W4b — dot/bullet stepper for the medication wizard.
- *
- * Replaces the continuous `` bar + "Step X of Y" caption with
- * one dot per path slot (so the one-shot path shows 5 dots, the
- * recurring path 8 — the dialog feeds the mode/cadence-aware `stepList`
- * straight in, so the dot count auto-collapses live). Each dot is a
- * button: completed + reachable dots jump on click, future dots gate on
- * the dialog's multi-step `validateStep` lookahead (`reachableUntil`).
- * `SkipBack` / `SkipForward` flank the row for jump-to-first /
- * jump-to-last.
- *
- * Purely presentational — every gating decision is computed in the
- * dialog (`goToStep` + `reachableUntil`) so the stepper stays dumb.
- * Connectors run dashed for not-yet-reached segments and solid for
- * completed ones, giving the "bullets, not one continuous line" feel.
- */
-
-import { SkipBack, SkipForward } from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-
-export interface WizardStepperProps {
- /** Ordered raw step numbers for the active mode + cadence path. */
- steps: readonly number[];
- /** The raw step number the body currently renders. */
- current: number;
- /**
- * Path-index ceiling (exclusive upper bound) of forward-reachable
- * slots. A slot at index `j` is reachable iff `j <= reachableUntil`.
- * The dialog computes it from the `validateStep` lookahead.
- */
- reachableUntil: number;
- /** Short per-step label keyed by raw step number. */
- labels: Record;
- /** Jump to a raw step number. */
- onJump: (step: number) => void;
- onFirst: () => void;
- onLast: () => void;
- firstEnabled: boolean;
- lastEnabled: boolean;
- /** Accessible labels for the flanking jump buttons. */
- firstLabel: string;
- lastLabel: string;
- /** Accessible group label, e.g. "Step 4 of 8". */
- srLabel: string;
-}
-
-export function WizardStepper({
- steps,
- current,
- reachableUntil,
- labels,
- onJump,
- onFirst,
- onLast,
- firstEnabled,
- lastEnabled,
- firstLabel,
- lastLabel,
- srLabel,
-}: WizardStepperProps) {
- const currentIndex = steps.indexOf(current);
- // When the step pointer is briefly off-path (e.g. Step 5 after the
- // mode collapses the path), pin the active slot to the last slot at
- // or before the current step so the active dot never disappears.
- const activeIndex =
- currentIndex >= 0
- ? currentIndex
- : steps.reduce((acc, n, i) => (n <= current ? i : acc), 0);
-
- const activeStepNumber = steps[activeIndex];
- const activeLabel =
- labels[activeStepNumber] ?? String(activeStepNumber ?? current);
-
- return (
-
-
-
-
-
-
-
- {steps.map((stepNumber, index) => {
- const isActive = index === activeIndex;
- const isCompleted = index < activeIndex;
- const isReachable = index <= reachableUntil;
- const label = labels[stepNumber] ?? String(stepNumber);
- // Backward + current are always navigable; forward is gated
- // on the reachability ceiling.
- const enabled = index <= activeIndex || isReachable;
- // A connector segment leads INTO this dot from the previous
- // one; it reads "completed" (solid) once we've passed it.
- const connectorSolid = index <= activeIndex;
-
- return (
-
- {index > 0 && (
-
- )}
- onJump(stepNumber)}
- disabled={!enabled}
- data-slot="wizard-stepper-dot"
- data-step-dot={stepNumber}
- data-active={isActive || undefined}
- data-completed={isCompleted || undefined}
- aria-current={isActive ? "step" : undefined}
- aria-label={`${index + 1}. ${label}`}
- className={cn(
- // 44 px hit target via padding even though the visual
- // dot is small.
- "group focus-visible:ring-ring/50 flex shrink-0 flex-col items-center gap-1 rounded-md p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none",
- enabled
- ? "cursor-pointer"
- : "cursor-not-allowed opacity-60",
- )}
- >
-
-
- {label}
-
-
-
- );
- })}
-
-
-
-
-
-
-
- {/* Compact caption — on narrow viewports the per-dot labels collapse
- to the active dot only, so this line names where the user stands
- ("Step 4 of 8 · How often") instead of leaving bare dots. Hidden
- from `sm+` where every dot carries its own label, and aria-hidden
- because the group already announces `srLabel`. */}
-
- {srLabel} · {activeLabel}
-
-
- );
-}
diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts
index c2c8c217d..e59142d4a 100644
--- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts
+++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts
@@ -20,7 +20,7 @@ import {
WIZARD_TREATMENT_MAPPING,
type WizardTreatmentRow,
} from "@/components/medications/wizard/wizard-payload";
-import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
+import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
import {
type CadenceKind,
DEFAULT_SUB_CONTROLS,
@@ -43,10 +43,7 @@ import {
*/
function makeStubT() {
- return (
- key: string,
- params?: Record,
- ): string => {
+ return (key: string, params?: Record): string => {
if (!params) return key;
return `${key}(${JSON.stringify(params)})`;
};
@@ -109,9 +106,9 @@ describe("validateStep", () => {
it("step 2: requires a treatment row", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 2)).toBe(false);
- expect(
- validateStep({ ...base, treatmentRow: "bloodPressure" }, 2),
- ).toBe(true);
+ expect(validateStep({ ...base, treatmentRow: "bloodPressure" }, 2)).toBe(
+ true,
+ );
});
it("step 3: requires populated dose amount", () => {
@@ -129,9 +126,9 @@ describe("validateStep", () => {
const base = emptyWizardPayload();
const start = new Date(Date.UTC(2026, 5, 1));
const earlier = new Date(Date.UTC(2026, 4, 1));
- expect(
- validateStep({ ...base, startsOn: start, endsOn: earlier }, 4),
- ).toBe(false);
+ expect(validateStep({ ...base, startsOn: start, endsOn: earlier }, 4)).toBe(
+ false,
+ );
});
it("step 4: accepts null endsOn (chronic)", () => {
@@ -152,30 +149,30 @@ describe("validateStep", () => {
});
it("step 6: weekdays requires >= 1 chip", () => {
- expect(
- validateStep(withCadence("weekdays", { weekdays: [] }), 6),
- ).toBe(false);
- expect(
- validateStep(withCadence("weekdays", { weekdays: ["MO"] }), 6),
- ).toBe(true);
+ expect(validateStep(withCadence("weekdays", { weekdays: [] }), 6)).toBe(
+ false,
+ );
+ expect(validateStep(withCadence("weekdays", { weekdays: ["MO"] }), 6)).toBe(
+ true,
+ );
});
it("step 6: monthly accepts day-of-month 1..31", () => {
- expect(
- validateStep(withCadence("monthly", { dayOfMonth: 1 }), 6),
- ).toBe(true);
- expect(
- validateStep(withCadence("monthly", { dayOfMonth: 31 }), 6),
- ).toBe(true);
+ expect(validateStep(withCadence("monthly", { dayOfMonth: 1 }), 6)).toBe(
+ true,
+ );
+ expect(validateStep(withCadence("monthly", { dayOfMonth: 31 }), 6)).toBe(
+ true,
+ );
});
it("step 6: rolling accepts 1..365 days", () => {
- expect(
- validateStep(withCadence("rolling", { rollingDays: 1 }), 6),
- ).toBe(true);
- expect(
- validateStep(withCadence("rolling", { rollingDays: 365 }), 6),
- ).toBe(true);
+ expect(validateStep(withCadence("rolling", { rollingDays: 1 }), 6)).toBe(
+ true,
+ );
+ expect(validateStep(withCadence("rolling", { rollingDays: 365 }), 6)).toBe(
+ true,
+ );
});
it("step 7: requires >= 1 valid HH:mm time", () => {
@@ -189,9 +186,9 @@ describe("validateStep", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, startsOn: null }, 8)).toBe(false);
const start = new Date(Date.UTC(2026, 5, 1));
- expect(
- validateStep({ ...base, startsOn: start, endsOn: null }, 8),
- ).toBe(true);
+ expect(validateStep({ ...base, startsOn: start, endsOn: null }, 8)).toBe(
+ true,
+ );
});
});
@@ -397,7 +394,9 @@ describe("buildCreateBody", () => {
// The default "1" still ships (an edit back to 1 must reach the server).
expect(buildCreateBody({ ...p, unitsPerDose: "1" }).unitsPerDose).toBe(1);
// An empty field omits it (server default applies).
- expect(buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose).toBeUndefined();
+ expect(
+ buildCreateBody({ ...p, unitsPerDose: "" }).unitsPerDose,
+ ).toBeUndefined();
const hydrated = hydrateWizardPayload({
id: "m1",
@@ -634,7 +633,9 @@ describe("summariseScheduleDraft — shared cadence-line helper", () => {
});
it("threads the everyNWeeks interval into the cadence key", () => {
- const p = commitActiveDraft(withCadence("everyNWeeks", { intervalWeeks: 3 }));
+ const p = commitActiveDraft(
+ withCadence("everyNWeeks", { intervalWeeks: 3 }),
+ );
const out = summariseScheduleDraft(p.schedules[p.activeScheduleIndex], t);
expect(out).toMatch(/cadence\.everyNWeeks[^|]*"n":3/);
});
@@ -686,16 +687,12 @@ describe("hydrateWizardPayload", () => {
});
it("hydrates a DIABETES medication as the diabetes row", () => {
- const out = hydrateWizardPayload(
- makeInitial({ category: "DIABETES" }),
- );
+ const out = hydrateWizardPayload(makeInitial({ category: "DIABETES" }));
expect(out.treatmentRow).toBe("diabetes");
});
it("hydrates an ANTIBIOTIC medication as the antibiotic row", () => {
- const out = hydrateWizardPayload(
- makeInitial({ category: "ANTIBIOTIC" }),
- );
+ const out = hydrateWizardPayload(makeInitial({ category: "ANTIBIOTIC" }));
expect(out.treatmentRow).toBe("antibiotic");
});
diff --git a/src/components/medications/wizard/MedicationWizardDialog.tsx b/src/components/medications/wizard/medication-wizard-dialog.tsx
similarity index 97%
rename from src/components/medications/wizard/MedicationWizardDialog.tsx
rename to src/components/medications/wizard/medication-wizard-dialog.tsx
index 154f0be94..5d789d405 100644
--- a/src/components/medications/wizard/MedicationWizardDialog.tsx
+++ b/src/components/medications/wizard/medication-wizard-dialog.tsx
@@ -49,7 +49,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
-import { NaturalLanguageExtractor } from "@/components/medications/scheduling/NaturalLanguageExtractor";
+import { NaturalLanguageExtractor } from "@/components/medications/scheduling/natural-language-extractor";
import {
addSchedule,
@@ -65,15 +65,15 @@ import {
setActiveSchedule,
type WizardPayload,
} from "./wizard-payload";
-import { WizardStepper } from "./WizardStepper";
-import { Step1Name } from "./steps/Step1Name";
-import { Step2Class } from "./steps/Step2Class";
-import { Step3Dose } from "./steps/Step3Dose";
-import { Step4Window } from "./steps/Step4Window";
-import { Step5Cadence } from "./steps/Step5Cadence";
-import { Step6SubCadence } from "./steps/Step6SubCadence";
-import { Step7Times } from "./steps/Step7Times";
-import { Step8Summary } from "./steps/Step8Summary";
+import { WizardStepper } from "./wizard-stepper";
+import { Step1Name } from "./steps/step1-name";
+import { Step2Class } from "./steps/step2-class";
+import { Step3Dose } from "./steps/step3-dose";
+import { Step4Window } from "./steps/step4-window";
+import { Step5Cadence } from "./steps/step5-cadence";
+import { Step6SubCadence } from "./steps/step6-sub-cadence";
+import { Step7Times } from "./steps/step7-times";
+import { Step8Summary } from "./steps/step8-summary";
import { validateStep } from "./wizard-payload";
import { extractorToWizardPartial } from "./nl-bridge";
diff --git a/src/components/medications/wizard/nl-bridge.ts b/src/components/medications/wizard/nl-bridge.ts
index 43d3eae38..9279a2211 100644
--- a/src/components/medications/wizard/nl-bridge.ts
+++ b/src/components/medications/wizard/nl-bridge.ts
@@ -11,14 +11,14 @@
* the mapping logic.
*/
-import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
-import { isoStringToDate } from "@/components/medications/scheduling/CourseWindowRow";
+import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
+import { isoStringToDate } from "@/components/medications/scheduling/course-window-row";
import {
DEFAULT_SUB_CONTROLS,
type CadenceSubControls,
type WeekdayToken,
} from "@/components/medications/scheduling/types";
-import type { WizardPayload as ExtractorPayload } from "@/components/medications/scheduling/NaturalLanguageExtractor";
+import type { WizardPayload as ExtractorPayload } from "@/components/medications/scheduling/natural-language-extractor";
import type { WizardPayload } from "./wizard-payload";
diff --git a/src/components/medications/wizard/steps/Step1Name.tsx b/src/components/medications/wizard/steps/step1-name.tsx
similarity index 100%
rename from src/components/medications/wizard/steps/Step1Name.tsx
rename to src/components/medications/wizard/steps/step1-name.tsx
diff --git a/src/components/medications/wizard/steps/Step2Class.tsx b/src/components/medications/wizard/steps/step2-class.tsx
similarity index 95%
rename from src/components/medications/wizard/steps/Step2Class.tsx
rename to src/components/medications/wizard/steps/step2-class.tsx
index b33f4f959..9ff663e84 100644
--- a/src/components/medications/wizard/steps/Step2Class.tsx
+++ b/src/components/medications/wizard/steps/step2-class.tsx
@@ -22,13 +22,10 @@ import {
type WizardTreatmentRow,
WIZARD_TREATMENT_ROWS,
} from "../wizard-payload";
-import type { StepProps } from "./Step1Name";
+import type { StepProps } from "./step1-name";
// Each row's Lucide glyph. maintainer-confirmed assignment in D-1 §3 Step 2.
-const ROW_ICONS: Record<
- WizardTreatmentRow,
- ComponentType
-> = {
+const ROW_ICONS: Record> = {
bloodPressure: Stethoscope,
diabetes: Droplet,
hormone: Activity,
diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/step3-dose.tsx
similarity index 98%
rename from src/components/medications/wizard/steps/Step3Dose.tsx
rename to src/components/medications/wizard/steps/step3-dose.tsx
index 265ba910d..85bc82012 100644
--- a/src/components/medications/wizard/steps/Step3Dose.tsx
+++ b/src/components/medications/wizard/steps/step3-dose.tsx
@@ -24,7 +24,7 @@ import type { MedicationDeliveryForm } from "@/lib/validations/medication";
import { unitsPerDoseOptionsFor } from "@/components/medications/units-per-dose";
import { useTranslations } from "@/lib/i18n/context";
-import type { StepProps } from "./Step1Name";
+import type { StepProps } from "./step1-name";
/**
* Dose unit choices, ordered so mg / ml / IE / µg lead and the
@@ -243,7 +243,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
setAllowedOpen((prev) => !prev)}
>
diff --git a/src/components/medications/wizard/steps/Step4Window.tsx b/src/components/medications/wizard/steps/step4-window.tsx
similarity index 81%
rename from src/components/medications/wizard/steps/Step4Window.tsx
rename to src/components/medications/wizard/steps/step4-window.tsx
index 26dcf69b5..0308a40be 100644
--- a/src/components/medications/wizard/steps/Step4Window.tsx
+++ b/src/components/medications/wizard/steps/step4-window.tsx
@@ -1,8 +1,8 @@
"use client";
-import { CourseWindowRow } from "@/components/medications/scheduling/CourseWindowRow";
+import { CourseWindowRow } from "@/components/medications/scheduling/course-window-row";
-import type { StepProps } from "./Step1Name";
+import type { StepProps } from "./step1-name";
/**
* Step 4 — Behandlungszeitraum (course window).
@@ -21,9 +21,7 @@ export function Step4Window({ payload, applyPartial }: StepProps) {
startsOn={payload.startsOn}
endsOn={payload.endsOn}
lockEndsToStart={isOneShot}
- onChange={({ startsOn, endsOn }) =>
- applyPartial({ startsOn, endsOn })
- }
+ onChange={({ startsOn, endsOn }) => applyPartial({ startsOn, endsOn })}
/>
);
diff --git a/src/components/medications/wizard/steps/Step5Cadence.tsx b/src/components/medications/wizard/steps/step5-cadence.tsx
similarity index 98%
rename from src/components/medications/wizard/steps/Step5Cadence.tsx
rename to src/components/medications/wizard/steps/step5-cadence.tsx
index 089f8985a..7eb53b0fc 100644
--- a/src/components/medications/wizard/steps/Step5Cadence.tsx
+++ b/src/components/medications/wizard/steps/step5-cadence.tsx
@@ -1,11 +1,11 @@
"use client";
-import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
+import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
import type { CadenceKind } from "@/components/medications/scheduling/types";
import { useTranslations } from "@/lib/i18n/context";
import type { WizardPayload } from "../wizard-payload";
-import type { StepProps } from "./Step1Name";
+import type { StepProps } from "./step1-name";
/**
* The seven cadence rows surfaced in Step 5. Each maps to a (mode,
diff --git a/src/components/medications/wizard/steps/Step6SubCadence.tsx b/src/components/medications/wizard/steps/step6-sub-cadence.tsx
similarity index 97%
rename from src/components/medications/wizard/steps/Step6SubCadence.tsx
rename to src/components/medications/wizard/steps/step6-sub-cadence.tsx
index aa4831fe4..259b0e499 100644
--- a/src/components/medications/wizard/steps/Step6SubCadence.tsx
+++ b/src/components/medications/wizard/steps/step6-sub-cadence.tsx
@@ -2,7 +2,7 @@
import { useMemo } from "react";
-import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
+import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -12,7 +12,7 @@ import {
} from "@/components/medications/scheduling/types";
import { useTranslations } from "@/lib/i18n/context";
-import type { StepProps } from "./Step1Name";
+import type { StepProps } from "./step1-name";
/**
* Step 6 — sub-cadence detail. The body swaps on the cadence kind the
@@ -173,7 +173,7 @@ function WeekdayChips({ selected, onChange }: WeekdayChipsProps) {
data-active={isOn ? "true" : "false"}
onClick={() => toggle(tok)}
className={[
- "focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
+ "focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
diff --git a/src/components/medications/wizard/steps/Step7Times.tsx b/src/components/medications/wizard/steps/step7-times.tsx
similarity index 93%
rename from src/components/medications/wizard/steps/Step7Times.tsx
rename to src/components/medications/wizard/steps/step7-times.tsx
index 3af88c51d..696781be9 100644
--- a/src/components/medications/wizard/steps/Step7Times.tsx
+++ b/src/components/medications/wizard/steps/step7-times.tsx
@@ -2,12 +2,12 @@
import { Moon, Sun, Sunrise, Sunset } from "lucide-react";
-import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
-import { DoseWindowEditor } from "@/components/medications/scheduling/DoseWindowEditor";
+import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
+import { DoseWindowEditor } from "@/components/medications/scheduling/dose-window-editor";
import type { DoseWindowScale } from "@/components/medications/scheduling/dose-window";
import { useTranslations } from "@/lib/i18n/context";
-import type { StepProps } from "./Step1Name";
+import type { StepProps } from "./step1-name";
/**
* Step 7 — Einnahmezeit(en). Reuses the v1.5 `TimesOfDayChips`
@@ -63,9 +63,7 @@ export function Step7Times({ payload, applyPartial }: StepProps) {
>
{PRESETS.map(({ time, icon: Icon, key }) => {
const isOn = payload.timesOfDay.includes(time);
- const label = t(
- `medications.wizard.steps.step7.presets.${key}`,
- );
+ const label = t(`medications.wizard.steps.step7.presets.${key}`);
return (
togglePreset(time)}
className={[
- "focus-visible:ring-ring inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
+ "focus-visible:ring-ring inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
diff --git a/src/components/medications/wizard/steps/Step8Summary.tsx b/src/components/medications/wizard/steps/step8-summary.tsx
similarity index 89%
rename from src/components/medications/wizard/steps/Step8Summary.tsx
rename to src/components/medications/wizard/steps/step8-summary.tsx
index 0cc776f46..1b9ddce80 100644
--- a/src/components/medications/wizard/steps/Step8Summary.tsx
+++ b/src/components/medications/wizard/steps/step8-summary.tsx
@@ -158,7 +158,7 @@ export function Step8Summary({
"border-border/70 hover:bg-muted/40 focus-visible:ring-ring",
"flex w-full items-center justify-center gap-2 rounded-md border border-dashed",
"p-3 text-sm font-medium transition-colors",
- "focus-visible:outline-none focus-visible:ring-2",
+ "focus-visible:ring-2 focus-visible:outline-none",
].join(" ")}
>
@@ -170,28 +170,25 @@ export function Step8Summary({
{/* v1.16.11 — an as-needed medication never reminds (no slots),
so the reminders toggle is hidden alongside the schedules. */}
{payload.mode !== "asNeeded" && (
-
-
-
- {t("medications.wizard.steps.step8.remindersLabel")}
-
-
- {t("medications.wizard.steps.step8.remindersDescription")}
-
+
+
+
+ {t("medications.wizard.steps.step8.remindersLabel")}
+
+
+ {t("medications.wizard.steps.step8.remindersDescription")}
+
+
+
+ applyPartial({ notificationsEnabled: checked })
+ }
+ data-slot="wizard-reminders-toggle"
+ aria-label={t("medications.wizard.steps.step8.remindersLabel")}
+ />
-
- applyPartial({ notificationsEnabled: checked })
- }
- data-slot="wizard-reminders-toggle"
- aria-label={t("medications.wizard.steps.step8.remindersLabel")}
- />
-
)}
{payload.mode !== "asNeeded" && showNoChannelHint && (
diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts
index 209a825dc..df549d54e 100644
--- a/src/components/medications/wizard/wizard-payload.ts
+++ b/src/components/medications/wizard/wizard-payload.ts
@@ -43,11 +43,11 @@
* reads. The mapping table is the single source of truth.
*/
-import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
+import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
import {
dateToIsoString,
isoStringToDate,
-} from "@/components/medications/scheduling/CourseWindowRow";
+} from "@/components/medications/scheduling/course-window-row";
import {
type CadenceKind,
type CadenceSubControls,
@@ -116,7 +116,10 @@ export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
*/
export const WIZARD_TREATMENT_MAPPING: Record<
WizardTreatmentRow,
- { treatmentClass: MedicationTreatmentClass; category: MedicationCategoryValue }
+ {
+ treatmentClass: MedicationTreatmentClass;
+ category: MedicationCategoryValue;
+ }
> = {
bloodPressure: { treatmentClass: "GENERIC", category: "BLOOD_PRESSURE" },
diabetes: { treatmentClass: "GENERIC", category: "DIABETES" },
@@ -696,7 +699,10 @@ export function buildCreateBody(
const draftsToEmit = isAsNeeded
? []
: isOneShot
- ? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
+ ? [
+ committed.schedules[committed.activeScheduleIndex] ??
+ committed.schedules[0],
+ ]
: committed.schedules;
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
@@ -738,7 +744,9 @@ export function buildCreateBody(
}),
oneShot: isOneShot,
asNeeded: isAsNeeded,
- schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)),
+ schedules: draftsToEmit.map((draft) =>
+ encodeScheduleDraft(draft, isOneShot),
+ ),
};
if (committed.startsOn) {
body.startsOn = dateToIsoString(committed.startsOn);
@@ -812,7 +820,8 @@ type CadenceShape = Pick
;
* summaries can't drift apart.
*/
function cadenceInterpolationN(shape: CadenceShape): number {
- if (shape.cadence.kind === "everyNWeeks") return shape.subControls.intervalWeeks;
+ if (shape.cadence.kind === "everyNWeeks")
+ return shape.subControls.intervalWeeks;
if (shape.cadence.kind === "rolling")
return shape.cadence.rollingIntervalDays ?? 0;
return 0;
@@ -974,7 +983,9 @@ export interface MedicationPayload {
}
/** Coerce a persisted `deliveryForm` string onto the closed enum. */
-function normaliseDeliveryForm(value: string | undefined): MedicationDeliveryForm {
+function normaliseDeliveryForm(
+ value: string | undefined,
+): MedicationDeliveryForm {
return value === "INJECTION" || value === "OTHER" ? value : "ORAL";
}
@@ -1037,7 +1048,9 @@ function hydrateScheduleDraft(
* the `(daysOfWeek, intervalWeeks)` pair when the v1.5 `rrule` /
* `rollingIntervalDays` fields are absent.
*/
-export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload {
+export function hydrateWizardPayload(
+ initial: MedicationPayload,
+): WizardPayload {
const base = emptyWizardPayload();
const parsedDose = parseDoseExpression(initial.dose ?? "");
@@ -1140,9 +1153,9 @@ function subControlsFromRrule(rrule: string): CadenceSubControls {
const sub: CadenceSubControls = { ...DEFAULT_SUB_CONTROLS };
const byday = /BYDAY=([A-Z,]+)/.exec(rrule);
if (byday) {
- const tokens = byday[1].split(",").filter((t) =>
- ["MO", "TU", "WE", "TH", "FR", "SA", "SU"].includes(t),
- );
+ const tokens = byday[1]
+ .split(",")
+ .filter((t) => ["MO", "TU", "WE", "TH", "FR", "SA", "SU"].includes(t));
if (tokens.length > 0) {
sub.weekdays = tokens as CadenceSubControls["weekdays"];
}
diff --git a/src/components/medications/wizard/wizard-stepper.tsx b/src/components/medications/wizard/wizard-stepper.tsx
new file mode 100644
index 000000000..765453fbc
--- /dev/null
+++ b/src/components/medications/wizard/wizard-stepper.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+/**
+ * v1.8.6 W4b — dot/bullet stepper for the medication wizard.
+ *
+ * Replaces the continuous `` bar + "Step X of Y" caption with
+ * one dot per path slot (so the one-shot path shows 5 dots, the
+ * recurring path 8 — the dialog feeds the mode/cadence-aware `stepList`
+ * straight in, so the dot count auto-collapses live). Each dot is a
+ * button: completed + reachable dots jump on click, future dots gate on
+ * the dialog's multi-step `validateStep` lookahead (`reachableUntil`).
+ * `SkipBack` / `SkipForward` flank the row for jump-to-first /
+ * jump-to-last.
+ *
+ * Purely presentational — every gating decision is computed in the
+ * dialog (`goToStep` + `reachableUntil`) so the stepper stays dumb.
+ * Connectors run dashed for not-yet-reached segments and solid for
+ * completed ones, giving the "bullets, not one continuous line" feel.
+ */
+
+import { SkipBack, SkipForward } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export interface WizardStepperProps {
+ /** Ordered raw step numbers for the active mode + cadence path. */
+ steps: readonly number[];
+ /** The raw step number the body currently renders. */
+ current: number;
+ /**
+ * Path-index ceiling (exclusive upper bound) of forward-reachable
+ * slots. A slot at index `j` is reachable iff `j <= reachableUntil`.
+ * The dialog computes it from the `validateStep` lookahead.
+ */
+ reachableUntil: number;
+ /** Short per-step label keyed by raw step number. */
+ labels: Record;
+ /** Jump to a raw step number. */
+ onJump: (step: number) => void;
+ onFirst: () => void;
+ onLast: () => void;
+ firstEnabled: boolean;
+ lastEnabled: boolean;
+ /** Accessible labels for the flanking jump buttons. */
+ firstLabel: string;
+ lastLabel: string;
+ /** Accessible group label, e.g. "Step 4 of 8". */
+ srLabel: string;
+}
+
+export function WizardStepper({
+ steps,
+ current,
+ reachableUntil,
+ labels,
+ onJump,
+ onFirst,
+ onLast,
+ firstEnabled,
+ lastEnabled,
+ firstLabel,
+ lastLabel,
+ srLabel,
+}: WizardStepperProps) {
+ const currentIndex = steps.indexOf(current);
+ // When the step pointer is briefly off-path (e.g. Step 5 after the
+ // mode collapses the path), pin the active slot to the last slot at
+ // or before the current step so the active dot never disappears.
+ const activeIndex =
+ currentIndex >= 0
+ ? currentIndex
+ : steps.reduce((acc, n, i) => (n <= current ? i : acc), 0);
+
+ const activeStepNumber = steps[activeIndex];
+ const activeLabel =
+ labels[activeStepNumber] ?? String(activeStepNumber ?? current);
+
+ return (
+
+
+
+
+
+
+
+ {steps.map((stepNumber, index) => {
+ const isActive = index === activeIndex;
+ const isCompleted = index < activeIndex;
+ const isReachable = index <= reachableUntil;
+ const label = labels[stepNumber] ?? String(stepNumber);
+ // Backward + current are always navigable; forward is gated
+ // on the reachability ceiling.
+ const enabled = index <= activeIndex || isReachable;
+ // A connector segment leads INTO this dot from the previous
+ // one; it reads "completed" (solid) once we've passed it.
+ const connectorSolid = index <= activeIndex;
+
+ return (
+
+ {index > 0 && (
+
+ )}
+ onJump(stepNumber)}
+ disabled={!enabled}
+ data-slot="wizard-stepper-dot"
+ data-step-dot={stepNumber}
+ data-active={isActive || undefined}
+ data-completed={isCompleted || undefined}
+ aria-current={isActive ? "step" : undefined}
+ aria-label={`${index + 1}. ${label}`}
+ className={cn(
+ // 44 px hit target via padding even though the visual
+ // dot is small.
+ "group focus-visible:ring-ring/50 flex shrink-0 flex-col items-center gap-1 rounded-md p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none",
+ enabled
+ ? "cursor-pointer"
+ : "cursor-not-allowed opacity-60",
+ )}
+ >
+
+
+ {label}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ {/* Compact caption — on narrow viewports the per-dot labels collapse
+ to the active dot only, so this line names where the user stands
+ ("Step 4 of 8 · How often") instead of leaving bare dots. Hidden
+ from `sm+` where every dot carries its own label, and aria-hidden
+ because the group already announces `srLabel`. */}
+
+ {srLabel} · {activeLabel}
+
+
+ );
+}
diff --git a/src/components/module-list/module-settings-frame.tsx b/src/components/module-list/module-settings-frame.tsx
deleted file mode 100644
index 4d2959b19..000000000
--- a/src/components/module-list/module-settings-frame.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-"use client";
-
-import type { ReactNode } from "react";
-
-import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
-import { SettingsSectionFrame } from "@/components/settings/settings-section-frame";
-
-/**
- * v1.18.6 (W8 / MOD-03) — heading frame for the per-module settings pages
- * (Vorsorge / Illness / Labs).
- *
- * Delegates to the shared `SettingsSectionFrame` so every settings page — the
- * 19 shell sections and these three per-module pages — renders one identical
- * heading frame. These pages stay out of the slug registry (their static
- * routes win over `/settings/[section]`), so they drive the frame's explicit
- * title/subtitle mode and supply the "return to the module" back-link through
- * the frame's `topSlot`, which sits above the heading.
- */
-export function ModuleSettingsFrame({
- title,
- description,
- backHref,
- backLabelKey,
- children,
-}: {
- title: string;
- description: string;
- /** Where the "← back" link returns to — the module's own page. */
- backHref: string;
- backLabelKey: string;
- children: ReactNode;
-}) {
- return (
- }
- >
- {children}
-
- );
-}
diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx
index ef0e2b8c9..745fbf998 100644
--- a/src/components/mood/__tests__/mood-list-filter-select.test.tsx
+++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx
@@ -87,7 +87,7 @@ describe("MoodList — filter bar + multi-select chrome", () => {
it("renders labelled selection checkboxes (per-row + select-all)", () => {
const html = render("en");
const boxes = html.match(/data-slot="checkbox"/g);
- expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3);
+ expect(boxes?.length ?? 0).toBeGreaterThanOrEqual(3);
expect(html).toContain('aria-label="Select all on this page"');
expect(html).toContain('aria-label="Select row"');
});
diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx
index 12de71804..d38567d46 100644
--- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx
+++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx
@@ -35,14 +35,8 @@ function renderForm(footerSlot: HTMLElement | null = null): string {
);
}
-const formSrc = readFileSync(
- resolve(__dirname, "../mood-form.tsx"),
- "utf8",
-);
-const listSrc = readFileSync(
- resolve(__dirname, "../mood-list.tsx"),
- "utf8",
-);
+const formSrc = readFileSync(resolve(__dirname, "../mood-form.tsx"), "utf8");
+const listSrc = readFileSync(resolve(__dirname, "../mood-list.tsx"), "utf8");
describe("MoodForm — 5-face hero (v1.12.0)", () => {
it("renders the five mood faces as a radiogroup on a pristine form", () => {
@@ -52,13 +46,7 @@ describe("MoodForm — 5-face hero (v1.12.0)", () => {
expect(html).toContain('data-slot="mood-face-hero"');
expect(html).toContain("How are you?");
// Best-on-the-left order: every enum value renders one face button.
- for (const mood of [
- "SUPER_GUT",
- "GUT",
- "OKAY",
- "SCHLECHT",
- "LAUSIG",
- ]) {
+ for (const mood of ["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]) {
expect(html).toContain(`data-mood="${mood}"`);
}
// The first face is SUPER_GUT (best on the left).
@@ -73,7 +61,7 @@ describe("MoodForm — 5-face hero (v1.12.0)", () => {
expect(html).not.toContain('data-slot="mood-annotate-panel"');
expect(html).not.toContain('data-testid="mood-note-counter"');
// The panel is gated on `moodPicked` (mood !== "").
- expect(formSrc).toContain("const moodPicked = mood !== \"\"");
+ expect(formSrc).toContain('const moodPicked = mood !== ""');
expect(formSrc).toMatch(
/moodPicked &&[\s\S]*data-slot="mood-annotate-panel"/,
);
@@ -114,7 +102,9 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
/function requestReset\(\) \{[\s\S]*if \(isDirty\) \{[\s\S]*setResetConfirmOpen\(true\)/,
);
// A pristine form resets immediately (nothing to lose).
- expect(formSrc).toMatch(/if \(isDirty\) \{[\s\S]*\} else \{[\s\S]*resetForm\(\)/);
+ expect(formSrc).toMatch(
+ /if \(isDirty\) \{[\s\S]*\} else \{[\s\S]*resetForm\(\)/,
+ );
});
it("mounts the confirm AlertDialog wired to resetForm on the destructive action", () => {
@@ -128,7 +118,9 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
describe("MoodForm — rated factors (v1.12.0)", () => {
it("lifts a ratedFactors set and threads it to the tag picker", () => {
expect(formSrc).toContain("const [ratedFactors, setRatedFactors]");
- expect(formSrc).toMatch(/function rateFactor\(key: string, rating: number \| null\)/);
+ expect(formSrc).toMatch(
+ /function rateFactor\(key: string, rating: number \| null\)/,
+ );
// The picker receives both the binary toggle and the factor rate
// callbacks so it can render BINARY chips and RATED controls.
expect(formSrc).toMatch(
diff --git a/src/components/mood/__tests__/recent-tags.test.ts b/src/components/mood/__tests__/recent-tags.test.ts
index f9d9390ac..aa94e54cb 100644
--- a/src/components/mood/__tests__/recent-tags.test.ts
+++ b/src/components/mood/__tests__/recent-tags.test.ts
@@ -8,11 +8,7 @@ describe("promoteRecentTags", () => {
});
it("moves an already-present key to the front without duplicating", () => {
- expect(promoteRecentTags(["a", "b", "c"], ["b"])).toEqual([
- "b",
- "a",
- "c",
- ]);
+ expect(promoteRecentTags(["a", "b", "c"], ["b"])).toEqual(["b", "a", "c"]);
});
it("keeps the latest of a multi-key use at the front", () => {
@@ -40,18 +36,11 @@ describe("selectQuickTagKeys", () => {
const catalog = ["a", "b", "c", "d", "e"];
it("returns the MRU keys that still exist in the catalog", () => {
- expect(selectQuickTagKeys(["c", "a"], catalog, 3)).toEqual([
- "c",
- "a",
- "b",
- ]);
+ expect(selectQuickTagKeys(["c", "a"], catalog, 3)).toEqual(["c", "a", "b"]);
});
it("drops MRU keys no longer in the catalog", () => {
- expect(selectQuickTagKeys(["gone", "b"], catalog, 2)).toEqual([
- "b",
- "a",
- ]);
+ expect(selectQuickTagKeys(["gone", "b"], catalog, 2)).toEqual(["b", "a"]);
});
it("falls back to the first catalog keys with no history", () => {
@@ -59,10 +48,7 @@ describe("selectQuickTagKeys", () => {
});
it("respects the limit", () => {
- expect(selectQuickTagKeys(["e", "d", "c"], catalog, 2)).toEqual([
- "e",
- "d",
- ]);
+ expect(selectQuickTagKeys(["e", "d", "c"], catalog, 2)).toEqual(["e", "d"]);
});
it("never duplicates when topping up from the catalog", () => {
diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx
index d827cf790..30bd2f698 100644
--- a/src/components/mood/mood-form.tsx
+++ b/src/components/mood/mood-form.tsx
@@ -38,10 +38,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
-import {
- SheetSection,
- SheetSectionCount,
-} from "@/components/ui/sheet-section";
+import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx
index 12e93967f..3669f4825 100644
--- a/src/components/mood/mood-list.tsx
+++ b/src/components/mood/mood-list.tsx
@@ -347,7 +347,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
note: string | null;
moodLoggedAt: string;
}) => {
- await apiPut(`/api/mood-entries/${id}`, { mood, tags, tagKeys, note, moodLoggedAt });
+ await apiPut(`/api/mood-entries/${id}`, {
+ mood,
+ tags,
+ tagKeys,
+ note,
+ moodLoggedAt,
+ });
},
onSuccess: async () => {
await invalidateKeys(queryClient, moodDependentKeys);
diff --git a/src/components/onboarding/__tests__/anamnesis-card.test.tsx b/src/components/onboarding/__tests__/anamnesis-card.test.tsx
index df08737cf..ae661b9ef 100644
--- a/src/components/onboarding/__tests__/anamnesis-card.test.tsx
+++ b/src/components/onboarding/__tests__/anamnesis-card.test.tsx
@@ -11,10 +11,7 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
-import {
- AnamnesisCard,
- buildAnamnesisAboutMeBody,
-} from "../AnamnesisCard";
+import { AnamnesisCard, buildAnamnesisAboutMeBody } from "../anamnesis-card";
describe("buildAnamnesisAboutMeBody", () => {
const empty = { conditions: "", allergies: "" };
diff --git a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
index 870803ba7..635733acf 100644
--- a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
+++ b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
@@ -32,8 +32,9 @@ describe("shouldAutoLaunchTour() — v1.4.47 W5 chain-gate", () => {
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: true,
- onboardingCompletedAt: new Date(NOW - 365 * 24 * ONE_HOUR_MS)
- .toISOString(),
+ onboardingCompletedAt: new Date(
+ NOW - 365 * 24 * ONE_HOUR_MS,
+ ).toISOString(),
nowMs: NOW,
}),
).toBe(false);
@@ -143,8 +144,6 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => {
});
it("ignores other disabled modules — only the achievements key matters", () => {
- expect(
- tourIncludesAchievements({ sleep: false, mood: false }),
- ).toBe(true);
+ expect(tourIncludesAchievements({ sleep: false, mood: false })).toBe(true);
});
});
diff --git a/src/components/onboarding/__tests__/tour-launcher-keys.test.ts b/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
index 4208c3296..fbdc6d7d5 100644
--- a/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
+++ b/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
@@ -17,10 +17,7 @@
* in-app tour re-entry trigger.
*/
import { describe, expect, it } from "vitest";
-import {
- tourReferrerKey,
- tourSessionDismissedKey,
-} from "../tour-launcher";
+import { tourReferrerKey, tourSessionDismissedKey } from "../tour-launcher";
describe("tour-launcher sessionStorage key scoping", () => {
it("scopes the dismiss key by user id", () => {
diff --git a/src/components/onboarding/AnamnesisCard.tsx b/src/components/onboarding/anamnesis-card.tsx
similarity index 98%
rename from src/components/onboarding/AnamnesisCard.tsx
rename to src/components/onboarding/anamnesis-card.tsx
index 723d6f98e..3b147d003 100644
--- a/src/components/onboarding/AnamnesisCard.tsx
+++ b/src/components/onboarding/anamnesis-card.tsx
@@ -63,7 +63,11 @@ export interface AnamnesisCardProps {
/** Per-field cap mirrors ABOUT_ME_FIELD_MAX_CHARS on the server. */
const FIELD_MAX = 500;
-export function AnamnesisCard({ value, onChange, disabled }: AnamnesisCardProps) {
+export function AnamnesisCard({
+ value,
+ onChange,
+ disabled,
+}: AnamnesisCardProps) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const conditionsId = useId();
diff --git a/src/components/onboarding/BaselineForm.tsx b/src/components/onboarding/baseline-form.tsx
similarity index 96%
rename from src/components/onboarding/BaselineForm.tsx
rename to src/components/onboarding/baseline-form.tsx
index d7a27ec56..2ca6f2d47 100644
--- a/src/components/onboarding/BaselineForm.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -20,11 +20,12 @@ import {
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import {
AnamnesisCard,
buildAnamnesisAboutMeBody,
type AnamnesisValue,
-} from "@/components/onboarding/AnamnesisCard";
+} from "@/components/onboarding/anamnesis-card";
/**
* v1.4.25 W14b-Content — onboarding step 3 (baseline).
@@ -141,7 +142,7 @@ export function BaselineForm() {
}
}
await apiPost("/api/onboarding/step", { step: 4 });
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/4");
} catch (err) {
const message =
@@ -152,10 +153,7 @@ export function BaselineForm() {
}
return (
-
+
- {saving ? : null}
+ {saving ? (
+
+ ) : null}
{t("onboarding.baseline.saveCta")}
@@ -291,4 +291,3 @@ export function BaselineForm() {
);
}
-
diff --git a/src/components/onboarding/DoneScreen.tsx b/src/components/onboarding/done-screen.tsx
similarity index 95%
rename from src/components/onboarding/DoneScreen.tsx
rename to src/components/onboarding/done-screen.tsx
index 5e02f3716..c11ef44cf 100644
--- a/src/components/onboarding/DoneScreen.tsx
+++ b/src/components/onboarding/done-screen.tsx
@@ -2,13 +2,7 @@
import Link from "next/link";
import { useEffect } from "react";
-import {
- CheckCircle2,
- FileUp,
- PlusCircle,
- Plug,
- Sparkles,
-} from "lucide-react";
+import { CheckCircle2, FileUp, PlusCircle, Plug, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
@@ -109,10 +103,7 @@ export function DoneScreen() {
-
+
{t("onboarding.done.logCta")}
diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx
index 821c21469..750580612 100644
--- a/src/components/onboarding/getting-started-checklist.tsx
+++ b/src/components/onboarding/getting-started-checklist.tsx
@@ -239,7 +239,7 @@ export function GettingStartedChecklist() {
});
const { data: notificationsData } = useQuery({
- queryKey: ["notifications", "preferences"],
+ queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
return apiGet("/api/notifications/preferences");
},
diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/goals-chip-picker.tsx
similarity index 96%
rename from src/components/onboarding/GoalsChipPicker.tsx
rename to src/components/onboarding/goals-chip-picker.tsx
index 488acd625..1425e89e4 100644
--- a/src/components/onboarding/GoalsChipPicker.tsx
+++ b/src/components/onboarding/goals-chip-picker.tsx
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.25 W14b-Content — onboarding step 1 (goals).
@@ -127,7 +128,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
step: 2,
goals: Array.from(selected),
});
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/2");
} catch (err) {
const message =
@@ -138,10 +139,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
}
return (
-
+
-
+
{t(option.labelKey)}
@@ -229,4 +227,3 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
);
}
-
diff --git a/src/components/onboarding/OnboardingShell.tsx b/src/components/onboarding/onboarding-shell.tsx
similarity index 98%
rename from src/components/onboarding/OnboardingShell.tsx
rename to src/components/onboarding/onboarding-shell.tsx
index 7bb0b6f76..1084ba693 100644
--- a/src/components/onboarding/OnboardingShell.tsx
+++ b/src/components/onboarding/onboarding-shell.tsx
@@ -80,7 +80,10 @@ export async function OnboardingShell({
: t(`onboarding.shell.step${clamped}of4`);
const primaryLabel =
- nextLabel ?? (clamped === 4 ? t("onboarding.done.returnCta") : t("onboarding.shell.next"));
+ nextLabel ??
+ (clamped === 4
+ ? t("onboarding.done.returnCta")
+ : t("onboarding.shell.next"));
return (
+
-
+
{MORE_SOURCES.map((item) => (
-
+
{titleLabel}
{recommendedLabel ? (
-
+
{recommendedLabel}
) : null}
@@ -317,7 +310,7 @@ function SourceCardItem({
aria-pressed={selected}
className={cn(
baseClasses,
- "cursor-pointer hover:border-primary/50",
+ "hover:border-primary/50 cursor-pointer",
selected && "border-primary bg-primary/5",
)}
data-testid={`source-card-${card.slug}`}
@@ -327,4 +320,3 @@ function SourceCardItem({
);
}
-
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index 02a7e5869..d8d50376d 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -45,6 +45,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import { OnboardingTour } from "./tour";
const POST_WIZARD_GRACE_MS = 1500;
@@ -335,7 +336,11 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
// v1.18.6 — stable progress callback so the overlay's checkpoint effect
// fires on a real step transition, not on every launcher re-render.
const handleProgress = useCallback(
- (progress: { lastStopId: string | null; completedStopIds: string[]; status: "in_progress" | "completed" | "skipped" }) => {
+ (progress: {
+ lastStopId: string | null;
+ completedStopIds: string[];
+ status: "in_progress" | "completed" | "skipped";
+ }) => {
void apiPost("/api/onboarding/tour", {
progress: { ...progress, updatedAt: new Date().toISOString() },
}).catch(() => {});
@@ -371,7 +376,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
if (user?.id) writeSessionDismissed(user.id);
try {
await apiPost("/api/onboarding/tour", { completed: true, outcome });
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
} catch {
// Swallow — the session-dismiss flag prevents a re-open.
// Next visit will retry via the same code path.
diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx
index 852828568..47ff722e8 100644
--- a/src/components/onboarding/tour.tsx
+++ b/src/components/onboarding/tour.tsx
@@ -520,9 +520,7 @@ export function OnboardingTour({
const dimRects = rect
? [
// Top strip — full width above spotlight.
- rect.top > 0
- ? { top: 0, left: 0, width: vw, height: rect.top }
- : null,
+ rect.top > 0 ? { top: 0, left: 0, width: vw, height: rect.top } : null,
// Bottom strip — full width below spotlight.
rect.top + rect.height < vh
? {
@@ -550,7 +548,12 @@ export function OnboardingTour({
height: rect.height,
}
: null,
- ].filter((r): r is { top: number; left: number; width: number; height: number } => r !== null)
+ ].filter(
+ (
+ r,
+ ): r is { top: number; left: number; width: number; height: number } =>
+ r !== null,
+ )
: null;
return (
@@ -589,31 +592,31 @@ export function OnboardingTour({
fully clickable: the user can drive the Hinzufügen dropdown,
sidebar links, or header avatar mid-tour and only the
tooltip-footer Skip / Back / Next buttons drive tour state. */}
- {dimRects
- ? dimRects.map((r, idx) => (
-
- ))
- : (
- // Center-placement fallback — no spotlight target available.
- // Render a single full-cover dim layer. Still purely visual:
- // skip / back / next live on the tooltip itself.
+ {dimRects ? (
+ dimRects.map((r, idx) => (
- )}
+ ))
+ ) : (
+ // Center-placement fallback — no spotlight target available.
+ // Render a single full-cover dim layer. Still purely visual:
+ // skip / back / next live on the tooltip itself.
+
+ )}
{/* Polite live region — announces the current step to screen readers. */}
diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/welcome-carousel.tsx
similarity index 96%
rename from src/components/onboarding/WelcomeCarousel.tsx
rename to src/components/onboarding/welcome-carousel.tsx
index 986a666f7..9647e3c5f 100644
--- a/src/components/onboarding/WelcomeCarousel.tsx
+++ b/src/components/onboarding/welcome-carousel.tsx
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
/**
@@ -152,7 +153,7 @@ export function WelcomeCarousel() {
});
}
await apiPost("/api/onboarding/step", { step: 1 });
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/1");
} catch (err) {
const message =
@@ -163,10 +164,7 @@ export function WelcomeCarousel() {
}
return (
-
+
- scrollToSlide(Math.min(SLIDES.length - 1, active + 1))
- }
+ onClick={() => scrollToSlide(Math.min(SLIDES.length - 1, active + 1))}
disabled={active === SLIDES.length - 1}
aria-label={t("onboarding.welcome.nextSlide")}
aria-controls={liveRegionId}
@@ -334,4 +330,3 @@ export function WelcomeCarousel() {
);
}
-
diff --git a/src/components/settings/__tests__/advanced-account-delete.test.tsx b/src/components/settings/__tests__/advanced-account-delete.test.tsx
index 0bbec8cbf..fd1c4e711 100644
--- a/src/components/settings/__tests__/advanced-account-delete.test.tsx
+++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx
@@ -29,7 +29,7 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
-vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
+vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
ResearchModeAcknowledgmentDialog: () =>
,
}));
diff --git a/src/components/settings/__tests__/advanced-research-mode.test.tsx b/src/components/settings/__tests__/advanced-research-mode.test.tsx
index df58a3c88..2984351cc 100644
--- a/src/components/settings/__tests__/advanced-research-mode.test.tsx
+++ b/src/components/settings/__tests__/advanced-research-mode.test.tsx
@@ -16,7 +16,7 @@
* even when `enabled === true`.
*
* The dialog itself is covered by
- * `ResearchModeAcknowledgmentDialog.test.tsx`; we mock it down to a
+ * `research-mode-acknowledgment-dialog.test.tsx`; we mock it down to a
* marker here so the toggle behaviour is the only contract under
* test.
*/
@@ -48,7 +48,7 @@ vi.mock("@tanstack/react-query", () => ({
// import to a transparent marker so we can assert "the toggle ON path
// renders the dialog with the right props" without re-running the
// dialog tree.
-vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
+vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
ResearchModeAcknowledgmentDialog: ({
open,
currentDisclaimerVersion,
diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx
index aaf77d38b..c9f40959e 100644
--- a/src/components/settings/__tests__/disable-coach-card.test.tsx
+++ b/src/components/settings/__tests__/disable-coach-card.test.tsx
@@ -147,8 +147,6 @@ describe("Settings — DisableCoachCard", () => {
const html = render();
// v1.18.1 (D7) — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
- expect(html).toContain(
- "Show the Coach button and drawer. On by default",
- );
+ expect(html).toContain("Show the Coach button and drawer. On by default");
});
});
diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts
index a4c2f25ae..f0a16559f 100644
--- a/src/components/settings/__tests__/oauth-return-outcome.test.ts
+++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts
@@ -7,10 +7,7 @@
*/
import { describe, it, expect } from "vitest";
-import {
- parseOAuthOutcome,
- oauthReasonKey,
-} from "../integrations-section";
+import { parseOAuthOutcome, oauthReasonKey } from "../integrations-section";
describe("parseOAuthOutcome", () => {
it("reads a connected outcome for every OAuth provider", () => {
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 24caa863b..da80f4bed 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -91,6 +91,12 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// sits right after `ai`.
// v1.18.1 (D4) — `channels` + `sources` split out of the Integrations
// sub-tabs into their own left-side entries, right after `integrations`.
+ // v1.18.7 — `labs`, `illness`, `vorsorge` move into the shell as
+ // first-class sections, right after `mood` (the three were standalone
+ // `ModuleSettingsFrame` pages).
+ // v1.18.7 — `sharing` (clinician share links) sits directly after
+ // `gesundheitsakte`, before `export`: minting a read-only link to the
+ // health record belongs next to the health-record export.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"modules",
@@ -103,11 +109,15 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"insights",
"medications",
"mood",
+ "labs",
+ "illness",
+ "vorsorge",
"thresholds",
"ai",
"coach",
"api",
"gesundheitsakte",
+ "sharing",
"export",
"advanced",
"about",
diff --git a/src/components/settings/__tests__/sharing-section.test.tsx b/src/components/settings/__tests__/sharing-section.test.tsx
new file mode 100644
index 000000000..f09fa06e4
--- /dev/null
+++ b/src/components/settings/__tests__/sharing-section.test.tsx
@@ -0,0 +1,125 @@
+/**
+ * v1.11.0 — Settings → Sharing (Epic C, C7).
+ *
+ * The owner share-link surface. The security-load-bearing guarantees the
+ * test pins:
+ * 1. The list never carries a raw `hls_` token — the server stores only the
+ * hash, so the list response shape has no token field and the rendered
+ * markup must not surface one.
+ * 2. The one-time token reveal card (`share-token-reveal`) is absent until a
+ * create succeeds — on first paint (no token in state) it must not render.
+ * 3. Active links render with a revoke control; revoked/expired links fold
+ * into the inactive list.
+ * 4. The expiry input is bounded at the server cap (90 days).
+ *
+ * SSR-static render only (no jsdom), matching the sibling settings-section
+ * tests. The TanStack hooks are mocked so the query returns a fixed link list.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", role: "USER" },
+ isAuthenticated: true,
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}));
+
+const FIXTURE_LINKS = [
+ {
+ id: "link-active",
+ label: "Cardiology referral",
+ rangeStart: "2026-01-01T00:00:00.000Z",
+ rangeEnd: null,
+ resourceTypes: ["Patient", "Observation"],
+ allowFhirApi: true,
+ expiresAt: "2099-01-01T00:00:00.000Z",
+ createdAt: "2026-06-01T00:00:00.000Z",
+ revokedAt: null,
+ lastAccessAt: "2026-06-02T10:00:00.000Z",
+ accessCount: 3,
+ active: true,
+ },
+ {
+ id: "link-revoked",
+ label: "Old GP link",
+ rangeStart: "2026-01-01T00:00:00.000Z",
+ rangeEnd: null,
+ resourceTypes: [],
+ allowFhirApi: false,
+ expiresAt: "2099-01-01T00:00:00.000Z",
+ createdAt: "2026-05-01T00:00:00.000Z",
+ revokedAt: "2026-05-15T00:00:00.000Z",
+ lastAccessAt: null,
+ accessCount: 0,
+ active: false,
+ },
+];
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: FIXTURE_LINKS, isLoading: false }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { SharingSection } from "../sharing-section";
+
+function render(locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+describe(" — owner share-link surface (C7)", () => {
+ it("renders the create form with a 90-day-capped expiry input", () => {
+ const html = render();
+ expect(html).toContain("New share link");
+ // The expiry field carries the server's max-days cap.
+ expect(html).toContain('max="90"');
+ expect(html).toContain("Create link");
+ });
+
+ it("does not reveal a token before a create succeeds", () => {
+ const html = render();
+ // The one-time reveal card only mounts once `newToken` state is set.
+ expect(html).not.toContain('data-testid="share-token-reveal"');
+ });
+
+ it("lists the active link with a revoke control and never a raw token", () => {
+ const html = render();
+ expect(html).toContain('data-testid="share-active-list"');
+ expect(html).toContain("Cardiology referral");
+ expect(html).toContain("Revoke");
+ // The list response shape carries no token; the markup must not surface
+ // an `hls_` string anywhere.
+ expect(html).not.toContain("hls_");
+ // The FHIR badge surfaces for the FHIR-enabled link.
+ expect(html).toContain("FHIR");
+ });
+
+ it("folds revoked links into the inactive list (not the active list)", () => {
+ const html = render();
+ // The active list must NOT contain the revoked link's label.
+ const activeListStart = html.indexOf('data-testid="share-active-list"');
+ const activeListEnd = html.indexOf('data-testid="share-inactive-list"');
+ // Inactive list is collapsed by default, so the label only appears once
+ // the disclosure is open — but the active list must never carry it.
+ const activeSlice =
+ activeListEnd > activeListStart
+ ? html.slice(activeListStart, activeListEnd)
+ : html.slice(activeListStart);
+ expect(activeSlice).not.toContain("Old GP link");
+ // The inactive-count disclosure surfaces.
+ expect(html).toContain("Revoked and expired");
+ });
+
+ it("renders localized section copy", () => {
+ const html = render("de");
+ expect(html).toContain("Neuer Freigabe-Link");
+ });
+});
diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx
index aeb554416..6683d45a4 100644
--- a/src/components/settings/about-me-section.tsx
+++ b/src/components/settings/about-me-section.tsx
@@ -33,6 +33,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -181,10 +182,11 @@ export function AboutMeSection({
}
return (
-
@@ -290,6 +293,7 @@ export function AboutMeSection({
save.mutate(value)}
@@ -346,6 +350,6 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
-
+
);
}
diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx
index 787c5b46a..de0bae81d 100644
--- a/src/components/settings/about-section.tsx
+++ b/src/components/settings/about-section.tsx
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -191,7 +192,7 @@ export function AboutSection() {
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
on the same row. */}
-
+
{isLoading || !version ? (
@@ -255,20 +256,25 @@ export function AboutSection() {
)}
)}
-
+
{/* Sources & docs — every link card was untitled in the v1.4.2
About surface, so the buttons floated alone with no heading
telling the user what the section was for. */}
{version && (
-
+
)}
{/* v1.18.6 — the tour-replay card consolidated onto a single home in
diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx
index bddb12312..4329df081 100644
--- a/src/components/settings/account-section.tsx
+++ b/src/components/settings/account-section.tsx
@@ -48,6 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { AboutMeSection } from "@/components/settings/about-me-section";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
@@ -375,7 +376,7 @@ export function AccountSection() {
{/* Profile photo card */}
-
+
-
+
{saving ? (
) : (
@@ -580,7 +585,7 @@ export function AccountSection() {
-
+
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
Medikamente settings section, where every medication-specific
@@ -599,7 +604,7 @@ export function AccountSection() {
{/* Passkeys card */}
-
+
@@ -632,7 +638,7 @@ export function AccountSection() {
{statusText(passkeyMsg, t)}
)}
-
+
{/* Password card. v1.4.19 A6: action-button placement contract —
on mobile (`
+
@@ -660,12 +666,12 @@ export function AccountSection() {
type="button"
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
- className="w-full shrink-0 sm:w-auto"
+ className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
-
+
{/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings →
Erweitert. It is a maintenance / reset action, not a profile or
@@ -693,7 +699,7 @@ export function AccountSection() {