diff --git a/CHANGELOG.md b/CHANGELOG.md
index 979f94a36..4b7edb69d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2333,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)
@@ -2698,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.
@@ -2728,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
@@ -2785,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
@@ -3095,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
@@ -3294,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
@@ -3622,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
@@ -3689,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
@@ -3715,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
@@ -3725,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
@@ -3787,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
@@ -3802,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
@@ -3820,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.
@@ -3896,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.
@@ -3969,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
@@ -4203,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
@@ -4454,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
@@ -4802,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
@@ -4885,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
@@ -5146,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
@@ -5184,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,
@@ -5709,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
@@ -5805,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
@@ -5974,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
@@ -5985,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
@@ -6056,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 +
@@ -6110,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
@@ -6118,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
@@ -6203,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
@@ -6283,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
@@ -6402,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
@@ -6608,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/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 2aca1fda8..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
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/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/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/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 5043c57ea..25ae998a6 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -194,17 +194,41 @@ async function seed() {
// 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);
+ 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);
+ 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);
+ 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);
+ const sleepMin = randomWalk(
+ 420,
+ 465,
+ span,
+ 25,
+ VALUE_RANGES.SLEEP_DURATION,
+ );
// Steps: 6500 → a solid ~9000
const steps = randomWalk(6500, 9000, span, 1500);
@@ -330,7 +354,8 @@ async function seed() {
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;
+ 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);
@@ -396,29 +421,77 @@ async function seed() {
// 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 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);
+ 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);
+ 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);
+ 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: "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" },
+ {
+ 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(
@@ -449,19 +522,47 @@ async function seed() {
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 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);
+ 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: "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: "%" },
+ {
+ type: "SLEEP_CONSISTENCY",
+ value: Math.round(sleepConsistency[i]),
+ unit: "%",
+ },
];
for (const s of scores) {
await client.query(
@@ -490,9 +591,33 @@ async function seed() {
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 },
+ {
+ 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.
@@ -503,7 +628,11 @@ async function seed() {
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 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();
@@ -755,12 +884,24 @@ async function seed() {
low: number;
high: number;
}> = [
- { analyte: "Total Cholesterol", value: 178, unit: "mg/dL", low: 0, high: 200 },
+ {
+ 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: "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 },
@@ -919,14 +1060,28 @@ async function seed() {
await client.query("COMMIT");
console.log("\nDemo data seeded successfully!");
console.log(` User: demo / demo123demo123`);
- 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(
+ ` 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(
+ ` 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`);
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 1029c31af..1b44d387b 100644
--- a/src/app/__tests__/insights-polish.test.ts
+++ b/src/app/__tests__/insights-polish.test.ts
@@ -68,18 +68,14 @@ 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.
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/route.ts b/src/app/api/c/[token]/unlock/route.ts
index 8cd027811..9c602abe0 100644
--- a/src/app/api/c/[token]/unlock/route.ts
+++ b/src/app/api/c/[token]/unlock/route.ts
@@ -46,10 +46,7 @@ function reject() {
}
export const POST = apiHandler(
- async (
- request: NextRequest,
- ctx: { params: Promise<{ token: string }> },
- ) => {
+ async (request: NextRequest, ctx: { params: Promise<{ token: string }> }) => {
const { token } = await ctx.params;
annotate({ action: { name: "share-link.unlock" } });
@@ -91,14 +88,18 @@ export const POST = apiHandler(
// 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,
- });
+ 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 89ead0497..88d04749b 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -571,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;
@@ -613,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) {
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 e782360e0..505bbc6a6 100644
--- a/src/app/api/measurements/series/route.ts
+++ b/src/app/api/measurements/series/route.ts
@@ -152,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: {
@@ -260,8 +262,7 @@ 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") {
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]/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 f8da4a14e..2125b3c8f 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
@@ -108,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) {
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 1b0bea45e..a0b0ea611 100644
--- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
@@ -360,9 +360,12 @@ 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);
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 ae94c8fce..db045fab0 100644
--- a/src/app/api/share-links/__tests__/route.test.ts
+++ b/src/app/api/share-links/__tests__/route.test.ts
@@ -213,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 8cfa15d44..1fa5d539c 100644
--- a/src/app/api/share-links/route.ts
+++ b/src/app/api/share-links/route.ts
@@ -85,8 +85,7 @@ function toSummary(row: {
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(),
};
}
@@ -94,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);
}
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/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 e6d201cf3..53dacacc1 100644
--- a/src/app/c/[token]/__tests__/page.test.tsx
+++ b/src/app/c/[token]/__tests__/page.test.tsx
@@ -105,7 +105,10 @@ describe("clinician share page", () => {
});
it("renders a legacy (no-passphrase) link without a gate", async () => {
- gateState.mockResolvedValue({ tokenHash: TOKEN_HASH, passphraseHash: null });
+ gateState.mockResolvedValue({
+ tokenHash: TOKEN_HASH,
+ passphraseHash: null,
+ });
resolve.mockResolvedValue(resolvedContext());
loadData.mockResolvedValue({ report: {}, sections: {} });
await ClinicianSharePage(pageProps(`hls_${"b".repeat(48)}`));
diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx
index acbca502c..38633ec14 100644
--- a/src/app/c/[token]/page.tsx
+++ b/src/app/c/[token]/page.tsx
@@ -29,11 +29,7 @@ import {
verifyUnlockValue,
} from "@/lib/clinician-share/unlock-cookie";
import { getServerTranslator } from "@/lib/i18n/server-translator";
-import {
- defaultLocale,
- locales,
- type Locale,
-} from "@/lib/i18n/config";
+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";
@@ -80,7 +76,9 @@ export default async function ClinicianSharePage({
// 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;
+ const unlockValue = cookieStore.get(
+ unlockCookieName(gate.tokenHash),
+ )?.value;
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
const locale = await resolveLocale();
const { t } = getServerTranslator(locale);
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.
= [
{ 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/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/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/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index 31b0716e2..ff0edadc8 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -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/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
index 790357525..a4f613e27 100644
--- a/src/components/clinician/share-unlock-gate.tsx
+++ b/src/components/clinician/share-unlock-gate.tsx
@@ -79,7 +79,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
return (
@@ -112,7 +112,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
{error && (
-
+
{t("clinicianView.unlock.error")}
)}
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 37562ae52..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}
diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx
index 79c76333a..00a78ba19 100644
--- a/src/components/cycle/log-day-sheet.tsx
+++ b/src/components/cycle/log-day-sheet.tsx
@@ -42,10 +42,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
-import {
- SheetSection,
- SheetSectionCount,
-} from "@/components/ui/sheet-section";
+import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { FieldInfo } from "./field-info";
@@ -509,7 +506,9 @@ export function LogDaySheet({
const [cervixFirmness, setCervixFirmness] = useState
(
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 95a975e9e..d13db1963 100644
--- a/src/components/dashboard/hero/dashboard-hero.tsx
+++ b/src/components/dashboard/hero/dashboard-hero.tsx
@@ -232,94 +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/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 278f9363a..4db8ab460 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -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 588d57901..7b4c616d1 100644
--- a/src/components/insights/__tests__/hero-strip.test.tsx
+++ b/src/components/insights/__tests__/hero-strip.test.tsx
@@ -127,9 +127,7 @@ describe("", () => {
// the insights layout shell), not as a hero affordance.
it("does NOT render an ask-the-coach action button", () => {
const html = render( );
- expect(html).not.toContain(
- 'data-slot="insights-hero-strip-action-coach"',
- );
+ expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"');
});
// v1.4.25 W3 — the regenerate button moved from the hero action row
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__/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__/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 bcda26d32..a6f785ab6 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -265,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
diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx
index 6076a2b4b..3933b2fe4 100644
--- a/src/components/insights/coach-panel/coach-drawer-body.tsx
+++ b/src/components/insights/coach-panel/coach-drawer-body.tsx
@@ -80,7 +80,9 @@ export function CoachDrawerBody({
return (
& { isFinal: boolean }
- >;
+ results: ArrayLike
& { isFinal: boolean }>;
};
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
@@ -403,7 +401,7 @@ export function CoachInput({
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
// scroll regions). Scoped here, not in globals.css.
- "[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent] [scrollbar-width:thin]",
+ "[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
diff --git a/src/components/insights/coach-panel/guided-dialog-bubbles.tsx b/src/components/insights/coach-panel/guided-dialog-bubbles.tsx
index c9b6deb68..7790cc5f3 100644
--- a/src/components/insights/coach-panel/guided-dialog-bubbles.tsx
+++ b/src/components/insights/coach-panel/guided-dialog-bubbles.tsx
@@ -67,7 +67,8 @@ function ProgressDots({ current, total }: { current: number; total: number }) {
className={cn(
"size-1.5 rounded-full",
i + 1 < current && "bg-dracula-purple",
- i + 1 === current && "ring-dracula-purple bg-dracula-purple/40 ring-1",
+ i + 1 === current &&
+ "ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 > 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 93bff63a6..0b7133e6c 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -184,8 +184,7 @@ export function HistoryRail({
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
- isConfirming &&
- "text-dracula-red hover:text-dracula-red",
+ isConfirming && "text-dracula-red hover:text-dracula-red",
)}
>
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 46c92bf2f..11c462516 100644
--- a/src/components/insights/coach-panel/use-coach.ts
+++ b/src/components/insights/coach-panel/use-coach.ts
@@ -300,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 a3f349d75..7af72f65f 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -376,10 +376,7 @@ export function DailyBriefing({
>
{t("insights.dailyBriefing.signalsTitle")}
-
+
{briefing.signalsOfDay.map((signal, index) => (
", () => {
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/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/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx
index cc0a27213..9ca90671c 100644
--- a/src/components/insights/derived/sparkline-delta-tile.tsx
+++ b/src/components/insights/derived/sparkline-delta-tile.tsx
@@ -102,9 +102,7 @@ export function SparklineDeltaTile({
};
const sparkData =
- series && series.length >= 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}`,
+ })}
>
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/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index cd544013e..7eb903ad5 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -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 00d3562a7..949c1cdb2 100644
--- a/src/components/insights/personal-record-badge.tsx
+++ b/src/components/insights/personal-record-badge.tsx
@@ -104,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;
@@ -119,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 (
diff --git a/src/components/insights/sleep/chronotype-section.tsx b/src/components/insights/sleep/chronotype-section.tsx
index cc9fdaadd..39f577634 100644
--- a/src/components/insights/sleep/chronotype-section.tsx
+++ b/src/components/insights/sleep/chronotype-section.tsx
@@ -35,7 +35,10 @@ export function ChronotypeSection({ enabled }: { enabled: boolean }) {
return (
-
+
{isError ? (
) => 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 c7dde248b..2da539098 100644
--- a/src/components/insights/sleep/sleep-quality-section.tsx
+++ b/src/components/insights/sleep/sleep-quality-section.tsx
@@ -189,9 +189,7 @@ function SleepQualityAssessmentBlock({
// 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
;
}
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/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
index 3d5396c8b..c10525f20 100644
--- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
+++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
@@ -65,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.
@@ -126,39 +127,41 @@ export function VorsorgeDashboardCard() {
className="flex items-center justify-between gap-3"
>
-
-
- {resolveLabel(reminder, t)}
-
-
- {t(`measurementReminders.${due.key}`, { days: due.days })}
-
- {/* v1.18.7 (Wave E) — discreet 7-day strip under the
+
+
+ {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")}
- >
- )}
-
+
+
+ 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 79f4a8b25..4002f1cd5 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -210,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");
@@ -224,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
@@ -640,10 +640,7 @@ export function VorsorgeSection({
{isLoading && (
-
+
{Array.from({ length: 4 }, (_, i) => (
@@ -755,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"
@@ -910,7 +906,8 @@ function VorsorgeCard({
size="sm"
className={cn(
"min-h-11 sm:min-h-9",
- isDue && "bg-success text-success-foreground hover:bg-success/90",
+ isDue &&
+ "bg-success text-success-foreground hover:bg-success/90",
)}
onClick={onPrimaryAction}
disabled={busy}
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/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 fd53625e6..49e368be3 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -1515,7 +1515,9 @@ function DayDrillDown({
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__/drug-level-chart.test.tsx b/src/components/medications/__tests__/drug-level-chart.test.tsx
index d8cc5777c..a509dbcd3 100644
--- a/src/components/medications/__tests__/drug-level-chart.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: (
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__/medication-card-header.test.tsx b/src/components/medications/__tests__/medication-card-header.test.tsx
index e1976d9ec..f38b5ff0d 100644
--- a/src/components/medications/__tests__/medication-card-header.test.tsx
+++ b/src/components/medications/__tests__/medication-card-header.test.tsx
@@ -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__/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-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/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index 5f5fdcd09..fd7febc1d 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -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/drug-level-chart.tsx b/src/components/medications/drug-level-chart.tsx
index a2c523862..444dd1173 100644
--- a/src/components/medications/drug-level-chart.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/medication-card-header.tsx b/src/components/medications/medication-card-header.tsx
index a00f84b37..1a7af3740 100644
--- a/src/components/medications/medication-card-header.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/scheduling/__tests__/cadence-picker.test.tsx b/src/components/medications/scheduling/__tests__/cadence-picker.test.tsx
index 3d232df9a..a1eedec3a 100644
--- a/src/components/medications/scheduling/__tests__/cadence-picker.test.tsx
+++ b/src/components/medications/scheduling/__tests__/cadence-picker.test.tsx
@@ -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__/course-window-row.test.tsx b/src/components/medications/scheduling/__tests__/course-window-row.test.tsx
index 84d10f205..59133c728 100644
--- a/src/components/medications/scheduling/__tests__/course-window-row.test.tsx
+++ b/src/components/medications/scheduling/__tests__/course-window-row.test.tsx
@@ -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__/times-of-day-chips.test.tsx b/src/components/medications/scheduling/__tests__/times-of-day-chips.test.tsx
index a5593b438..b5121034d 100644
--- a/src/components/medications/scheduling/__tests__/times-of-day-chips.test.tsx
+++ b/src/components/medications/scheduling/__tests__/times-of-day-chips.test.tsx
@@ -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/cadence-picker.tsx b/src/components/medications/scheduling/cadence-picker.tsx
index 7a84ef0b5..aac443d72 100644
--- a/src/components/medications/scheduling/cadence-picker.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/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 1fa87d947..5af04db0f 100644
--- a/src/components/medications/scheduling/legacy-bridge.ts
+++ b/src/components/medications/scheduling/legacy-bridge.ts
@@ -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/natural-language-extractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx
index cd9a2d067..29a55dd86 100644
--- a/src/components/medications/scheduling/natural-language-extractor.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-times-editor.tsx b/src/components/medications/scheduling/schedule-times-editor.tsx
index 84b1c78f8..58f24262e 100644
--- a/src/components/medications/scheduling/schedule-times-editor.tsx
+++ b/src/components/medications/scheduling/schedule-times-editor.tsx
@@ -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/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 9018ea169..9a54d11f9 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -130,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",
@@ -181,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
@@ -501,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;
@@ -607,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"
@@ -767,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"),
})}
@@ -824,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 ||
@@ -933,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/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/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts
index 90a4f64f3..e59142d4a 100644
--- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts
+++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts
@@ -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/steps/step2-class.tsx b/src/components/medications/wizard/steps/step2-class.tsx
index c4b9d2f27..9ff663e84 100644
--- a/src/components/medications/wizard/steps/step2-class.tsx
+++ b/src/components/medications/wizard/steps/step2-class.tsx
@@ -25,10 +25,7 @@ import {
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/step4-window.tsx b/src/components/medications/wizard/steps/step4-window.tsx
index e14c3b070..0308a40be 100644
--- a/src/components/medications/wizard/steps/step4-window.tsx
+++ b/src/components/medications/wizard/steps/step4-window.tsx
@@ -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/step6-sub-cadence.tsx b/src/components/medications/wizard/steps/step6-sub-cadence.tsx
index f3dbfcb05..259b0e499 100644
--- a/src/components/medications/wizard/steps/step6-sub-cadence.tsx
+++ b/src/components/medications/wizard/steps/step6-sub-cadence.tsx
@@ -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/step7-times.tsx b/src/components/medications/wizard/steps/step7-times.tsx
index 8536242ea..696781be9 100644
--- a/src/components/medications/wizard/steps/step7-times.tsx
+++ b/src/components/medications/wizard/steps/step7-times.tsx
@@ -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/step8-summary.tsx b/src/components/medications/wizard/steps/step8-summary.tsx
index 0cc776f46..1b9ddce80 100644
--- a/src/components/medications/wizard/steps/step8-summary.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 225b7dca0..df549d54e 100644
--- a/src/components/medications/wizard/wizard-payload.ts
+++ b/src/components/medications/wizard/wizard-payload.ts
@@ -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
index d5ee67dc5..765453fbc 100644
--- a/src/components/medications/wizard/wizard-stepper.tsx
+++ b/src/components/medications/wizard/wizard-stepper.tsx
@@ -84,114 +84,114 @@ export function WizardStepper({
aria-label={srLabel}
>
-
-
-
+
+
+
-
- {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;
+
+ {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}`}
+ return (
+
- 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(
- "grid h-3 w-3 place-items-center rounded-full border transition-colors",
- isActive
- ? "border-primary bg-primary ring-primary/30 ring-2 ring-offset-1 ring-offset-transparent"
- : isCompleted
- ? "border-primary bg-primary"
- : isReachable
- ? "border-primary bg-transparent"
- : "border-muted-foreground/30 bg-transparent",
- )}
- />
-
- {label}
-
-
-
- );
- })}
-
+
+
+ {label}
+
+
+
+ );
+ })}
+
-
-
-
+
+
+
{/* Compact caption — on narrow viewports the per-dot labels collapse
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 41ad4ccf7..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 "../anamnesis-card";
+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/anamnesis-card.tsx b/src/components/onboarding/anamnesis-card.tsx
index 723d6f98e..3b147d003 100644
--- a/src/components/onboarding/anamnesis-card.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/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx
index 5462c287d..2ca6f2d47 100644
--- a/src/components/onboarding/baseline-form.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -153,10 +153,7 @@ export function BaselineForm() {
}
return (
-
+
- {saving ? : null}
+ {saving ? (
+
+ ) : null}
{t("onboarding.baseline.saveCta")}
@@ -292,4 +291,3 @@ export function BaselineForm() {
);
}
-
diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx
index 5e02f3716..c11ef44cf 100644
--- a/src/components/onboarding/done-screen.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/goals-chip-picker.tsx b/src/components/onboarding/goals-chip-picker.tsx
index 6a9d46e1c..1425e89e4 100644
--- a/src/components/onboarding/goals-chip-picker.tsx
+++ b/src/components/onboarding/goals-chip-picker.tsx
@@ -139,10 +139,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
}
return (
-
+
-
+
{t(option.labelKey)}
@@ -230,4 +227,3 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
);
}
-
diff --git a/src/components/onboarding/onboarding-shell.tsx b/src/components/onboarding/onboarding-shell.tsx
index 7bb0b6f76..1084ba693 100644
--- a/src/components/onboarding/onboarding-shell.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}
@@ -318,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}`}
@@ -328,4 +320,3 @@ function SourceCardItem({
);
}
-
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index 43bc22475..d8d50376d 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -336,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(() => {});
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/welcome-carousel.tsx b/src/components/onboarding/welcome-carousel.tsx
index fde5a41bc..9647e3c5f 100644
--- a/src/components/onboarding/welcome-carousel.tsx
+++ b/src/components/onboarding/welcome-carousel.tsx
@@ -164,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}
@@ -335,4 +330,3 @@ export function WelcomeCarousel() {
);
}
-
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/advanced-section.tsx b/src/components/settings/advanced-section.tsx
index 46f9414a7..39ef7f925 100644
--- a/src/components/settings/advanced-section.tsx
+++ b/src/components/settings/advanced-section.tsx
@@ -140,9 +140,7 @@ function ResearchModeCard() {
}
return (
-
+
+
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
neutralised the title colour so the danger-zone shaping is
GitHub-style (red CTA only) rather than red-on-red-on-red.
@@ -401,9 +397,7 @@ function AccountDeleteCard() {
}
return (
-
+
diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx
index 73852c7d8..db637acb2 100644
--- a/src/components/settings/ai/disable-coach-card.tsx
+++ b/src/components/settings/ai/disable-coach-card.tsx
@@ -95,7 +95,9 @@ export function DisableCoachCard({
// the activated-state view of it. The SSR default is ON (checked) so the
// default-on contract holds before the wire value resolves.
const mounted = useMounted();
- const activated = mounted ? (optimistic ?? !(user?.disableCoach ?? false)) : true;
+ const activated = mounted
+ ? (optimistic ?? !(user?.disableCoach ?? false))
+ : true;
const mutation = useMutation({
// `next` is the desired ACTIVATED state; the column stores the inverse.
diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx
index d1c5e4498..0631a8f1f 100644
--- a/src/components/settings/ai/fallback-chain-card.tsx
+++ b/src/components/settings/ai/fallback-chain-card.tsx
@@ -250,11 +250,7 @@ export function FallbackChainCard({
-
+
{t("settings.ai.providerChain.resetDefaults")}
diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx
index a502d519f..05d3fe199 100644
--- a/src/components/settings/coach-prefs-section.tsx
+++ b/src/components/settings/coach-prefs-section.tsx
@@ -107,9 +107,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
const isLoading = isAuthenticated && !persisted;
return (
-
+
+
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
right-align-on-desktop contract as Account → Password +
Restart onboarding tour. Avoids the long German "Auf Standard
diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx
index 0a2801501..bb5895dcf 100644
--- a/src/components/settings/email-card.tsx
+++ b/src/components/settings/email-card.tsx
@@ -129,7 +129,11 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
endpoint="/api/settings/email/test"
disabled={!settings?.enabled}
/>
-
+
{save.isPending && (
)}
diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx
index 039d4e59a..c42fc41b4 100644
--- a/src/components/settings/import-panel.tsx
+++ b/src/components/settings/import-panel.tsx
@@ -103,10 +103,7 @@ function ImportCardShell({
children,
}: ImportCardShellProps) {
return (
-
+
+
+
-
{t("settings.nightscoutUrl")}
+
+ {t("settings.nightscoutUrl")}
+
+
,
diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx
index 9d488a10d..57d1822bc 100644
--- a/src/components/settings/medications-section.tsx
+++ b/src/components/settings/medications-section.tsx
@@ -80,10 +80,7 @@ export function MedicationsSection() {
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
-
+
{/* Manual order — applies to both list views. */}
-
+
+
{/* Groups — picker group order + own groups. */}
-
+
{/* Tags — visibility, order, edit, move, create. */}
-
+
{isLoading || !catalog ? loading : }
{/* Archived custom tags — restore / hard delete. */}
-
+
+
{headingBlock}
: null}
+ {headingBlock ? (
+ {headingBlock}
+ ) : null}
{/* Mobile section strip — horizontal scroll, hidden on md+.
`no-scrollbar` (defined in `globals.css`) suppresses the
diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx
index 6a1efd874..d42000c7f 100644
--- a/src/components/settings/sharing-section.tsx
+++ b/src/components/settings/sharing-section.tsx
@@ -270,9 +270,7 @@ function ShareLinksCard() {
-
- {t("settings.sharing.expiry")}
-
+
{t("settings.sharing.expiry")}
+
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
already provides the title + description for this route, so the
card-level title + subtitle were a duplicate of the page header.
diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx
index e43e0e40d..310e5f241 100644
--- a/src/components/settings/webhook-card.tsx
+++ b/src/components/settings/webhook-card.tsx
@@ -43,7 +43,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
});
// React-recommended sync-from-server pattern (no setState-in-effect).
- const settingsKey = settings ? `${settings.url}|${settings.headerName}` : null;
+ const settingsKey = settings
+ ? `${settings.url}|${settings.headerName}`
+ : null;
const [seededKey, setSeededKey] = useState(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
@@ -161,7 +163,11 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
/>
-
+
{save.isPending && (
)}
diff --git a/src/components/ui/__tests__/responsive-sheet.test.tsx b/src/components/ui/__tests__/responsive-sheet.test.tsx
index 95ac92e14..026a3826f 100644
--- a/src/components/ui/__tests__/responsive-sheet.test.tsx
+++ b/src/components/ui/__tests__/responsive-sheet.test.tsx
@@ -18,13 +18,8 @@ vi.mock("@/hooks/use-is-mobile", () => ({
}));
vi.mock("@/components/ui/sheet", () => ({
- Sheet: ({
- children,
- open,
- }: {
- children: React.ReactNode;
- open: boolean;
- }) => (open ? {children}
: null),
+ Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
+ open ? {children}
: null,
SheetContent: ({
children,
className,
@@ -67,13 +62,8 @@ vi.mock("@/components/ui/sheet", () => ({
}));
vi.mock("@/components/ui/dialog", () => ({
- Dialog: ({
- children,
- open,
- }: {
- children: React.ReactNode;
- open: boolean;
- }) => (open ? {children}
: null),
+ Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
+ open ? {children}
: null,
DialogContent: ({
children,
className,
@@ -209,11 +199,7 @@ describe("", () => {
// Mounted closed — Dialog mock returns null, smoke-only render
// confirms no throw + the prop wiring round-trips.
renderToStaticMarkup(
-
+
body
,
);
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index dd588b68e..08472023c 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -69,7 +69,7 @@ function DialogContent({
// viewport on short / soft-keyboard-collapsed mobile screens.
// Consumers that need a different ceiling can override via
// `className`.
- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
@@ -83,7 +83,7 @@ function DialogContent({
// crowd the dialog header out of proportion.
{/* v1.4.34 IW-G — read `common.close` so screen readers
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx
index cdaad1d3a..06fdc35d5 100644
--- a/src/components/ui/empty-state.tsx
+++ b/src/components/ui/empty-state.tsx
@@ -103,7 +103,7 @@ function EmptyState({
// existing CTAs that pass a plain `` (no class
// overrides) pick up the lift automatically.
ctaSize === "lg" &&
- "w-full sm:w-auto [&>a]:min-h-11 [&>a]:w-full [&>button]:min-h-11 [&>button]:w-full sm:[&>a]:w-auto sm:[&>button]:w-auto",
+ "w-full sm:w-auto [&>a]:min-h-11 [&>a]:w-full sm:[&>a]:w-auto [&>button]:min-h-11 [&>button]:w-full sm:[&>button]:w-auto",
)}
>
{action}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 1376c6060..203c3dc8a 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -69,7 +69,7 @@ function Input({
className={cn(
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
// shrink to 40 px on sm+ where pointer precision is higher.
- "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 sm:h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
diff --git a/src/components/ui/learning-gate.tsx b/src/components/ui/learning-gate.tsx
index 7befc3245..346a94a38 100644
--- a/src/components/ui/learning-gate.tsx
+++ b/src/components/ui/learning-gate.tsx
@@ -24,8 +24,10 @@ import { cn } from "@/lib/utils";
* - `"bordered"`: a dashed-border centered note for STANDALONE use where
* there is no surrounding card chrome (score anatomy).
*/
-export interface LearningGateProps
- extends Omit, "title"> {
+export interface LearningGateProps extends Omit<
+ React.HTMLAttributes,
+ "title"
+> {
/** Localized primary "still learning" sentence. */
message: React.ReactNode;
/** Optional secondary line — a caveat or a "N more days" nudge. */
@@ -72,8 +74,7 @@ export function LearningGate({
aria-live="polite"
className={cn(
"space-y-2",
- variant === "bordered" &&
- "rounded-lg border border-dashed px-4 py-6",
+ variant === "bordered" && "rounded-lg border border-dashed px-4 py-6",
className,
)}
{...props}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 6a1fdcd64..45cb3da2b 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -49,7 +49,7 @@ function SelectTrigger({
// value are inset equally; `gap-2` between them keeps the chevron
// from crowding a long value, and `shrink-0` guarantees it never
// clips.
- "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 data-[size=default]:sm:h-10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
diff --git a/src/components/ui/sheet-section.tsx b/src/components/ui/sheet-section.tsx
index 2dcdea099..bd865124a 100644
--- a/src/components/ui/sheet-section.tsx
+++ b/src/components/ui/sheet-section.tsx
@@ -74,10 +74,7 @@ export function SheetSection({
{title}
{summary ? (
-
+
{summary}
) : null}
@@ -88,7 +85,7 @@ export function SheetSection({
{children}
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index a2b355b6f..0b9080ba0 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -85,7 +85,7 @@ function SheetContent({
// header out of proportion on the bottom-sheet branch.
{/* v1.4.34 IW-G — read `common.close` so screen readers
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
index 80acff7d0..7bf1e1b06 100644
--- a/src/components/ui/sonner.tsx
+++ b/src/components/ui/sonner.tsx
@@ -23,7 +23,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
info: ,
warning: ,
error: ,
- loading: ,
+ loading: (
+
+ ),
}}
style={
{
diff --git a/src/hooks/__tests__/use-auth-retry.test.ts b/src/hooks/__tests__/use-auth-retry.test.ts
index a5236c9ce..744599635 100644
--- a/src/hooks/__tests__/use-auth-retry.test.ts
+++ b/src/hooks/__tests__/use-auth-retry.test.ts
@@ -10,9 +10,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-const useQueryMock = vi.fn<(opts: Record) => { data: undefined }>(
- () => ({ data: undefined }),
-);
+const useQueryMock = vi.fn<
+ (opts: Record) => { data: undefined }
+>(() => ({ data: undefined }));
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: Record) => useQueryMock(opts),
diff --git a/src/hooks/__tests__/use-feature-flags.test.tsx b/src/hooks/__tests__/use-feature-flags.test.tsx
index b34dbe36b..2c7cae7d3 100644
--- a/src/hooks/__tests__/use-feature-flags.test.tsx
+++ b/src/hooks/__tests__/use-feature-flags.test.tsx
@@ -3,10 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import {
- DEFAULT_ASSISTANT_FLAGS,
- useFeatureFlags,
-} from "../use-feature-flags";
+import { DEFAULT_ASSISTANT_FLAGS, useFeatureFlags } from "../use-feature-flags";
function Probe() {
const flags = useFeatureFlags();
@@ -46,22 +43,23 @@ describe("useFeatureFlags", () => {
it("returns the resolved matrix when the endpoint responds", async () => {
vi.stubGlobal(
"fetch",
- vi.fn(async () =>
- new Response(
- JSON.stringify({
- data: {
- assistant: {
- enabled: true,
- coach: false,
- briefing: true,
- insightStatus: false,
- correlations: true,
- healthScoreExplainer: false,
+ vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: {
+ assistant: {
+ enabled: true,
+ coach: false,
+ briefing: true,
+ insightStatus: false,
+ correlations: true,
+ healthScoreExplainer: false,
+ },
},
- },
- }),
- { status: 200 },
- ),
+ }),
+ { status: 200 },
+ ),
),
);
diff --git a/src/hooks/__tests__/use-insight-status.test.ts b/src/hooks/__tests__/use-insight-status.test.ts
index 0f352ec9d..b36ddd5e8 100644
--- a/src/hooks/__tests__/use-insight-status.test.ts
+++ b/src/hooks/__tests__/use-insight-status.test.ts
@@ -35,9 +35,9 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => {
it("stops polling once the attempt cap is reached", () => {
expect(nextStatusPollInterval(true, STATUS_POLL_MAX_ATTEMPTS)).toBe(false);
- expect(
- nextStatusPollInterval(true, STATUS_POLL_MAX_ATTEMPTS + 5),
- ).toBe(false);
+ expect(nextStatusPollInterval(true, STATUS_POLL_MAX_ATTEMPTS + 5)).toBe(
+ false,
+ );
});
it("returns a positive interval while it polls", () => {
@@ -58,9 +58,9 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => {
});
it("honours the same attempt cap for revalidating", () => {
- expect(
- nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS, true),
- ).toBe(false);
+ expect(nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS, true)).toBe(
+ false,
+ );
expect(
nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS + 5, true),
).toBe(false);
diff --git a/src/hooks/__tests__/use-roving-radio-group.test.ts b/src/hooks/__tests__/use-roving-radio-group.test.ts
index 97837a5c3..85cfe48b3 100644
--- a/src/hooks/__tests__/use-roving-radio-group.test.ts
+++ b/src/hooks/__tests__/use-roving-radio-group.test.ts
@@ -49,7 +49,9 @@ describe("rovingRadioNextIndex", () => {
it("skips disabled options when stepping", () => {
// Middle option disabled: ArrowRight from 0 lands on 2, not 1.
const middleDisabled = (i: number) => i === 1;
- expect(rovingRadioNextIndex("ArrowRight", 0, COUNT, middleDisabled)).toBe(2);
+ expect(rovingRadioNextIndex("ArrowRight", 0, COUNT, middleDisabled)).toBe(
+ 2,
+ );
expect(rovingRadioNextIndex("ArrowLeft", 2, COUNT, middleDisabled)).toBe(0);
});
@@ -60,8 +62,6 @@ describe("rovingRadioNextIndex", () => {
});
it("returns null when every option is disabled", () => {
- expect(
- rovingRadioNextIndex("ArrowRight", 0, COUNT, () => true),
- ).toBeNull();
+ expect(rovingRadioNextIndex("ArrowRight", 0, COUNT, () => true)).toBeNull();
});
});
diff --git a/src/hooks/use-count-up.ts b/src/hooks/use-count-up.ts
index 57991d621..c5feda310 100644
--- a/src/hooks/use-count-up.ts
+++ b/src/hooks/use-count-up.ts
@@ -17,7 +17,11 @@ import { useEffect, useRef, useState } from "react";
*/
export function useCountUp(
target: number,
- options: { durationMs?: number; enabled?: boolean; startDelayMs?: number } = {},
+ options: {
+ durationMs?: number;
+ enabled?: boolean;
+ startDelayMs?: number;
+ } = {},
): number {
const { durationMs = 600, enabled = true, startDelayMs = 0 } = options;
// Seed at the final value so the reduced-motion / SSR path is already
@@ -47,9 +51,7 @@ export function useCountUp(
typeof window !== "undefined" &&
typeof window.requestAnimationFrame === "function"
) {
- frameRef.current = window.requestAnimationFrame(() =>
- setValue(target),
- );
+ frameRef.current = window.requestAnimationFrame(() => setValue(target));
return () => {
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
diff --git a/src/hooks/use-insights-analytics.ts b/src/hooks/use-insights-analytics.ts
index aa4bcc20a..5f354a798 100644
--- a/src/hooks/use-insights-analytics.ts
+++ b/src/hooks/use-insights-analytics.ts
@@ -1,7 +1,10 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
-import { hasMetricData, type InsightMetric } from "@/lib/insights/metric-availability";
+import {
+ hasMetricData,
+ type InsightMetric,
+} from "@/lib/insights/metric-availability";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import type { SubPageAnalyticsData } from "@/types/analytics";
diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts
index 23921c0ca..21998a28c 100644
--- a/src/hooks/use-measurement-reminders.ts
+++ b/src/hooks/use-measurement-reminders.ts
@@ -53,9 +53,8 @@ export interface CreateMeasurementReminderBody {
enabled?: boolean;
}
-export type UpdateMeasurementReminderBody = Partial<
- CreateMeasurementReminderBody
->;
+export type UpdateMeasurementReminderBody =
+ Partial;
const BASE = "/api/measurement-reminders";
diff --git a/src/hooks/use-roving-radio-group.ts b/src/hooks/use-roving-radio-group.ts
index ab9a90374..45e6d52da 100644
--- a/src/hooks/use-roving-radio-group.ts
+++ b/src/hooks/use-roving-radio-group.ts
@@ -70,7 +70,9 @@ export function rovingRadioNextIndex(
const firstEnabled = firstEnabledIndex(count, enabled);
if (firstEnabled === null) return null;
const cursor =
- current >= 0 && current < count && enabled(current) ? current : firstEnabled;
+ current >= 0 && current < count && enabled(current)
+ ? current
+ : firstEnabled;
switch (key) {
case "ArrowRight":
diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts
index 6d2c49ace..30cbe4937 100644
--- a/src/hooks/use-workouts.ts
+++ b/src/hooks/use-workouts.ts
@@ -118,12 +118,10 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
elevationM: number | null;
pauseDurationSec: number | null;
metadata: unknown;
- route:
- | {
- geometry: unknown;
- sampleTimestamps: string[] | null;
- }
- | null;
+ route: {
+ geometry: unknown;
+ sampleTimestamps: string[] | null;
+ } | null;
canonicalId: string;
}
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
index 95eb33211..77ab68ae8 100644
--- a/src/instrumentation.ts
+++ b/src/instrumentation.ts
@@ -6,9 +6,8 @@ export async function register() {
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
- const { startEventLoopLagMonitor } = await import(
- "@/lib/observability/event-loop-lag"
- );
+ const { startEventLoopLagMonitor } =
+ await import("@/lib/observability/event-loop-lag");
startEventLoopLagMonitor();
const { shouldRunWorker } = await import("@/lib/process-type");
diff --git a/src/lib/__tests__/api-handler-safe-request-prop.test.ts b/src/lib/__tests__/api-handler-safe-request-prop.test.ts
index b15f75192..1c4249c07 100644
--- a/src/lib/__tests__/api-handler-safe-request-prop.test.ts
+++ b/src/lib/__tests__/api-handler-safe-request-prop.test.ts
@@ -90,9 +90,9 @@ describe("isTolerableRequestProbeError — narrow-catch classifier", () => {
expect(isTolerableRequestProbeError(new Error("private member #x"))).toBe(
false,
);
- expect(
- isTolerableRequestProbeError(new RangeError("private member")),
- ).toBe(false);
+ expect(isTolerableRequestProbeError(new RangeError("private member"))).toBe(
+ false,
+ );
expect(isTolerableRequestProbeError("private member string")).toBe(false);
expect(isTolerableRequestProbeError(null)).toBe(false);
expect(isTolerableRequestProbeError(undefined)).toBe(false);
diff --git a/src/lib/__tests__/api-response-zod.test.ts b/src/lib/__tests__/api-response-zod.test.ts
index 5f45b2b3d..902349e4e 100644
--- a/src/lib/__tests__/api-response-zod.test.ts
+++ b/src/lib/__tests__/api-response-zod.test.ts
@@ -1,9 +1,6 @@
import { describe, it, expect } from "vitest";
import { z } from "zod/v4";
-import {
- returnAllZodIssues,
- sanitiseZodIssues,
-} from "../api-response";
+import { returnAllZodIssues, sanitiseZodIssues } from "../api-response";
// v1.4.42 W2 — every Zod-validated route historically returned
// `parsed.error.issues[0].message`, dropping every issue past the first.
@@ -28,7 +25,9 @@ describe("returnAllZodIssues", () => {
const body = (await response.json()) as {
data: null;
error: string;
- details: { issues: Array<{ path: string; code: string; message: string }> };
+ details: {
+ issues: Array<{ path: string; code: string; message: string }>;
+ };
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
diff --git a/src/lib/__tests__/avatar.test.ts b/src/lib/__tests__/avatar.test.ts
index 1569b2357..544e2d5eb 100644
--- a/src/lib/__tests__/avatar.test.ts
+++ b/src/lib/__tests__/avatar.test.ts
@@ -15,14 +15,41 @@ const PNG_1X1 = Buffer.from(
/** Minimal JPEG: SOI + APP0 + SOF0(1×1) + EOI. */
const JPEG_1X1 = Buffer.from([
- 0xff, 0xd8, // SOI
- 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
- 0x01, 0x00, 0x01, 0x00, 0x00,
- 0xff, 0xc0, 0x00, 0x0b, 0x08,
- 0x00, 0x01, // height = 1
- 0x00, 0x01, // width = 1
- 0x01, 0x01, 0x11, 0x00,
- 0xff, 0xd9, // EOI
+ 0xff,
+ 0xd8, // SOI
+ 0xff,
+ 0xe0,
+ 0x00,
+ 0x10,
+ 0x4a,
+ 0x46,
+ 0x49,
+ 0x46,
+ 0x00,
+ 0x01,
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x01,
+ 0x00,
+ 0x01,
+ 0x00,
+ 0x00,
+ 0xff,
+ 0xc0,
+ 0x00,
+ 0x0b,
+ 0x08,
+ 0x00,
+ 0x01, // height = 1
+ 0x00,
+ 0x01, // width = 1
+ 0x01,
+ 0x01,
+ 0x11,
+ 0x00,
+ 0xff,
+ 0xd9, // EOI
]);
/**
diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts
index fd08a5d0e..752b9fd15 100644
Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ
diff --git a/src/lib/__tests__/format-active-locale.test.ts b/src/lib/__tests__/format-active-locale.test.ts
index 6c7c11cba..b7b86984f 100644
--- a/src/lib/__tests__/format-active-locale.test.ts
+++ b/src/lib/__tests__/format-active-locale.test.ts
@@ -10,14 +10,7 @@
* `window` shape the helper actually reads, so the test runs under
* the default node environment without pulling in `jsdom`.
*/
-import {
- afterEach,
- beforeEach,
- describe,
- expect,
- it,
- vi,
-} from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface Globals {
document?: { cookie: string };
diff --git a/src/lib/__tests__/format-date-or-relative.test.ts b/src/lib/__tests__/format-date-or-relative.test.ts
index 362b1e7d2..8d7f075cd 100644
--- a/src/lib/__tests__/format-date-or-relative.test.ts
+++ b/src/lib/__tests__/format-date-or-relative.test.ts
@@ -106,9 +106,9 @@ describe("formatDateOrRelative", () => {
});
it("accepts a Date instance as well as ISO string", () => {
- expect(
- formatDateOrRelative(new Date(NOW - 30 * MINUTE), fakeT, NOW),
- ).toBe("insights.relativeMinutesAgoOther:count=30");
+ expect(formatDateOrRelative(new Date(NOW - 30 * MINUTE), fakeT, NOW)).toBe(
+ "insights.relativeMinutesAgoOther:count=30",
+ );
});
// v1.4.49.2 — regression guard. The helper's `t()` calls must dispatch
@@ -121,16 +121,39 @@ describe("formatDateOrRelative", () => {
it("emits only keys that exist in the translation bundle", async () => {
const en = await import("../../../messages/en.json");
const bundleKeysCalled = new Set();
- const collectingT = (key: string, params?: Record) => {
+ const collectingT = (
+ key: string,
+ params?: Record,
+ ) => {
bundleKeysCalled.add(key);
return params ? `${key}:${JSON.stringify(params)}` : key;
};
// Drive every relative bucket so we hit `t()` on each branch.
- formatDateOrRelative(new Date(NOW - 30 * SECOND).toISOString(), collectingT, NOW);
- formatDateOrRelative(new Date(NOW - 1 * MINUTE).toISOString(), collectingT, NOW);
- formatDateOrRelative(new Date(NOW - 30 * MINUTE).toISOString(), collectingT, NOW);
- formatDateOrRelative(new Date(NOW - 1 * HOUR).toISOString(), collectingT, NOW);
- formatDateOrRelative(new Date(NOW - 12 * HOUR).toISOString(), collectingT, NOW);
+ formatDateOrRelative(
+ new Date(NOW - 30 * SECOND).toISOString(),
+ collectingT,
+ NOW,
+ );
+ formatDateOrRelative(
+ new Date(NOW - 1 * MINUTE).toISOString(),
+ collectingT,
+ NOW,
+ );
+ formatDateOrRelative(
+ new Date(NOW - 30 * MINUTE).toISOString(),
+ collectingT,
+ NOW,
+ );
+ formatDateOrRelative(
+ new Date(NOW - 1 * HOUR).toISOString(),
+ collectingT,
+ NOW,
+ );
+ formatDateOrRelative(
+ new Date(NOW - 12 * HOUR).toISOString(),
+ collectingT,
+ NOW,
+ );
expect(bundleKeysCalled.size).toBeGreaterThan(0);
// `insights.*` carries a mix of string and nested-object children
// (e.g. `personalRecord: { badge, tooltip }`), so the bundle type
diff --git a/src/lib/__tests__/geo-asn.test.ts b/src/lib/__tests__/geo-asn.test.ts
index 3fc9c3f23..0664b16a5 100644
--- a/src/lib/__tests__/geo-asn.test.ts
+++ b/src/lib/__tests__/geo-asn.test.ts
@@ -75,10 +75,8 @@ vi.mock("node:fs", async () => {
return false;
};
const readFileSync = (p: string) => {
- if (p.endsWith("GeoLite2-City.mmdb"))
- return Buffer.from("city-mmdb-stub");
- if (p.endsWith("GeoLite2-ASN.mmdb"))
- return Buffer.from("asn-mmdb-stub");
+ if (p.endsWith("GeoLite2-City.mmdb")) return Buffer.from("city-mmdb-stub");
+ if (p.endsWith("GeoLite2-ASN.mmdb")) return Buffer.from("asn-mmdb-stub");
throw new Error(`unexpected read ${p}`);
};
return {
@@ -219,7 +217,11 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () =>
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
new TextEncoder().encode(
- JSON.stringify({ success: true, city: "Hamburg", country_code: "DE" }),
+ JSON.stringify({
+ success: true,
+ city: "Hamburg",
+ country_code: "DE",
+ }),
).buffer as ArrayBuffer,
{
status: 200,
diff --git a/src/lib/__tests__/geo-offline-detection.test.ts b/src/lib/__tests__/geo-offline-detection.test.ts
index 787b64819..f56898822 100644
--- a/src/lib/__tests__/geo-offline-detection.test.ts
+++ b/src/lib/__tests__/geo-offline-detection.test.ts
@@ -98,7 +98,10 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
});
it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => {
- writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
+ writeFileSync(
+ join(tmpRoot, "GeoLite2-City.mmdb"),
+ Buffer.from("city-stub"),
+ );
const { offlineGeoReady } = await import("../geo");
expect(offlineGeoReady()).toBe(true);
});
@@ -130,7 +133,10 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
});
it("does NOT fire the notification when the City MMDB is present", async () => {
- writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
+ writeFileSync(
+ join(tmpRoot, "GeoLite2-City.mmdb"),
+ Buffer.from("city-stub"),
+ );
// The stubbed reader returns null for every IP, which is fine — the
// lookup will still go through the online path, but the offline
// check sees a healthy directory so the notification stays silent.
diff --git a/src/lib/__tests__/get-client-ip.test.ts b/src/lib/__tests__/get-client-ip.test.ts
index e56e586f7..2f94ab68c 100644
--- a/src/lib/__tests__/get-client-ip.test.ts
+++ b/src/lib/__tests__/get-client-ip.test.ts
@@ -107,16 +107,12 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
// pollute the rate-limit / audit log with non-IP strings; they are
// now rejected at the helper.
it("rejects structurally invalid IPv4 candidates (1.2)", () => {
- const ip = getClientIp(
- makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }),
- );
+ const ip = getClientIp(makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }));
expect(ip).toBe("5.6.7.8");
});
it("rejects structurally invalid IPv6 candidates (:::)", () => {
- const ip = getClientIp(
- makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }),
- );
+ const ip = getClientIp(makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }));
expect(ip).toBe("5.6.7.8");
});
@@ -129,9 +125,7 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
it("accepts well-formed IPv6 addresses", () => {
process.env.TRUST_PROXY_HOPS = "1";
- const ip = getClientIp(
- makeRequest({ "x-forwarded-for": "2001:db8::1" }),
- );
+ const ip = getClientIp(makeRequest({ "x-forwarded-for": "2001:db8::1" }));
expect(ip).toBe("2001:db8::1");
});
@@ -294,12 +288,8 @@ describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => {
process.env.TRUST_PROXY_HOPS = "2";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
- getClientIpOrTrustWarning(
- makeRequest({ "x-forwarded-for": "5.6.7.8" }),
- );
- getClientIpOrTrustWarning(
- makeRequest({ "x-forwarded-for": "5.6.7.8" }),
- );
+ getClientIpOrTrustWarning(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
+ getClientIpOrTrustWarning(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
expect(warn).toHaveBeenCalledTimes(1);
} finally {
warn.mockRestore();
diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts
index 96c67f823..c68033ce7 100644
--- a/src/lib/__tests__/insights-layout.test.ts
+++ b/src/lib/__tests__/insights-layout.test.ts
@@ -189,7 +189,9 @@ describe("serializeInsightsLayout — defaults + dense order", () => {
tiles: [{ id: "overview", visible: true, order: 0 }],
});
// Sorted by input order, then re-numbered 0,1,2.
- expect(serialized.sections.map((s) => ({ id: s.id, order: s.order }))).toEqual([
+ expect(
+ serialized.sections.map((s) => ({ id: s.id, order: s.order })),
+ ).toEqual([
{ id: "trends", order: 0 },
{ id: "vitals", order: 1 },
{ id: "signals", order: 2 },
@@ -333,7 +335,11 @@ describe("orderedVisibleSectionIds — W2 render-order helper", () => {
const visible = orderedVisibleSectionIds(layout);
// The three explicit sections lead in their saved order; the rest
// auto-merge invisible (new-id semantics), so they do NOT appear.
- expect(visible.slice(0, 3)).toEqual(["trends", "vitals", "wellness-scores"]);
+ expect(visible.slice(0, 3)).toEqual([
+ "trends",
+ "vitals",
+ "wellness-scores",
+ ]);
});
});
diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts
index 5b1e70f5f..b9bfbf88a 100644
--- a/src/lib/__tests__/query-keys.test.ts
+++ b/src/lib/__tests__/query-keys.test.ts
@@ -33,7 +33,10 @@ describe("queryKeys factory", () => {
});
it("threads the slim slice through queryKeys.analytics", () => {
- expect(queryKeys.analytics("summaries")).toEqual(["analytics", "summaries"]);
+ expect(queryKeys.analytics("summaries")).toEqual([
+ "analytics",
+ "summaries",
+ ]);
});
it("keeps the no-arg call byte-identical to the legacy literal", () => {
@@ -238,7 +241,9 @@ describe("dependent-key bundles", () => {
// (audit-L4).
it("medicationDependentKeys bundles the dashboard-medication-compliance prefix (v1.4.40)", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
- expect(keyStrings).toContain(JSON.stringify(["dashboard-medication-compliance"]));
+ expect(keyStrings).toContain(
+ JSON.stringify(["dashboard-medication-compliance"]),
+ );
});
// v1.5.5 D-3 §10 invariant 20 — the per-medication inline compliance
@@ -334,7 +339,8 @@ describe("queryKey factory enforcement", () => {
}
it("no guarded file declares a bare-literal `queryKey:` (factory enforcement)", () => {
- const offenders: Array<{ file: string; line: number; snippet: string }> = [];
+ const offenders: Array<{ file: string; line: number; snippet: string }> =
+ [];
const files = collect();
for (const file of files) {
const text = readFileSync(file, "utf8");
diff --git a/src/lib/__tests__/reference-ranges.test.ts b/src/lib/__tests__/reference-ranges.test.ts
index 730739d9c..e701fc435 100644
--- a/src/lib/__tests__/reference-ranges.test.ts
+++ b/src/lib/__tests__/reference-ranges.test.ts
@@ -17,13 +17,22 @@ describe("reference-ranges backbone", () => {
it("every band + conflict + headline resolves a real citation", () => {
for (const metric of REFERENCE_METRICS) {
const range = REFERENCE_RANGES[metric];
- expect(CITATIONS[range.referenceId], `${metric} referenceId`).toBeTruthy();
+ expect(
+ CITATIONS[range.referenceId],
+ `${metric} referenceId`,
+ ).toBeTruthy();
expect(range.unit, `${metric} unit`).toBeTruthy();
expect(range.guidanceCaveat, `${metric} guidanceCaveat`).toBeTruthy();
for (const band of range.bands) {
- expect(CITATIONS[band.citation], `${metric} band ${band.label}`).toBeTruthy();
+ expect(
+ CITATIONS[band.citation],
+ `${metric} band ${band.label}`,
+ ).toBeTruthy();
// A band must pin at least one bound.
- expect(band.low != null || band.high != null, `${metric} ${band.label}`).toBe(true);
+ expect(
+ band.low != null || band.high != null,
+ `${metric} ${band.label}`,
+ ).toBe(true);
}
for (const conflict of range.conflicts ?? []) {
expect(CITATIONS[conflict.citation], `${metric} conflict`).toBeTruthy();
@@ -62,14 +71,18 @@ describe("reference-ranges backbone", () => {
const marked = bands.filter((b) => b.normal === true);
expect(marked, `${metric} normal-band count`).toHaveLength(1);
// `normalBandIndex` points at that marked band.
- expect(bands[normalBandIndex(metric)].normal, `${metric} index`).toBe(true);
+ expect(bands[normalBandIndex(metric)].normal, `${metric} index`).toBe(
+ true,
+ );
}
});
it("resolves the normal band by marker, not by index 0", () => {
// BMI authors Underweight FIRST; the normal band is the 18.5–24.9 entry.
expect(REFERENCE_RANGES.BMI.bands[0].label).toBe("Underweight");
- expect(REFERENCE_RANGES.BMI.bands[normalBandIndex("BMI")].label).toBe("Normal");
+ expect(REFERENCE_RANGES.BMI.bands[normalBandIndex("BMI")].label).toBe(
+ "Normal",
+ );
// BMI 22 is healthy → within (the index-0 bug reported it slightly-outside).
expect(classifyReference("BMI", 22)).toBe("within");
// BMI 17 is underweight → one band below normal → slightly-outside
@@ -93,10 +106,14 @@ describe("reference-ranges backbone", () => {
it("returns insufficient for absent values and band-less metrics", () => {
expect(classifyReference("RESTING_HEART_RATE", null)).toBe("insufficient");
- expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe("insufficient");
+ expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe(
+ "insufficient",
+ );
// HRV carries no fixed population band — baseline-only.
expect(REFERENCE_RANGES.HEART_RATE_VARIABILITY.bands).toHaveLength(0);
- expect(classifyReference("HEART_RATE_VARIABILITY", 42)).toBe("insufficient");
+ expect(classifyReference("HEART_RATE_VARIABILITY", 42)).toBe(
+ "insufficient",
+ );
});
it("steps backbone uses the canonical 8,000 green floor", () => {
diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts
index c2c455ce8..c3df27353 100644
--- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts
+++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts
@@ -67,18 +67,16 @@ describe("pinnedPublicDispatcher", () => {
it("refuses a hostname that resolves to a private IP", async () => {
// Mock dns.lookup to return 169.254.169.254 (cloud metadata).
const lookupSpy = vi.spyOn(dns, "lookup");
- lookupSpy.mockImplementation(
- ((
- _hostname: string,
- _opts: dns.LookupAllOptions,
- callback: (
- err: NodeJS.ErrnoException | null,
- addresses: dns.LookupAddress[],
- ) => void,
- ) => {
- callback(null, [{ address: "169.254.169.254", family: 4 }]);
- }) as unknown as typeof dns.lookup,
- );
+ lookupSpy.mockImplementation(((
+ _hostname: string,
+ _opts: dns.LookupAllOptions,
+ callback: (
+ err: NodeJS.ErrnoException | null,
+ addresses: dns.LookupAddress[],
+ ) => void,
+ ) => {
+ callback(null, [{ address: "169.254.169.254", family: 4 }]);
+ }) as unknown as typeof dns.lookup);
const dispatcher = getPinnedPublicDispatcher();
// Drive the dispatcher through `fetch` so the integration mirrors
@@ -100,18 +98,16 @@ describe("pinnedPublicDispatcher", () => {
// The expected outcome is therefore NOT ENOTFOUND but a connect
// failure further down the stack (the IP refuses or times out).
const lookupSpy = vi.spyOn(dns, "lookup");
- lookupSpy.mockImplementation(
- ((
- _hostname: string,
- _opts: dns.LookupAllOptions,
- callback: (
- err: NodeJS.ErrnoException | null,
- addresses: dns.LookupAddress[],
- ) => void,
- ) => {
- callback(null, [{ address: "192.0.2.1", family: 4 }]);
- }) as unknown as typeof dns.lookup,
- );
+ lookupSpy.mockImplementation(((
+ _hostname: string,
+ _opts: dns.LookupAllOptions,
+ callback: (
+ err: NodeJS.ErrnoException | null,
+ addresses: dns.LookupAddress[],
+ ) => void,
+ ) => {
+ callback(null, [{ address: "192.0.2.1", family: 4 }]);
+ }) as unknown as typeof dns.lookup);
const dispatcher = getPinnedPublicDispatcher();
let caught: unknown;
@@ -135,21 +131,19 @@ describe("pinnedPublicDispatcher", () => {
// Mock dns.lookup to return one private + one public address. The
// dispatcher must drop the private one and pin to the public one.
const lookupSpy = vi.spyOn(dns, "lookup");
- lookupSpy.mockImplementation(
- ((
- _hostname: string,
- _opts: dns.LookupAllOptions,
- callback: (
- err: NodeJS.ErrnoException | null,
- addresses: dns.LookupAddress[],
- ) => void,
- ) => {
- callback(null, [
- { address: "10.0.0.5", family: 4 },
- { address: "192.0.2.1", family: 4 },
- ]);
- }) as unknown as typeof dns.lookup,
- );
+ lookupSpy.mockImplementation(((
+ _hostname: string,
+ _opts: dns.LookupAllOptions,
+ callback: (
+ err: NodeJS.ErrnoException | null,
+ addresses: dns.LookupAddress[],
+ ) => void,
+ ) => {
+ callback(null, [
+ { address: "10.0.0.5", family: 4 },
+ { address: "192.0.2.1", family: 4 },
+ ]);
+ }) as unknown as typeof dns.lookup);
const dispatcher = getPinnedPublicDispatcher();
let caught: unknown;
diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts
index 51d4b05e8..57ecdb773 100644
--- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts
+++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts
@@ -365,7 +365,8 @@ describe("Codex slug fallback chain", () => {
} finally {
if (origModel === undefined) delete process.env.CODEX_MODEL;
else process.env.CODEX_MODEL = origModel;
- if (origChain === undefined) delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
+ if (origChain === undefined)
+ delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
else process.env.CODEX_MODEL_FALLBACK_CHAIN = origChain;
}
});
diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts
index 76c7656f1..67a7f3be3 100644
--- a/src/lib/ai/__tests__/consent-guard.test.ts
+++ b/src/lib/ai/__tests__/consent-guard.test.ts
@@ -49,7 +49,10 @@ describe("chainRequiresServerManagedConsent", () => {
true,
);
expect(
- chainRequiresServerManagedConsent([entry("openai"), entry("admin-openai")]),
+ chainRequiresServerManagedConsent([
+ entry("openai"),
+ entry("admin-openai"),
+ ]),
).toBe(true);
});
diff --git a/src/lib/ai/__tests__/local-host-allowlist.test.ts b/src/lib/ai/__tests__/local-host-allowlist.test.ts
index b989306f8..0341767aa 100644
--- a/src/lib/ai/__tests__/local-host-allowlist.test.ts
+++ b/src/lib/ai/__tests__/local-host-allowlist.test.ts
@@ -28,10 +28,7 @@ describe("isLocalAiHostAllowed", () => {
});
it("permits ONLY the listed hosts when given a comma-separated allowlist", () => {
- vi.stubEnv(
- "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
- "ollama.lan, 10.0.0.5",
- );
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan, 10.0.0.5");
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
// A different private host is still rejected — the metadata endpoint and
diff --git a/src/lib/ai/coach/__tests__/clusters.test.ts b/src/lib/ai/coach/__tests__/clusters.test.ts
index 361c52692..0b54c45ec 100644
--- a/src/lib/ai/coach/__tests__/clusters.test.ts
+++ b/src/lib/ai/coach/__tests__/clusters.test.ts
@@ -57,7 +57,13 @@ describe("coach clusters", () => {
it("expands the default clusters to the legacy core sources + additive members", () => {
const sources = clusterSourcesFromPrefs(undefined);
// Legacy five domains' sources are all present.
- for (const core of ["bp", "weight", "pulse", "mood", "compliance"] as const) {
+ for (const core of [
+ "bp",
+ "weight",
+ "pulse",
+ "mood",
+ "compliance",
+ ] as const) {
expect(sources.has(core)).toBe(true);
}
// Additive members ride inside cardio/body.
diff --git a/src/lib/ai/coach/__tests__/conversation-summary.test.ts b/src/lib/ai/coach/__tests__/conversation-summary.test.ts
index 7791a7a1e..5adc81ee6 100644
--- a/src/lib/ai/coach/__tests__/conversation-summary.test.ts
+++ b/src/lib/ai/coach/__tests__/conversation-summary.test.ts
@@ -121,7 +121,8 @@ describe("refreshConversationSummary", () => {
});
it("(c) merge path passes the decrypted prior summary into the user prompt", async () => {
- const prior = "Earlier: user wants better sleep and dislikes evening caffeine.";
+ const prior =
+ "Earlier: user wants better sleep and dislikes evening caffeine.";
// 30 turns, no prior summaryTurnCount → forces a generation.
const { client } = makePrisma(
makeConversation(30, {
@@ -162,7 +163,9 @@ describe("refreshConversationSummary", () => {
it("(e) success path encrypts and persists summaryEncrypted + turn count + updatedAt", async () => {
const now = new Date("2026-06-04T08:00:00.000Z");
- const { client, update } = makePrisma(makeConversation(30, { summaryTurnCount: 0 }));
+ const { client, update } = makePrisma(
+ makeConversation(30, { summaryTurnCount: 0 }),
+ );
const runCompletion = vi.fn().mockResolvedValue(okCompletion);
const result = await refreshConversationSummary("conv-1", "user-1", {
@@ -185,9 +188,7 @@ describe("refreshConversationSummary", () => {
describe("buildSummaryUserPrompt", () => {
it("uses (none) when no prior summary is supplied", () => {
- const out = buildSummaryUserPrompt(null, [
- { role: "user", content: "hi" },
- ]);
+ const out = buildSummaryUserPrompt(null, [{ role: "user", content: "hi" }]);
expect(out).toContain("PRIOR SUMMARY\n(none)");
expect(out).toContain("user: hi");
});
diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts
index 7c50f6f23..7ac766bf2 100644
--- a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts
@@ -73,7 +73,9 @@ function rhrMeasurements() {
out.push({
type: "RESTING_HEART_RATE",
value: v + (i % 3) * 0.5,
- measuredAt: new Date(`2026-03-${String(i).padStart(2, "0")}T12:00:00.000Z`),
+ measuredAt: new Date(
+ `2026-03-${String(i).padStart(2, "0")}T12:00:00.000Z`,
+ ),
source: "APPLE_HEALTH",
deviceType: null,
});
@@ -176,6 +178,8 @@ describe("buildCycleSnapshotBlock", () => {
expect(block!.phaseInsight).not.toBeNull();
expect(block!.phaseInsight!.metric).toBe("resting heart rate");
expect(block!.phaseInsight!.delta).toBeGreaterThan(0);
- expect(block!.phaseInsight!.interpretation).toMatch(/descriptive pattern, not a cause/);
+ expect(block!.phaseInsight!.interpretation).toMatch(
+ /descriptive pattern, not a cause/,
+ );
});
});
diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts
index 7bdacb9aa..7a3704f00 100644
--- a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/insights/derived", async (importOriginal) => {
- const actual = await importOriginal();
+ const actual =
+ await importOriginal();
return { ...actual, computeDerivedMetric: vi.fn() };
});
@@ -14,8 +15,9 @@ import { prisma } from "@/lib/db";
import { buildDerivedSnapshotBlock } from "../derived-snapshot";
const compute = computeDerivedMetric as unknown as ReturnType