style: apply prettier across the tree

One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:20:31 +02:00
1 parent b3fea2ef59
commit 1f4ba5939d
1000 files changed
+12130 -8385

No files matched your search

+40 -39
View File
@@ -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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
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 `<MedicationCardHeader>` 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
`<MedicationDetailSection>` (`text-base font-semibold leading-6
tracking-tight`). DrugLevelChart's standalone header migrates to
tracking-tight`). DrugLevelChart's standalone header migrates to
`<h2>` 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.
+45 -44
View File
@@ -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.
+2 -2
View File
@@ -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/<slug>`), 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
+29 -29
View File
@@ -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. |
+16
View File
@@ -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://<your-instance>/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://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
- [ ] Privacy Policy URL: **https://<your-instance>/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
+7 -7
View File
@@ -6,13 +6,13 @@ pages as `<img>` 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
+6 -6
View File
@@ -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` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `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` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `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
+13 -13
View File
@@ -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 |
| ----------- | ------- | ----- |
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
| XML element | Maps to | Notes |
| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `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 <n> 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 <n> 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
+89 -85
View File
@@ -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`
+1
View File
@@ -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.
+10 -10
View File
@@ -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
+6 -6
View File
@@ -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.
+1
View File
@@ -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`)
+23 -23
View File
@@ -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.
+25 -25
View File
@@ -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/<topic>`.
@@ -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
@@ -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.
+12 -12
View File
@@ -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:
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -235,7 +235,7 @@ lists) uses the unified `<FilterBar>` rail from
labelled `<Select>`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
+3 -1
View File
@@ -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"]'),
+6 -1
View File
@@ -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 =
+8 -10
View File
@@ -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");
});
+1 -4
View File
@@ -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, {
+7 -3
View File
@@ -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));
}
+3 -1
View File
@@ -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(
+17 -21
View File
@@ -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);
+1 -3
View File
@@ -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);
});
}
+4 -4
View File
@@ -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
+2 -6
View File
@@ -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");
});
+2 -2
View File
@@ -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({
@@ -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) {
+4 -15
View File
@@ -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"]
}
}
}
+1 -3
View File
@@ -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).*)",
+9 -3
View File
@@ -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" }
]
}
]
}
@@ -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";
+6 -2
View File
@@ -59,7 +59,9 @@ async function main(): Promise<void> {
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<void> {
}
}
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();
}
+3 -6
View File
@@ -66,12 +66,9 @@ async function main(): Promise<void> {
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} ` +
+2 -7
View File
@@ -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: "<all-or-none>",
@@ -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 {
+8 -2
View File
@@ -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."
}
]
}
]
+3 -1
View File
@@ -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`,
);
+23 -23
View File
@@ -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
+5 -1
View File
@@ -151,7 +151,11 @@ async function updateCoolifyEnv(
appUuid: string,
value: string,
): Promise<void> {
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`);
}
+14 -9
View File
@@ -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<void> {
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<void> {
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}`);
+6 -6
View File
@@ -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(
+189 -34
View File
@@ -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`);
+18 -24
View File
@@ -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<typeof import("@/lib/logging/context")>(
"@/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<typeof vi.fn>).mockResolvedValue(
undefined,
@@ -185,9 +179,9 @@ beforeEach(async () => {
priorMonth: null,
priorYear: null,
});
(computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
(
computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue(null);
(
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue({ hypotheses: [] });
@@ -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: {
+11 -10
View File
@@ -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 =
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
const KEY_PATTERN = /(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
interface CallSite {
key: string;
@@ -98,7 +92,10 @@ function extractKeys(): CallSite[] {
inBlockComment = true;
break;
}
line = line.slice(0, start) + " ".repeat(end - start + 2) + line.slice(end + 2);
line =
line.slice(0, start) +
" ".repeat(end - start + 2) +
line.slice(end + 2);
}
line = stripCommentLine(line);
let m: RegExpExecArray | null;
@@ -119,7 +116,11 @@ function resolveLeaf(obj: unknown, dotted: string): string | null {
const parts = dotted.split(".");
let cur: unknown = obj;
for (const p of parts) {
if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
if (
cur &&
typeof cur === "object" &&
p in (cur as Record<string, unknown>)
) {
cur = (cur as Record<string, unknown>)[p];
} else {
return null;
+1 -5
View File
@@ -30,11 +30,7 @@ function loadBundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
}
function flatten(
obj: unknown,
prefix: string,
out: Map<string, string>,
): void {
function flatten(obj: unknown, prefix: string, out: Map<string, string>): void {
if (obj == null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
+5 -1
View File
@@ -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;
}
}
@@ -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);
});
+4 -5
View File
@@ -324,7 +324,9 @@ describe("sw.js — trimCache", () => {
store.keysCalls = 0;
const trimCache = (
harness.context as { trimCache?: (name: string, max: number) => Promise<void> }
harness.context as {
trimCache?: (name: string, max: number) => Promise<void>;
}
).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<void> {
async function dispatchPush(harness: SwHarness, data: unknown): Promise<void> {
let settled: Promise<unknown> = Promise.resolve();
harness.listeners.get("push")!({
data: { json: () => data, text: () => JSON.stringify(data) },
@@ -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", () => {
@@ -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(
/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/,
);
expect(src).toMatch(/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/);
});
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
+2 -6
View File
@@ -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(/<CoachLaunchProvider>/);
expect(shellSource).toMatch(/<LayoutCoachMount \/>/);
});
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.
+8 -9
View File
@@ -50,7 +50,7 @@ function Section({
children: React.ReactNode;
}) {
return (
<section id={id} className="space-y-3 scroll-mt-28">
<section id={id} className="scroll-mt-28 space-y-3">
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h2>
@@ -64,7 +64,7 @@ function Section({
export default function AboutPage() {
return (
<div className="bg-background text-foreground min-h-dvh">
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur pt-[env(safe-area-inset-top)]">
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3 md:px-6">
<Link
href="/"
@@ -122,8 +122,8 @@ export default function AboutPage() {
<Section id="credits" title="Credits">
<p>
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.
</p>
@@ -150,23 +150,22 @@ export default function AboutPage() {
GeoLite2-ASN
</code>
) 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{" "}
<a
href="https://creativecommons.org/licenses/by-sa/4.0/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Creative Commons Attribution-ShareAlike 4.0 International
License
Creative Commons Attribution-ShareAlike 4.0 International License
</a>
.
</p>
</Section>
<footer
className="border-border/60 mt-12 border-t pt-6 text-xs text-muted-foreground"
className="border-border/60 text-muted-foreground mt-12 border-t pt-6 text-xs"
data-slot="about-footer"
>
<p>
+2 -7
View File
@@ -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")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
</div>
<div className="flex shrink-0 items-center gap-2"></div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -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<string> } {
function extractModuleKeys(text: string): {
hasLiteral: boolean;
keys: Set<string>;
} {
const keys = new Set<string>();
let hasLiteral = false;
const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g;
@@ -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<string, unknown> }) => ({
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<string, unknown>;
}) => ({
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 () => {
@@ -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);
});
@@ -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
+2 -7
View File
@@ -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: {
@@ -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);
});
@@ -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),
@@ -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;
@@ -36,8 +36,9 @@ vi.mock("@/lib/api-handler", async () => {
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
h,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
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);
});
@@ -63,8 +63,14 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
}
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 () => {
@@ -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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<string, boolean> };
@@ -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) {
@@ -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 } });
});
});
@@ -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();
});
+19 -25
View File
@@ -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<Response>)(
req,
);
const res = await (
GET as unknown as (req: Request) => Promise<Response>
)(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 () => {
+6 -3
View File
@@ -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<Awaited<ReturnType<typeof computeSummariesSlice>>>,
caches.analytics as ServerCache<
Awaited<ReturnType<typeof computeSummariesSlice>>
>,
`${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<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildAnalyticsResponse>>
>,
`${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
@@ -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),
),
}));
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
},
toJson: <T,>(v: T) => v,
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -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<Response>)(mkPatch({ sleep: false }));
await (PATCH as (r: Request) => Promise<Response>)(
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<Response>)(mkPatch({ glucose: true }));
await (PATCH as (r: Request) => Promise<Response>)(
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<Response>)(mkPatch({ labs: false }));
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ labs: false }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
@@ -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({
+2 -1
View File
@@ -171,7 +171,8 @@ export const POST = apiHandler(async (req: Request) => {
previous: previous ?? null,
next: {
...next,
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
researchModeAcknowledgedAt:
next.researchModeAcknowledgedAt.toISOString(),
},
},
});
@@ -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",
}),
}),
);
});
@@ -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";
@@ -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",
@@ -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<unknown> };
@@ -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", () => ({
+4 -1
View File
@@ -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);
@@ -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<unknown> };
+13 -12
View File
@@ -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 });
@@ -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,
@@ -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,
@@ -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),
}));
@@ -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 {
+5 -6
View File
@@ -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<ConsentKind, ReturnType<typeof serialiseReceipt> | null> = {
const result: Record<
ConsentKind,
ReturnType<typeof serialiseReceipt> | null
> = {
ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null,
ai_insights_only: byKind.ai_insights_only
? serialiseReceipt(byKind.ai_insights_only)
@@ -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" },
});
});
});
+3 -1
View File
@@ -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);
@@ -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());
+24 -13
View File
@@ -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<Awaited<
ReturnType<typeof buildDashboardSummary>
>>,
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildDashboardSummary>>
>,
`${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<string, number> = {};
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
const time = async <T>(
label: string,
builder: () => Promise<T>,
): Promise<T> => {
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<LatestEverRow[]>`
time(
"latestEver",
() =>
prisma.$queryRaw<LatestEverRow[]>`
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<SparklineRow[]>`
time(
"sparkline",
() =>
prisma.$queryRaw<SparklineRow[]>`
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<ActivityDayRow[]>`
time(
"streakDays",
() =>
prisma.$queryRaw<ActivityDayRow[]>`
SELECT DISTINCT
to_char(
(m."measured_at" AT TIME ZONE ${userTz}),
@@ -37,8 +37,7 @@ vi.mock("@/lib/db-compat", () => ({
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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 }> };
+4 -1
View File
@@ -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 },
@@ -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
+4 -9
View File
@@ -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(
{
+4 -3
View File
@@ -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(
Loaded 100 of 1000 files, more files were not shown because too many files have changed in this diff. Show more