diff --git a/CHANGELOG.md b/CHANGELOG.md index 979f94a36..4b7edb69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2333,6 +2333,7 @@ iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neithe ### Tests Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11: + - BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline` - BP with null readings ever → tile NOT emitted - Weight within 7 days → behaviour unchanged (regression guard) @@ -2698,7 +2699,7 @@ self-heal via boot-time backfill on first reach. card; not exposed via any route yet. - **`computeLongWindowSummary(userId, type, windowDays)`** in `summaries-slice.ts` — granularity-routed `count / min / max / - mean / sum` aggregate for long-window consumers. +mean / sum` aggregate for long-window consumers. - **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` / `readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with legacy-NULL fallback. @@ -2728,7 +2729,7 @@ rides the post-deploy window. - **`/api/measurements?groupBy=day` cumulative path:** consumes `sum_value` directly; eliminates per-type JS aggregation on `ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE / - TIME_IN_DAYLIGHT / ACTIVE_ENERGY`. +TIME_IN_DAYLIGHT / ACTIVE_ENERGY`. - **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing 425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes @@ -2785,10 +2786,10 @@ false and stranded the whole helper on live SQL across the full ### Fixed - **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS + - PULSE + WEIGHT`** — the three types the helper actually reads. +PULSE + WEIGHT`** — the three types the helper actually reads. `isFullyCovered(coverage)` AND-term dropped. - **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA`**. Same fix. +BLOOD_PRESSURE_DIA`**. Same fix. - **`health-score-fast-path` gates only on `WEIGHT`**. Same fix. Coverage-probe semantics unchanged. Each helper now consults the @@ -3095,7 +3096,7 @@ without changing existing behaviour. `aps.category = "MEDICATION_REMINDER"` on every medication-reminder payload so iOS renders the three action-buttons (Genommen / Snooze 15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content - = 1` is set by default so a future Notification Service Extension += 1` is set by default so a future Notification Service Extension can hook the payload without a server-side change. The med-reminder metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min" action pins to the schedule slot rather than wall-clock delivery @@ -3294,7 +3295,7 @@ dashboard SQL surfaced. list through a Zod `refine` chained on the existing `isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at the write boundary so the new `to_char(measured_at AT TIME ZONE - $tz)` SQL in the dashboard summary route cannot be self-DoS'd by +$tz)` SQL in the dashboard summary route cannot be self-DoS'd by a corrupted stored value. ### Tests @@ -3622,7 +3623,7 @@ surfaces city + country when the carrier lookup misses. buckets + a freshly logged WEIGHT type) falls back to the live aggregate so no type silently underreports. - **Daily chart fetches opt into rollup buckets.** `GET - /api/measurements?aggregate=daily&source=rollup` reads DAY buckets +/api/measurements?aggregate=daily&source=rollup` reads DAY buckets from `measurement_rollups` instead of scanning the measurements table when the window exceeds seven days. The Insights chart fetchers pass `source=rollup`; iOS and other clients are @@ -3689,7 +3690,7 @@ surfaces city + country when the carrier lookup misses. `pickCumulativeDaySum` helper, source-priority-aware in the same shape SLEEP_DURATION already used. - **Insights nav strict-gate** flipped (`if (!availability) return - true` → `return false`). Types without measurements no longer +true` → `return false`). Types without measurements no longer appear in the strip. - **About → Admin Console.** "Über HealthLog" moved out of the personal dropdown into a new `/admin/about` slug. The public @@ -3715,7 +3716,7 @@ surfaces city + country when the carrier lookup misses. ### Schema - **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL - DEFAULT '{}'`). Migration +DEFAULT '{}'`). Migration `0068_v1436_insights_exclude_metrics` is idempotent under re-run. ### Refactor @@ -3725,7 +3726,7 @@ surfaces city + country when the carrier lookup misses. `applyInsightsExcludeFilter`, `pickCumulativeDaySum`, `probeRollupCoverage`, `isFullyCovered`. Tested standalone. - `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed: - true, rollup_refresh_error: ... }` on failure instead of swallowing +true, rollup_refresh_error: ... }` on failure instead of swallowing silently. The read path still returns `{ recomputed: false }` so the response never fails because of a populator hiccup. - Cumulative metric-key lookup hoisted out of a four-level nested @@ -3787,7 +3788,7 @@ zero reader-path impact (serial concurrency, runs in the background). in place. The discovery query finds users with at least one row in `measurements` and zero rows in `measurement_rollups`, then sends one job per uncovered account with `singletonKey: - boot-backfill|{userId}` so a fast restart while a fold is queued +boot-backfill|{userId}` so a fast restart while a fold is queued doesn't pile up duplicates. - **Best-effort:** any error during discovery is captured in the return value and logged through `workerLog`; the worker boot never @@ -3802,7 +3803,7 @@ zero reader-path impact (serial concurrency, runs in the background). user lands on the queue when a third already-folded user co-exists; zero jobs enqueued when every account is already covered. - Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm - typecheck` + `pnpm lint` clean. +typecheck` + `pnpm lint` clean. ## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap @@ -3820,7 +3821,7 @@ across every analytics surface is planned for a follow-up. - **New table `measurement_rollups`** keyed on `(user_id, type, granularity, bucket_start)` with `count / mean / min_value / max_value / sd / slope / r2 / - computed_at`. Backed by a composite-descending index on +computed_at`. Backed by a composite-descending index on `(user_id, type, granularity, bucket_start DESC)`. Additive only — no `ALTER` on existing tables. Migration `0067_v1434_measurement_rollups` is idempotent under re-run. @@ -3896,8 +3897,8 @@ viewport-zoom fix surfaced during the mobile-deep pass. - `tests/integration/withings-oauth.test.ts` covers connect / callback / disconnect plus state-token replay rejection. - `tests/integration/passkey-register.test.ts` covers register-options - + register-verify + tampered-attestation rejection + challenge - single-use. + - register-verify + tampered-attestation rejection + challenge + single-use. - `tests/integration/cache-invalidation-coverage.test.ts` grids the v1.4.34 IW-G invalidation matrix across 5 write surfaces. - Integration suite goes from 197/50 to 222/55. tsc + lint clean. @@ -3969,7 +3970,7 @@ freeze. - **Focus rings on mobile + tablet navigation.** The bottom-nav primary links, the More overflow trigger, and the mobile top-bar user menu trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2 - focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard +focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard and switch-control users can now see focus on every nav surface. - **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px @@ -4203,7 +4204,7 @@ without leaking userIds. gate; Bearer tokens never elevate). - **`ImportJob` schema model + migration.** New Prisma model captures the per-upload state machine (`queued | unpacking | parsing | - upserting | done | failed`), the content-hash for idempotency +upserting | done | failed`), the content-hash for idempotency short-circuits, and the per-`MeasurementType` ingestion counters the status route surfaces back to the client. - **`lastSeenByType` on `/api/analytics`.** Both the slim @@ -4454,8 +4455,7 @@ the affected surfaces. `very_late`.** Defensive fallback when the dose-window estimator can't resolve a window. - **`/api/insights/generate` POST gating.** Endpoint now refuses - on a disabled assistant master flag instead of returning a - 500. + on a disabled assistant master flag instead of returning a 500. - **Mood Log overflow on narrow viewports.** Cards no longer shred their internal grid below 360 CSS px. - **F13 username readability.** Header username now respects @@ -4802,7 +4802,7 @@ permission picker and the future Insights nav. ### Added - **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId, - date)` mints `"stats::"` +date)` mints `"stats::"` alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is locked in the iOS coordination notes (§12). iOS emits one row per day per cumulative type via @@ -4885,11 +4885,12 @@ encounter them in read paths because no source writes them yet, and the codegen path will pick them up on the next iOS regeneration. **Cutover sequence.** v1.4.30 ships with the helper + drain script -+ server tolerance for both shapes. The next iOS TestFlight build -adopts `HealthKitStatisticsService.swift` and starts posting daily- -aggregated rows for the five cumulative types. Operator runs the -drain script once after the new TestFlight cuts over; per-sample -row pressure on `Measurement` drops 50-200× for cumulative types. + +- server tolerance for both shapes. The next iOS TestFlight build + adopts `HealthKitStatisticsService.swift` and starts posting daily- + aggregated rows for the five cumulative types. Operator runs the + drain script once after the new TestFlight cuts over; per-sample + row pressure on `Measurement` drops 50-200× for cumulative types. ## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix @@ -5146,7 +5147,7 @@ internal-only `/api/internal/web-vitals` beacon route). GLP-1 row painted with a brand icon and a middle-dot separator; every other row painted two-line without an icon. Both routes through a shared `` now — line 1 is `{name} - {dose}`, line 2 is the class label plus state badges. The GLP-1 +{dose}`, line 2 is the class label plus state badges. The GLP-1 outlier shape is gone. - **HealthScore card came up short below the action+prompts column.** The card now opts into `flex h-full flex-col` with the disclaimer @@ -5184,7 +5185,7 @@ internal-only `/api/internal/web-vitals` beacon route). - **Medications detail-page chrome collapsed to one heading scale.** Every section on `/medications/[id]` mounts through `` (`text-base font-semibold leading-6 - tracking-tight`). DrugLevelChart's standalone header migrates to +tracking-tight`). DrugLevelChart's standalone header migrates to `

` with the same classes. Micro labels lift from `text-[10px]` / `text-[11px]` to `text-xs` across Scheduling, Titration, SideEffects. Three scales survive the page: heading, @@ -5709,7 +5710,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only. `router.replace`s to a clean URL. - **`/api/version` register button below the tap floor.** The `/auth/register` submit button promotes to `size="lg" min-h-11 - w-full` so the primary action stays finger-tap reachable on +w-full` so the primary action stays finger-tap reachable on narrow viewports. - **Workouts pagination broken under canonical dedup.** Pulling the full filtered set and slicing post-dedup yields the correct @@ -5805,8 +5806,8 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only. contract and the SSR `useIsMobile` first-paint. - **16 new `metric-availability.test.ts` cases** cover each metric × `{has data, no data, undefined summaries, missing summary entry, - BMI-from-WEIGHT derivation, sys-vs-dia independence, - mood/medication overrides}`. +BMI-from-WEIGHT derivation, sys-vs-dia independence, +mood/medication overrides}`. - **6 new `insights-tab-strip.test.tsx` cases** assert backward-compat without `availability`, pill-drop when data is missing, overview pill always renders, mood + medication @@ -5974,7 +5975,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). browser-detect maps `fr|es|it|pl` to the corresponding bundle. - **Coach — native first-party system prompts across six locales.** Coach + insights system prompts move from EN body + `REPLY - LANGUAGE` footer to native per-locale bodies; the safety contract +LANGUAGE` footer to native per-locale bodies; the safety contract matrix lives as YAML per locale (single source of truth across drafting + tests). The refusal-probe matrix in CI catches cross-locale prompt regressions before they reach the dispatch @@ -5985,7 +5986,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). - **Personal Records end-to-end — schema, detection worker, badge, push opt-in.** Migration 0054 introduces the `PersonalRecord` table + `PersonalRecordDirection` helper; `GET - /api/personal-records` is paginated (default twenty-five, max two +/api/personal-records` is paginated (default twenty-five, max two hundred). A pg-boss worker sweeps MAX / MIN per metric and the workout slots on every batch-ingest + a thirty-minute fallback cron (concurrency five, warmup gate so the very first datapoint @@ -6056,7 +6057,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). GLP-1 detail page renders the user's current step + remaining ladder as observational reference, framed as reference-not-advice and bound by GROUND RULE 15. `GET - /api/medications/[id]/titration-ladder` returns the structured +/api/medications/[id]/titration-ladder` returns the structured ladder + current step. - **GLP-1 — pen-and-vial inventory with a 30-day in-use clock.** Migration 0056 introduces the `MedicationInventoryItem` table + @@ -6110,7 +6111,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). and the per-row collapse affordance comes off. - **DELETE `/api/measurements/by-external-ids` for iOS deletion sync.** Idempotent batch delete keyed on `(user, source, - externalId)` tuples; rate-limited the same way as the batch +externalId)` tuples; rate-limited the same way as the batch ingest endpoint. - **Multi-arch Docker image.** GHCR publish workflow builds `linux/amd64` on `ubuntu-latest` plus `linux/arm64` on @@ -6118,7 +6119,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). Macs and arm64 clouds now pull native; the previously-stale README claim is accurate again. - **GLP-1 endpoint hardening.** `POST - /api/medications/[id]/glp1` now parses through bounded Zod +/api/medications/[id]/glp1` now parses through bounded Zod schemas (`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`) with length-capped notes, finite-number guards on dose value, and bounded `effectiveFrom`. Every write produces an audit-log @@ -6203,7 +6204,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). `src/lib/medications/route-guards.ts` rather than open-coding the lookup; rate-limit headers across the same surface align on the option-bag form `apiError(..., { headers: - rateLimitHeaders(rl) })`. +rateLimitHeaders(rl) })`. ### Fixed @@ -6283,7 +6284,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). single-entry POST stripped it on the Zod boundary. Mirror parity restored. - **Personal-records endpoint unpaginated.** `GET - /api/personal-records` now clamps `?limit` (default twenty-five, +/api/personal-records` now clamps `?limit` (default twenty-five, maximum two hundred); the unbounded read is gone. - **Withings activity sync bucketed positive-offset users into the wrong day.** The per-day row was anchored at 23:59:59 UTC, so a @@ -6402,7 +6403,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). 2024 pharmacokinetic reference. The acknowledgment version re-prompts the user on bump. - **GLP-1 convenience endpoint hardened.** `POST - /api/medications/[id]/glp1` parses through bounded Zod schemas +/api/medications/[id]/glp1` parses through bounded Zod schemas (`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`), enforces length caps on notes, finite-number guards on dose value, and bounded `effectiveFrom`; every write produces an audit-log row @@ -6608,7 +6609,7 @@ estimates and source citations, is tracked internally. Headline items: - **iOS Swift app — P1 through P5.** Login + dashboard + widget (P1); Apple Health sync (P2); Coach extended for HRV / sleep / resting HR / steps (P3); per-metric APNs alerts (P4); workouts - + GeoJSON routes (P5). All server contracts locked in v1.4.25. + - GeoJSON routes (P5). All server contracts locked in v1.4.25. - **Workout ingest API matching the v1.5 iOS contract.** Schema shipped this release; endpoint signature finalised during the iOS sprint. diff --git a/CLAUDE.md b/CLAUDE.md index eeb174169..cc180a5fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,19 +32,19 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with ## Tech stack at a glance -| Layer | Tech | Notes | -|---|---|---| -| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. | -| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. | -| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. | -| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. | -| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). | -| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. | -| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. | -| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. | -| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. | -| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. | -| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. | +| Layer | Tech | Notes | +| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. | +| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. | +| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. | +| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. | +| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). | +| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. | +| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. | +| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. | +| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. | +| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. | +| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. | More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit. @@ -110,23 +110,23 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file: ## Critical-files map -| Concern | File | What it owns | -|---|---|---| -| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. | -| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. | -| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. | -| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. | -| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). | -| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. | -| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. | -| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. | -| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). | -| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. | -| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. | -| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. | -| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. | -| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). | -| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. | +| Concern | File | What it owns | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. | +| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. | +| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. | +| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. | +| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). | +| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. | +| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. | +| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. | +| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). | +| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. | +| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. | +| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. | +| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. | +| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). | +| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. | `.planning/codebase/arch.md` carries the full annotated walk for every section. @@ -181,21 +181,22 @@ The native SwiftUI client lives in a separate repository and rides public beta v ## Pointers -| What | Where | -|---|---| -| Public face | `README.md` | -| Release history | `CHANGELOG.md` | -| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` | -| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` | -| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) | -| Self-hosting guide | `docs/self-hosting/` | -| Public API contract | `docs/api/openapi.yaml` | -| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) | -| iOS coordination notes | `.planning/ios-coord/` | -| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) | -| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) | +| What | Where | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public face | `README.md` | +| Release history | `CHANGELOG.md` | +| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` | +| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` | +| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) | +| Self-hosting guide | `docs/self-hosting/` | +| Public API contract | `docs/api/openapi.yaml` | +| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) | +| iOS coordination notes | `.planning/ios-coord/` | +| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) | +| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) | ## Agent context hygiene (ECONNRESET / oversized-request prevention) -- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET` on large turns. Regenerate via `pnpm prisma generate` if needed; never open it. (Enforced via `.claude/settings.local.json` Read-deny.) + +- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.) - Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads. - For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions. diff --git a/docs/adr/0001-insights-naming-convention.md b/docs/adr/0001-insights-naming-convention.md index 2dd5ba69a..6d52afa06 100644 --- a/docs/adr/0001-insights-naming-convention.md +++ b/docs/adr/0001-insights-naming-convention.md @@ -32,7 +32,7 @@ Separate the two naming axes cleanly: - **UI copy is always localised.** Every visible string resolves through an i18n `t()` key and is translated across all six locales (`de / en / es / fr / - it / pl`). The user never sees a raw identifier. +it / pl`). The user never sees a raw identifier. - **Internal identifiers are English.** Route slugs (`/insights/`), the tile-layout enum (`INSIGHTS_TILE_IDS`), query keys, and the internal slug→metric / slug→target maps all use established English terms. English is @@ -54,7 +54,7 @@ The migration must not break either client-visible boundary: navigation keep resolving. - **Tile-layout contract.** The layout endpoint keeps **accepting** the legacy German tile ids on input. The Zod enum is built from - `ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids *plus* the legacy German + `ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids _plus_ the legacy German aliases), so a `PUT` carrying old ids validates rather than tripping a 422. `normalizeInsightsTileId` collapses each legacy id onto its canonical English replacement; this normalisation runs on **both** the read path diff --git a/docs/api/cycle-healthkit-mapping.md b/docs/api/cycle-healthkit-mapping.md index 69bce5e91..bf827824f 100644 --- a/docs/api/cycle-healthkit-mapping.md +++ b/docs/api/cycle-healthkit-mapping.md @@ -13,16 +13,16 @@ row. The fold only runs when cycle tracking is enabled for the account. ## Day-log fields -| HealthKit category type | CycleDayLog field | Values | -|---|---|---| -| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE | -| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` | -| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE | -| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE | -| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` | -| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE | -| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE | -| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY | +| HealthKit category type | CycleDayLog field | Values | +| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE | +| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` | +| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE | +| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE | +| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` | +| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE | +| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE | +| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY | HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a @@ -31,21 +31,21 @@ logged-but-unspecified bleeding day is at least light flow). ## Symptom category types → seeded symptom keys Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A -sample whose severity is *not present* (codepoint 1) does NOT create a link. +sample whose severity is _not present_ (codepoint 1) does NOT create a link. -| HealthKit category type | CycleSymptom key | -|---|---| -| `AbdominalCramps` | `cramps` | -| `Headache` | `headache` | -| `Bloating` | `bloating` | -| `Acne` | `acne` | -| `BreastPain` | `breast_tenderness` | -| `Fatigue` | `fatigue` | -| `LowerBackPain` | `back_pain` | -| `SleepChanges` | `insomnia` | -| `MoodChanges` | `mood_swings` | -| `AppetiteChanges` | `food_cravings` | -| `Nausea` | `nausea` | +| HealthKit category type | CycleSymptom key | +| ----------------------- | ------------------- | +| `AbdominalCramps` | `cramps` | +| `Headache` | `headache` | +| `Bloating` | `bloating` | +| `Acne` | `acne` | +| `BreastPain` | `breast_tenderness` | +| `Fatigue` | `fatigue` | +| `LowerBackPain` | `back_pain` | +| `SleepChanges` | `insomnia` | +| `MoodChanges` | `mood_swings` | +| `AppetiteChanges` | `food_cravings` | +| `Nausea` | `nausea` | ## Profile-level signals @@ -64,8 +64,8 @@ the temperature-trend ovulation method directly from `Measurement`. ## Deferred -| HealthKit category type | Status | -|---|---| -| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). | -| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. | -| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. | +| HealthKit category type | Status | +| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). | +| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. | +| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. | diff --git a/docs/apple-store-connect-checklist.md b/docs/apple-store-connect-checklist.md index 88806d1d1..c16fe0e45 100644 --- a/docs/apple-store-connect-checklist.md +++ b/docs/apple-store-connect-checklist.md @@ -11,6 +11,7 @@ last_updated: 2026-05-15 ## TL;DR Three submission gates that need the maintainer's hand at ASC: + 1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix)) 2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2) 3. Standard metadata (name, description, screenshots, categories, age rating) @@ -24,9 +25,11 @@ Plus pre-submit final checks. **Decision**: HealthLog is **NOT a medical device** under EU MDR 2017/745 / FDA 21 CFR 880 / Apple's interpretation. **Recommended wording in the declaration form**: + > HealthLog is a personal health-data logging and visualization app. It displays user-entered observations + integrates with Apple HealthKit, Withings, and manual entry. It does not diagnose, treat, prescribe, or recommend dosing. The AI Coach surfaces are explicit-refusal-gated (GROUND RULES 9 + 15) against any medical-device-class output. The drug-level estimation chart is display-only behind a versioned MDR-acknowledgment dialog citing EU 2017/745 + MDCG 2021-24. For medical advice, users must consult licensed clinicians. **What to check**: + - [ ] Check "No" on the medical-device toggle - [ ] Add the description above to the medical-device-justification field if Apple shows one - [ ] Cross-reference the in-app `/privacy` page (https:///privacy) §7 MDR-boundary @@ -44,22 +47,26 @@ Plus pre-submit final checks. For each category below: **Linked to user, used for App Functionality + Account Management**. Optional purposes: never enable Analytics, Tracking, Advertising, or Third-Party Tracking. **Health & Fitness** + - Health: yes (all HKQuantityType + HKCategoryType the iOS app reads — see `/privacy` page §2) - Fitness: yes (steps, distance, active energy, workouts) - Purpose: App Functionality - Linked to user: yes **Contact Info** + - Email Address: yes (for account login + GDPR data-subject correspondence) - Purpose: App Functionality + Account Management - Linked to user: yes **Sensitive Info** + - Blood pressure, glucose, medications, drug-related observations: yes - Purpose: App Functionality - Linked to user: yes **User Content** + - Emails or Text Messages: NO (the AI Coach is in-app conversation, but iOS does not send emails) - Photos or Videos: NO - Audio Data: NO (HealthKit audio-exposure is environmental dB; not the user's voice) @@ -69,12 +76,14 @@ For each category below: **Linked to user, used for App Functionality + Account - Linked to user: yes **Identifiers** + - User ID: yes (server-side user UUID) - Device ID: yes (X-Device-Id Keychain-stored UUID, APNs push token) - Purpose: App Functionality + Account Management - Linked to user: yes **Other Data Types** + - Other Data Types: yes (general medical observations not fitting above) - Purpose: App Functionality - Linked to user: yes @@ -97,6 +106,7 @@ For each category below: **Linked to user, used for App Functionality + Account ## Section 3 — Standard Metadata Checklist ### App Information + - [ ] Name: "HealthLog" - [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach") - [ ] Primary category: Health & Fitness @@ -105,25 +115,30 @@ For each category below: **Linked to user, used for App Functionality + Account - [ ] Age rating: 17+ (because Sensitive Health Info content) ### Description + - [ ] Long-form description (4000 char max): cover Insights + Coach + GLP-1 + Withings + Apple HealthKit + open-source nature + EU GDPR compliance - [ ] What's New (version-by-version notes): v0.3.0 = "Server-sync, comprehensive Apple HealthKit support across 19 sample types, smart Insights, AI Coach, Health Score, GLP-1 tracking with Research-Mode-gated drug-level chart, interactive charts with drill-down, push notifications, deep links, and account deletion." ### Screenshots + - [ ] 6.5" iPhone screenshots: cover Onboarding (welcome carousel + permissions), Dashboard (Health Score tile + tile-strip), Insights (sub-page with severity sort), Coach (refusal example to show MDR boundary), Medications (GLP-1 detail stack), Settings (Research Mode toggle + account deletion). - [ ] 5.5" iPhone screenshots: same set (Apple's legacy size). - [ ] iPad screenshots if supporting iPad. ### URLs + - [ ] Marketing URL: https:// (or the marketing site once the landing repo's v1.4.25 is deployed) - [ ] Privacy Policy URL: **https:///privacy** (ships with v1.4.25.1 hotfix) - [ ] Support URL: https://github.com/MBombeck/HealthLog/issues ### App Review Information + - [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields - [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim. - [ ] Sign-in required: yes (provide demo credentials) ### Pricing + Availability + - [ ] Free - [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch) @@ -154,6 +169,7 @@ Run through this list: --- **Source links**: + - Apple Health App Guidelines: https://developer.apple.com/app-store/review/guidelines/#health-and-health-research - Apple App Privacy Details: https://developer.apple.com/app-store/app-privacy-details/ - EU MDR 2017/745: https://eur-lex.europa.eu/eli/reg/2017/745/oj diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index 5311e5ed7..101cabfed 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -6,13 +6,13 @@ pages as `` tags. Mirrored into `healthlog-docs/src/assets/diagrams/` and `healthlog-landing/public/diagrams/` so each surface ships its own copy. -| File | Story it tells | -| ---- | -------------- | -| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). | -| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. | -| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. | -| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. | -| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. | +| File | Story it tells | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). | +| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. | +| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. | +| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. | +| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. | ## Editing diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index 2aca1fda8..1b1600b7e 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -9,12 +9,12 @@ Ollama endpoint can fall back from one to the other automatically. ## Provider matrix -| Provider | Key acquisition | Default model | Endpoint | Privacy stance | -| -------- | ---------------- | ------------- | -------- | -------------- | -| `OPENAI` | | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI | -| `ANTHROPIC` | | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic | -| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network | -| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription | +| Provider | Key acquisition | Default model | Endpoint | Privacy stance | +| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- | +| `OPENAI` | | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI | +| `ANTHROPIC` | | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic | +| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network | +| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription | The four spellings above are the canonical `User.aiProvider` enum values. Anything else falls through to the admin-shared OpenAI key diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index 285982d81..3f9d3eefe 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -97,12 +97,12 @@ they triggered. The parser folds four XML element types into the existing HealthLog schema (CHANGELOG v1.4.34): -| XML element | Maps to | Notes | -| ----------- | ------- | ----- | -| `` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:` fallback so re-imports stay idempotent. | -| `` | `Workout` rows | Activity type + duration + energy + distance. | -| `` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. | -| `` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. | +| XML element | Maps to | Notes | +| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:` fallback so re-imports stay idempotent. | +| `` | `Workout` rows | Activity type + duration + energy + distance. | +| `` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. | +| `` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. | The HK quantity types that map to HealthLog metric types live in `src/lib/measurements/apple-health-mapping.ts`. Anything outside the @@ -187,13 +187,13 @@ Override per-user via the Sources section of `/settings/thresholds`. ## Failure modes -| Failure | What you see | Recovery | -| ------- | ------------ | -------- | -| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. | -| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. | -| Unsupported compression method | `failureReason: "Unsupported ZIP compression method for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). | -| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. | -| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. | +| Failure | What you see | Recovery | +| --------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. | +| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. | +| Unsupported compression method | `failureReason: "Unsupported ZIP compression method for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). | +| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. | +| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. | The wide-event log line `import.apple-health.kickoff` (or `import.apple-health.kickoff.denied` on failure) captures every diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index 514a6907b..a128097a8 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -36,8 +36,12 @@ arrays: ```json { - "measurements": [ /* … */ ], - "moodEntries": [ /* … */ ] + "measurements": [ + /* … */ + ], + "moodEntries": [ + /* … */ + ] } ``` @@ -48,26 +52,26 @@ skipped and counted under `skipped` in the response. ### Measurement entry -| Field | Required | Type | Notes | -|---|---|---|---| -| `type` | yes | enum | One of the `MeasurementType` values in the table below. | -| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. | -| `unit` | yes | string | The canonical unit for the type (see the table). | -| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. | -| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. | -| `notes` | no | string | Optional free text. | -| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). | +| Field | Required | Type | Notes | +| ---------------- | -------- | ------ | --------------------------------------------------------------------------------- | +| `type` | yes | enum | One of the `MeasurementType` values in the table below. | +| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. | +| `unit` | yes | string | The canonical unit for the type (see the table). | +| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. | +| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. | +| `notes` | no | string | Optional free text. | +| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). | ### Mood entry -| Field | Required | Type | Notes | -|---|---|---|---| -| `date` | yes | string | `YYYY-MM-DD`. | -| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. | -| `score` | yes | integer | `1`–`5`. | -| `tags` | no | string | Comma-separated tags. | -| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. | -| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. | +| Field | Required | Type | Notes | +| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `date` | yes | string | `YYYY-MM-DD`. | +| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. | +| `score` | yes | integer | `1`–`5`. | +| `tags` | no | string | Comma-separated tags. | +| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. | +| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. | ### `MeasurementType` values and canonical units @@ -76,72 +80,72 @@ display per the user's preference. Event-class types record a single fired event (`value` is `1`) and are normally produced by a device, not hand-authored. -| Type | Canonical unit | -|---|---| -| `WEIGHT` | kg | -| `BLOOD_PRESSURE_SYS` | mmHg | -| `BLOOD_PRESSURE_DIA` | mmHg | -| `PULSE` | bpm | -| `RESTING_HEART_RATE` | bpm | -| `WALKING_HEART_RATE_AVERAGE` | bpm | -| `AVERAGE_HEART_RATE` | bpm | -| `MAX_HEART_RATE` | bpm | -| `CARDIO_RECOVERY` | bpm | -| `HEART_RATE_VARIABILITY` | ms (SDNN) | -| `HRV_RMSSD` | ms (RMSSD) | -| `RESPIRATORY_RATE` | count/min | -| `OXYGEN_SATURATION` | percent (0–100) | -| `BLOOD_GLUCOSE` | mg/dL | -| `BODY_FAT` | percent (0–100) | -| `FAT_MASS` | kg | -| `FAT_FREE_MASS` | kg | -| `LEAN_BODY_MASS` | kg | -| `MUSCLE_MASS` | kg | -| `BONE_MASS` | kg | -| `TOTAL_BODY_WATER` | kg | -| `VISCERAL_FAT` | rating | -| `BODY_MASS_INDEX` | kg/m² | -| `BODY_TEMPERATURE` | °C (core) | -| `SKIN_TEMPERATURE` | °C (dermal spot) | -| `WRIST_TEMPERATURE` | °C (overnight wrist) | -| `SLEEP_DURATION` | minutes | -| `SLEEP_NEED` | minutes | -| `SLEEP_PERFORMANCE` | percent (0–100) | -| `SLEEP_EFFICIENCY` | percent (0–100) | -| `SLEEP_CONSISTENCY` | percent (0–100) | -| `SLEEP_DISTURBANCE_COUNT` | count | -| `BREATHING_DISTURBANCES` | count | -| `ACTIVITY_STEPS` | count | -| `FLIGHTS_CLIMBED` | count | -| `WALKING_RUNNING_DISTANCE` | metres | -| `SIX_MINUTE_WALK_DISTANCE` | metres | -| `WALKING_STEP_LENGTH` | metres | -| `WALKING_SPEED` | m/s | -| `STAIR_ASCENT_SPEED` | m/s | -| `STAIR_DESCENT_SPEED` | m/s | -| `WALKING_STEADINESS` | percent (0–100) | -| `WALKING_ASYMMETRY` | percent (0–100) | -| `WALKING_DOUBLE_SUPPORT` | percent (0–100) | -| `ACTIVE_ENERGY_BURNED` | kcal | -| `ENERGY_EXPENDITURE_KJ` | kJ | -| `VO2_MAX` | mL/(kg·min) | -| `PULSE_WAVE_VELOCITY` | m/s | -| `VASCULAR_AGE` | years | -| `FALL_COUNT` | count | -| `AUDIO_EXPOSURE_ENV` | dBA | -| `AUDIO_EXPOSURE_HEADPHONE` | dBA | -| `TIME_IN_DAYLIGHT` | minutes | -| `RECOVERY_SCORE` | score (0–100) | -| `STRESS_SCORE` | score (0–100) | -| `STRAIN_SCORE` | score (0–100) | -| `DAY_STRAIN` | score (0–21) | -| `WORKOUT_STRAIN` | score (0–21) | -| `AUDIO_EXPOSURE_EVENT` | count (event) | -| `IRREGULAR_RHYTHM_NOTIFICATION` | event | -| `HIGH_HEART_RATE_EVENT` | event | -| `LOW_HEART_RATE_EVENT` | event | -| `WALKING_STEADINESS_EVENT` | event | -| `BREATHING_DISTURBANCE_EVENT` | event | +| Type | Canonical unit | +| ------------------------------- | -------------------- | +| `WEIGHT` | kg | +| `BLOOD_PRESSURE_SYS` | mmHg | +| `BLOOD_PRESSURE_DIA` | mmHg | +| `PULSE` | bpm | +| `RESTING_HEART_RATE` | bpm | +| `WALKING_HEART_RATE_AVERAGE` | bpm | +| `AVERAGE_HEART_RATE` | bpm | +| `MAX_HEART_RATE` | bpm | +| `CARDIO_RECOVERY` | bpm | +| `HEART_RATE_VARIABILITY` | ms (SDNN) | +| `HRV_RMSSD` | ms (RMSSD) | +| `RESPIRATORY_RATE` | count/min | +| `OXYGEN_SATURATION` | percent (0–100) | +| `BLOOD_GLUCOSE` | mg/dL | +| `BODY_FAT` | percent (0–100) | +| `FAT_MASS` | kg | +| `FAT_FREE_MASS` | kg | +| `LEAN_BODY_MASS` | kg | +| `MUSCLE_MASS` | kg | +| `BONE_MASS` | kg | +| `TOTAL_BODY_WATER` | kg | +| `VISCERAL_FAT` | rating | +| `BODY_MASS_INDEX` | kg/m² | +| `BODY_TEMPERATURE` | °C (core) | +| `SKIN_TEMPERATURE` | °C (dermal spot) | +| `WRIST_TEMPERATURE` | °C (overnight wrist) | +| `SLEEP_DURATION` | minutes | +| `SLEEP_NEED` | minutes | +| `SLEEP_PERFORMANCE` | percent (0–100) | +| `SLEEP_EFFICIENCY` | percent (0–100) | +| `SLEEP_CONSISTENCY` | percent (0–100) | +| `SLEEP_DISTURBANCE_COUNT` | count | +| `BREATHING_DISTURBANCES` | count | +| `ACTIVITY_STEPS` | count | +| `FLIGHTS_CLIMBED` | count | +| `WALKING_RUNNING_DISTANCE` | metres | +| `SIX_MINUTE_WALK_DISTANCE` | metres | +| `WALKING_STEP_LENGTH` | metres | +| `WALKING_SPEED` | m/s | +| `STAIR_ASCENT_SPEED` | m/s | +| `STAIR_DESCENT_SPEED` | m/s | +| `WALKING_STEADINESS` | percent (0–100) | +| `WALKING_ASYMMETRY` | percent (0–100) | +| `WALKING_DOUBLE_SUPPORT` | percent (0–100) | +| `ACTIVE_ENERGY_BURNED` | kcal | +| `ENERGY_EXPENDITURE_KJ` | kJ | +| `VO2_MAX` | mL/(kg·min) | +| `PULSE_WAVE_VELOCITY` | m/s | +| `VASCULAR_AGE` | years | +| `FALL_COUNT` | count | +| `AUDIO_EXPOSURE_ENV` | dBA | +| `AUDIO_EXPOSURE_HEADPHONE` | dBA | +| `TIME_IN_DAYLIGHT` | minutes | +| `RECOVERY_SCORE` | score (0–100) | +| `STRESS_SCORE` | score (0–100) | +| `STRAIN_SCORE` | score (0–100) | +| `DAY_STRAIN` | score (0–21) | +| `WORKOUT_STRAIN` | score (0–21) | +| `AUDIO_EXPOSURE_EVENT` | count (event) | +| `IRREGULAR_RHYTHM_NOTIFICATION` | event | +| `HIGH_HEART_RATE_EVENT` | event | +| `LOW_HEART_RATE_EVENT` | event | +| `WALKING_STEADINESS_EVENT` | event | +| `BREATHING_DISTURBANCE_EVENT` | event | The live, machine-readable list of writable types and their units is also served by `GET /api/meta/capabilities` under `ingest.quantityTypes` diff --git a/docs/integrations/whoop.md b/docs/integrations/whoop.md index d9f9ce759..8359de2d0 100644 --- a/docs/integrations/whoop.md +++ b/docs/integrations/whoop.md @@ -31,6 +31,7 @@ grant scoped to credentials they control. and the connection dies when the first access token expires (~1 hour). The remaining `read:*` scopes are read-only; request all of them so the user grants once and every sync resource is covered. + 4. Save the app. WHOOP issues a **Client ID** and **Client Secret** — keep the secret out of any chat log; it grants read access to the linked account's full WHOOP history. diff --git a/docs/integrations/withings.md b/docs/integrations/withings.md index 41fa96bc4..465ad4465 100644 --- a/docs/integrations/withings.md +++ b/docs/integrations/withings.md @@ -11,14 +11,14 @@ relay. The Withings client (`src/lib/withings/client.ts`) maps the following metric types into `Measurement` rows tagged `source = WITHINGS`: -| Withings meastype | HealthLog `MeasurementType` | -| ----------------- | --------------------------- | -| 1 | `WEIGHT` | -| 5 | `FAT_FREE_MASS` | -| 9 | `BLOOD_PRESSURE_DIA` | -| 10 | `BLOOD_PRESSURE_SYS` | -| 11 | `PULSE` | -| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) | +| Withings meastype | HealthLog `MeasurementType` | +| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| 1 | `WEIGHT` | +| 5 | `FAT_FREE_MASS` | +| 9 | `BLOOD_PRESSURE_DIA` | +| 10 | `BLOOD_PRESSURE_SYS` | +| 11 | `PULSE` | +| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) | | Plus the body-composition family (fat ratio, muscle mass, bone mass, hydration) and sleep / heart-rate variability via the heart-list endpoint | Activity data (steps, active energy, distance, floors climbed) lands @@ -136,7 +136,7 @@ Override per-user via the Sources section of `/settings/thresholds`. - **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID` or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both are set in the container's runtime environment (`docker compose - exec app env | grep WITHINGS`). +exec app env | grep WITHINGS`). - **`Callback URI mismatch`** — the URL HealthLog sends to Withings must match an entry in the developer-portal app's callback list. Add the public URL exactly, including the `https://` scheme and @@ -183,7 +183,7 @@ The Settings page **Disconnect** button: the database — disconnect does not delete data. Re-linking the same Withings account merges back onto the same metric timeline; the upsert path matches on `(userId, type, measuredAt, - source)` so previously-synced rows update in place. +source)` so previously-synced rows update in place. ## Admin probe diff --git a/docs/ops/env-check.md b/docs/ops/env-check.md index 3dd76f928..2e186ca46 100644 --- a/docs/ops/env-check.md +++ b/docs/ops/env-check.md @@ -39,11 +39,11 @@ export without polluting the local shell. Output is one row per declared variable, grouped by section. Three states: -| Marker | Meaning | -| --------------------- | ---------------------------------------------------------------------------------- | -| `[OK]` | Variable is set and non-empty. | -| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. | -| `[missing-optional]` | Variable is optional. The script reports it but does not fail. | +| Marker | Meaning | +| -------------------- | -------------------------------------------------------------- | +| `[OK]` | Variable is set and non-empty. | +| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. | +| `[missing-optional]` | Variable is optional. The script reports it but does not fail. | The grep-friendly format lets a CI pipeline do `grep -q '\[MISSING-REQUIRED\]'` to gate a deploy. The CI integration itself @@ -93,7 +93,7 @@ so changes go through a PR. To add a new variable: Deferred to v1.4.43: - GitHub Actions workflow that runs `pnpm check-env --file - .env.production.example` on every PR, blocking changes that +.env.production.example` on every PR, blocking changes that introduce new required vars without updating the example file. - Coolify pre-deploy hook that runs `pnpm check-env` inside the target container and aborts the deploy on exit code 1. diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index e71dc5e05..ef77cb16c 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -96,6 +96,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix ``` `--tombstone-implausible` is refused without `--fix`. + - **Drifted schedule windows are reconciled** to `min/max(times_of_day)` so the legacy pair stops contradicting the canonical dose times. Overnight windows (`window_end < window_start`) diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 996bd107d..03135bd6f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -9,8 +9,8 @@ already installed, longer if a TLS-fronted public hostname is in scope - **Docker 24+ with Compose v2.** The bundled `docker-compose.yml` pulls a multi-arch image from GitHub Container Registry (linux/amd64 - + linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work - unchanged. + - linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work + unchanged. - **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with measurement history; the Apple Health import worker can briefly hold a 1.5 GB upload in `/tmp` while it parses. @@ -43,11 +43,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env ``` -| Variable | Purpose | Rotation cost | -| -------- | ------- | ------------- | -| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update | -| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | -| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token | +| Variable | Purpose | Rotation cost | +| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update | +| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | +| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token | Then open `.env` and confirm `DATABASE_URL` includes the password you just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`) @@ -131,9 +131,9 @@ compose `environment:` block has no effect — they only take hold when you build your own image. They are intentionally absent from the compose runtime whitelist for that reason. -| Variable | Default | Effect when set | -| --- | --- | --- | -| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. | +| Variable | Default | Effect when set | +| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. | To flip one, rebuild the image with the variable exported: @@ -144,18 +144,18 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build ## 7. Where to go next -| Next step | File | -| --------- | ---- | -| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` | -| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` | -| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` | -| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` | -| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | -| Withings device sync | `docs/integrations/withings.md` | -| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` | -| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` | -| Apple Health `export.zip` import | `docs/integrations/apple-health.md` | -| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` | +| Next step | File | +| ------------------------------------------------------------------- | ------------------------------------- | +| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` | +| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` | +| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` | +| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` | +| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | +| Withings device sync | `docs/integrations/withings.md` | +| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` | +| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` | +| Apple Health `export.zip` import | `docs/integrations/apple-health.md` | +| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` | ## Troubleshooting @@ -168,7 +168,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build `app_settings.singleton.registrationEnabled`. The admin panel can flip it back if a previous owner closed it. - **Service unreachable on `localhost:3000`.** Check `docker compose - ps` — the `app` healthcheck polls `/api/version` every 30 seconds +ps` — the `app` healthcheck polls `/api/version` every 30 seconds and will mark the container unhealthy if Postgres is still starting. Tail `docker compose logs db` to confirm Postgres came up cleanly. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 19e6878f4..b3bd02df0 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -24,14 +24,14 @@ Apple-platform exclusives the web cannot offer. ## TL;DR — which channel needs what -| Channel | What it needs | Cost | Where it works | -| --- | --- | --- | --- | -| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) | -| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs | -| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser | -| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself | -| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox | -| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign | +| Channel | What it needs | Cost | Where it works | +| ------------ | ------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- | +| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after _Add to Home Screen_) | +| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs | +| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser | +| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself | +| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox | +| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign | Web Push is the recommended default — it needs no third-party account, just a keypair you generate yourself once. @@ -173,7 +173,7 @@ pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and HealthLog publishes to that topic. -1. Pick a hard-to-guess topic name (the topic *is* the access control on +1. Pick a hard-to-guess topic name (the topic _is_ the access control on public `ntfy.sh` — anyone who knows the string can read it, so treat it like a secret). 2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/`. @@ -268,14 +268,14 @@ under your own Apple identity. Everything below is that path. The APNs env vars (`src/lib/notifications/senders/apns.ts`): -| Variable | Purpose | -| --- | --- | -| `APNS_KEY_ID` | The `.p8` key's Key ID | -| `APNS_TEAM_ID` | Your Apple Developer Team ID | -| `APNS_BUNDLE_ID` | Your app's bundle identifier | -| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) | -| `APNS_KEY` | OR the raw PEM body inline | -| `APNS_KEY_FILE` | OR a path to the `.p8` file | +| Variable | Purpose | +| --------------------------- | ----------------------------------------------------------------------------------- | +| `APNS_KEY_ID` | The `.p8` key's Key ID | +| `APNS_TEAM_ID` | Your Apple Developer Team ID | +| `APNS_BUNDLE_ID` | Your app's bundle identifier | +| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) | +| `APNS_KEY` | OR the raw PEM body inline | +| `APNS_KEY_FILE` | OR a path to the `.p8` file | | `APNS_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement | APNs is **all-or-none**: either set the three IDs plus exactly one key @@ -311,14 +311,14 @@ Two gotchas worth knowing before you blame a stale token: ## Which channel should I pick? -| Your setup | Recommended channel | -| --- | --- | -| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | -| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. | -| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | -| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | -| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | -| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | +| Your setup | Recommended channel | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | +| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. | +| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | +| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | +| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | +| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | Mixing channels is fine and common — e.g. Web Push on the laptop and ntfy on the phone. Each user configures their own set from diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index bb30fd909..051c5bdfd 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -45,7 +45,7 @@ reached: - **Physical or legal seizure of your own host.** If the machine running PostgreSQL — your VPS, your home server, your NAS — is seized, imaged, or compelled, the data is on it. Encryption at rest protects - against a stolen disk or a leaked backup only while the key is *not* + against a stolen disk or a leaked backup only while the key is _not_ also available; if the host is running, or the key is stored next to the data, that protection is gone. - **Your hosting provider.** A VPS provider can snapshot your disk and @@ -77,7 +77,7 @@ record, not speculation: California privacy statute over the sharing of menstrual-tracking data; the verdict and the underlying class action are public record. -These are cited as factual public record to explain *why* the +These are cited as factual public record to explain _why_ the "who holds the data" question is the one that matters for this category. HealthLog makes no claim about any other product's current practices. diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md index 471e630ed..b75df95b1 100644 --- a/docs/self-hosting/reverse-proxy.md +++ b/docs/self-hosting/reverse-proxy.md @@ -11,11 +11,11 @@ the env-var pairing is identical across them. ## Pre-flight — env vars the proxy interacts with -| Variable | Required for | Notes | -| -------- | ------------ | ----- | -| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves | -| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match | -| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop | +| Variable | Required for | Notes | +| --------------------- | ---------------------------------------------- | ---------------------------------------- | +| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves | +| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match | +| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop | Set the URL pair to the public origin (`https://your-instance.example.com`) and restart the `app` container. The image reads both at startup; no @@ -28,12 +28,12 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries. This guards against a client rotating `X-Forwarded-For` per request to defeat IP-based rate limits. -| Topology | `TRUST_PROXY_HOPS` | -| -------- | ------------------ | -| App is internet-facing with no proxy | `0` (XFF ignored entirely) | -| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) | -| Cloudflare → your proxy → app | `2` | -| Cloudflare → Coolify-Tunnel → Coolify → app | `3` | +| Topology | `TRUST_PROXY_HOPS` | +| --------------------------------------------------------- | -------------------------- | +| App is internet-facing with no proxy | `0` (XFF ignored entirely) | +| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) | +| Cloudflare → your proxy → app | `2` | +| Cloudflare → Coolify-Tunnel → Coolify → app | `3` | A misconfigured count logs a one-shot warning to stderr and collapses every anonymous caller into one shared rate-limit bucket. Match the @@ -132,7 +132,7 @@ operator attention: The following variables are **build-time only** — they are injected via `--build-arg` by the `docker-publish` workflow and baked into the -shipped image. Setting them under Coolify's *Environment Variables* +shipped image. Setting them under Coolify's _Environment Variables_ panel has zero effect on the running container, and the stale value becomes a misleading artefact next time someone audits the deploy config: diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index d6480cfca..9e314b2a2 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -90,7 +90,7 @@ services: app: image: ghcr.io/mbombeck/healthlog:latest environment: - DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100 + DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100 HEALTHLOG_PROCESS_TYPE: web ``` diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index 3e58b9069..48b78efd7 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -235,7 +235,7 @@ lists) uses the unified `` rail from labelled ` setReminderLateDraft(Number(e.target.value))} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - className="w-32" - /> - -
- -

- {t("admin.reminderMissedMinutesDescription")} -

- setReminderMissedDraft(Number(e.target.value))} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - className="w-32" - /> +
+
+
+ +

+ {t("admin.reminderLateMinutesDescription")} +

+ setReminderLateDraft(Number(e.target.value))} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + className="w-32" + /> +
+
+ +

+ {t("admin.reminderMissedMinutesDescription")} +

+ setReminderMissedDraft(Number(e.target.value))} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + disabled={updateSettings.isPending} + className="w-32" + /> +
-
-
- - - + + -
+ { + onSuccess: () => { + setReminderLateDraft(null); + setReminderMissedDraft(null); + }, + }, + ); + }} + disabled={ + updateSettings.isPending || + (reminderLateDraft == null && reminderMissedDraft == null) + } + > + {updateSettings.isPending && ( + + )} + {t("common.save")} + + - {testNotification.data?.results && - testNotification.data.results.length > 0 && ( -
- {testNotification.data.results.map((r, i) => ( -
- {r.success ? ( - - ) : ( - - )} - {r.channel} - {r.error && ( - — {r.error} - )} -
- ))} -
- )} - - {reminderCheck.data?.medications && - reminderCheck.data.medications.length > 0 && ( -
- {/* v1.4.33 IW9 — h4 -> h2 so the page outline stays - sequential (parent admin SectionFrame ships

). */} -

- {t("admin.reminderCheckResults")} -

-
- {reminderCheck.data.medications.map((med, i) => ( -
-
- - {med.name} ({med.dose}) - - - {med.notificationsEnabled - ? t("admin.reminderCheckNotifOn") - : t("admin.reminderCheckNotifOff")} - -
-

- {med.user} — {med.dayOfWeek} {med.localTime} —{" "} - {t("admin.reminderCheckEventsToday")}: {med.eventsToday} -

- {med.schedules.map((sched, j) => { - const statusColor = - sched.status === "open" - ? "text-success dark:text-green-400" - : sched.status === "threshold" - ? "text-warning dark:text-yellow-400" - : sched.status === "missed" - ? "text-destructive dark:text-red-400" - : sched.status === "skipped" - ? "text-muted-foreground" - : ""; - return ( -
- - {sched.window} - - - [{sched.days}] - - {sched.label} - {sched.notificationSent && ( - - - {t("admin.reminderCheckNotifSent")} - - )} -
- ); - })} + {testNotification.data?.results && + testNotification.data.results.length > 0 && ( +
+ {testNotification.data.results.map((r, i) => ( +
+ {r.success ? ( + + ) : ( + + )} + {r.channel} + {r.error && ( + — {r.error} + )}
))}
-
- )} -
- + )} + + {reminderCheck.data?.medications && + reminderCheck.data.medications.length > 0 && ( +
+ {/* v1.4.33 IW9 — h4 -> h2 so the page outline stays + sequential (parent admin SectionFrame ships

). */} +

+ {t("admin.reminderCheckResults")} +

+
+ {reminderCheck.data.medications.map((med, i) => ( +
+
+ + {med.name} ({med.dose}) + + + {med.notificationsEnabled + ? t("admin.reminderCheckNotifOn") + : t("admin.reminderCheckNotifOff")} + +
+

+ {med.user} — {med.dayOfWeek} {med.localTime} —{" "} + {t("admin.reminderCheckEventsToday")}: {med.eventsToday} +

+ {med.schedules.map((sched, j) => { + const statusColor = + sched.status === "open" + ? "text-success dark:text-green-400" + : sched.status === "threshold" + ? "text-warning dark:text-yellow-400" + : sched.status === "missed" + ? "text-destructive dark:text-red-400" + : sched.status === "skipped" + ? "text-muted-foreground" + : ""; + return ( +
+ + {sched.window} + + + [{sched.days}] + + {sched.label} + {sched.notificationSent && ( + + + {t("admin.reminderCheckNotifSent")} + + )} +
+ ); + })} +
+ ))} +
+
+ )} +
+ ); } diff --git a/src/components/charts/__tests__/bd-tile.test.tsx b/src/components/charts/__tests__/bd-tile.test.tsx index 9ee1ac337..489d21608 100644 --- a/src/components/charts/__tests__/bd-tile.test.tsx +++ b/src/components/charts/__tests__/bd-tile.test.tsx @@ -86,9 +86,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => { ); // Same primitive slots on both tiles. - for (const slot of [ - "trend-card-value-row", - ]) { + for (const slot of ["trend-card-value-row"]) { expect(bdHtml).toContain(`data-slot="${slot}"`); expect(weightHtml).toContain(`data-slot="${slot}"`); } diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx index 609ceb6d0..a9533aab7 100644 --- a/src/components/charts/__tests__/chart-error-state.test.tsx +++ b/src/components/charts/__tests__/chart-error-state.test.tsx @@ -100,9 +100,8 @@ describe("chart isError rendering — error state, not empty state", () => { mockQueryError(); const { I18nProvider } = await import("@/lib/i18n/context"); - const { MedicationComplianceChart } = await import( - "../medication-compliance-chart" - ); + const { MedicationComplianceChart } = + await import("../medication-compliance-chart"); const html = renderToStaticMarkup( @@ -125,9 +124,7 @@ describe(" — announcement, sizing, accessible name", () => { }); async function renderState( - props: Partial< - import("../chart-error-state").ChartErrorStateProps - > = {}, + props: Partial = {}, ) { const { ChartErrorState } = await import("../chart-error-state"); return renderToStaticMarkup( diff --git a/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx index 9a344b43c..197cf9a01 100644 --- a/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx +++ b/src/components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx @@ -29,7 +29,6 @@ describe(" — empty-state gate by raw count", () => vi.resetModules(); }); - it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => { const data = [ { date: "2026-05-19", scheduled: 10, taken: 8 }, @@ -48,9 +47,8 @@ describe(" — empty-state gate by raw count", () => })); const { I18nProvider } = await import("@/lib/i18n/context"); - const { MedicationComplianceChart } = await import( - "../medication-compliance-chart" - ); + const { MedicationComplianceChart } = + await import("../medication-compliance-chart"); const html = renderToStaticMarkup( @@ -79,9 +77,8 @@ describe(" — empty-state gate by raw count", () => })); const { I18nProvider } = await import("@/lib/i18n/context"); - const { MedicationComplianceChart } = await import( - "../medication-compliance-chart" - ); + const { MedicationComplianceChart } = + await import("../medication-compliance-chart"); const html = renderToStaticMarkup( diff --git a/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx index 1733651b2..35bd7d700 100644 --- a/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx +++ b/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx @@ -28,7 +28,6 @@ describe(" — empty-state gate by raw count", () => { vi.resetModules(); }); - it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => { const data = { entries: [ diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx index 21377d57b..94dc50ec9 100644 --- a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx +++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx @@ -209,8 +209,10 @@ describe("dashboard.*Short keys exist across all six locales", () => { .default; const dashboard = messages.dashboard ?? {}; for (const key of REQUIRED_KEYS) { - expect(dashboard[key], `dashboard.${key} missing in ${locale}.json`) - .toBeTypeOf("string"); + expect( + dashboard[key], + `dashboard.${key} missing in ${locale}.json`, + ).toBeTypeOf("string"); expect( (dashboard[key] as string).length, `dashboard.${key} empty in ${locale}.json`, diff --git a/src/components/charts/__tests__/trend-card-stale-days.test.tsx b/src/components/charts/__tests__/trend-card-stale-days.test.tsx index 8b690ef49..72fde7852 100644 --- a/src/components/charts/__tests__/trend-card-stale-days.test.tsx +++ b/src/components/charts/__tests__/trend-card-stale-days.test.tsx @@ -36,12 +36,12 @@ const baseProps = { describe("", () => { it("stays silent when the metric is fresh (<= 7 days)", () => { const html = render(); - expect(html).not.toContain("data-slot=\"tile-stale-hint\""); + expect(html).not.toContain('data-slot="tile-stale-hint"'); }); it("renders day-bucket copy when staleDays is in (7, 30]", () => { const html = render(, "en"); - expect(html).toContain("data-slot=\"tile-stale-hint\""); + expect(html).toContain('data-slot="tile-stale-hint"'); expect(html).toContain("Last reading 12 d ago"); }); diff --git a/src/components/charts/chart-overlay-controls.tsx b/src/components/charts/chart-overlay-controls.tsx index f7e188815..e759a8009 100644 --- a/src/components/charts/chart-overlay-controls.tsx +++ b/src/components/charts/chart-overlay-controls.tsx @@ -248,7 +248,7 @@ export function ChartOverlayControlsBody({ prefs.comparisonBaseline === value ? "default" : "outline" } size="sm" - className={`min-h-11 sm:min-h-9 px-2 text-[11px] ${isGreyed ? "opacity-50" : ""}`.trim()} + className={`min-h-11 px-2 text-[11px] sm:min-h-9 ${isGreyed ? "opacity-50" : ""}`.trim()} onClick={() => setComparisonBaseline(value)} aria-pressed={prefs.comparisonBaseline === value} aria-disabled={isGreyed || undefined} diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx index a4401fbdc..6e1c0e7c3 100644 --- a/src/components/charts/chart-skeleton.tsx +++ b/src/components/charts/chart-skeleton.tsx @@ -69,10 +69,7 @@ export function ChartSkeleton({ const [showSlowHint, setShowSlowHint] = useState(false); useEffect(() => { if (mini) return undefined; - const handle = setTimeout( - () => setShowSlowHint(true), - SLOW_HINT_DELAY_MS, - ); + const handle = setTimeout(() => setShowSlowHint(true), SLOW_HINT_DELAY_MS); return () => clearTimeout(handle); }, [mini]); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index c6a23e071..e92a03f48 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -259,7 +259,11 @@ export function ComplianceHeatmap({ only kicks in for the non-stretch (static 18 px cell) case when the parent column is narrower than `weeks * 21 px`. */}
setTooltip((prev) => (prev?.pinned ? prev : null))} + onMouseLeave={() => + setTooltip((prev) => (prev?.pinned ? prev : null)) + } > {/* Month labels */} {monthMarkers.map((m, i) => ( diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c26b57a24..305b927fa 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -740,7 +740,9 @@ export function HealthChart({ // request so a longer comparison window still resolves. sleepParams.set( "days", - String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))), + String( + Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays))), + ), ); // v1.16.8 — a failed fetch rejects the whole query instead of // silently resolving an empty series: the swallowed-catch @@ -872,9 +874,13 @@ export function HealthChart({ const scaledMin = measurement.minValue * valueScale; const scaledMax = measurement.maxValue * valueScale; current.min = - current.min === null ? scaledMin : Math.min(current.min, scaledMin); + current.min === null + ? scaledMin + : Math.min(current.min, scaledMin); current.max = - current.max === null ? scaledMax : Math.max(current.max, scaledMax); + current.max === null + ? scaledMax + : Math.max(current.max, scaledMax); } bucket.values[type] = current; dailyAggregates.set(dayKey, bucket); @@ -1063,20 +1069,20 @@ export function HealthChart({ // recomputes whenever the range tab changes the visible slice, so the // `` a sub-page lifts the callback into always reflects the // same window the chart paints. Skipped in mini mode (no stat strip). - const visibleStatsByType = useMemo | null>( - () => { - if (mini || !chartData?.length) return null; - const out: Record = {}; - for (const type of types) { - const stats = computeWindowStats( - chartData.map((point) => point[type] as number | undefined), - ); - if (stats.count > 0) out[type] = stats; - } - return Object.keys(out).length > 0 ? out : null; - }, - [mini, chartData, types], - ); + const visibleStatsByType = useMemo | null>(() => { + if (mini || !chartData?.length) return null; + const out: Record = {}; + for (const type of types) { + const stats = computeWindowStats( + chartData.map((point) => point[type] as number | undefined), + ); + if (stats.count > 0) out[type] = stats; + } + return Object.keys(out).length > 0 ? out : null; + }, [mini, chartData, types]); // Report the visible-range stats up to the sub-page so the shared // `` can read them. Effect (not render-time call) so the @@ -1674,10 +1680,7 @@ export function HealthChart({ // positions through the `ticks` prop so the legacy // day-aware density policy stays effective on the // pulse chart. - ticks={computeTickPositions( - chartData ?? [], - viewportWidth, - )} + ticks={computeTickPositions(chartData ?? [], viewportWidth)} padding={{ left: 10, right: 10 }} tickMargin={10} /> @@ -1690,7 +1693,9 @@ export function HealthChart({ domain={yDomain} tickFormatter={(value) => typeof value === "number" - ? formatAxisValue(useDecimalAxis ? value : Math.round(value)) + ? formatAxisValue( + useDecimalAxis ? value : Math.round(value), + ) : String(value) } unit={ diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index cf7c81611..d5fdd8ed8 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -246,7 +246,9 @@ export function MedicationComplianceChart({ const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.dashboardMedicationCompliance(days), queryFn: async (): Promise => { - return apiGet(`/api/medications/intake?scope=compliance&days=${days}`); + return apiGet( + `/api/medications/intake?scope=compliance&days=${days}`, + ); }, enabled: isAuthenticated, }); @@ -452,7 +454,7 @@ export function MedicationComplianceChart({ /> ) ) : ( -
+
@@ -218,7 +218,9 @@ export function MoodHeatmap({ // natural, square, left-aligned grid rather than CSS-stretching a few // capped columns into wide rectangles. Mirrors `compliance-heatmap`. className={stretch ? "block max-w-full" : "block"} - onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))} + onMouseLeave={() => + setTooltip((prev) => (prev?.pinned ? prev : null)) + } > {monthMarkers.map((m, i) => ( {[ - { color: "var(--dracula-green)", label: t("insights.mood.legendGreat") }, - { color: "var(--dracula-yellow)", label: t("insights.mood.legendGood") }, - { color: "var(--dracula-orange)", label: t("insights.mood.legendOkay") }, + { + color: "var(--dracula-green)", + label: t("insights.mood.legendGreat"), + }, + { + color: "var(--dracula-yellow)", + label: t("insights.mood.legendGood"), + }, + { + color: "var(--dracula-orange)", + label: t("insights.mood.legendOkay"), + }, { color: "var(--dracula-red)", label: t("insights.mood.legendLow") }, ].map(({ color, label }) => ( diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx index ac2bb5fe0..1f6f9f3ed 100644 --- a/src/components/charts/scatter-correlation-chart.tsx +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -97,7 +97,7 @@ export function ScatterCorrelationChart>({ // shape once layout settles; this floor only prevents the brief // zero-height window that fires Recharts' width(-1)/height(-1) // console warning when the chart swaps in after the skeleton. - className="touch-pan-y aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto" + className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto" style={{ height: undefined }} data-explicit-height={height} > diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 01f9c445e..f654a841b 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -134,11 +134,7 @@ export function TrendCard({ // appeared on the same tile because slope30 and trend7Delta could // legitimately disagree (30-day downward arc, 7-day uptick). const primarySignal: number | null = - trend7Delta != null - ? trend7Delta - : slope30 - ? slope30.slope * 7 - : null; + trend7Delta != null ? trend7Delta : slope30 ? slope30.slope * 7 : null; const TrendIcon = ((): typeof ArrowUp => { if (primarySignal == null) return Minus; @@ -233,51 +229,51 @@ export function TrendCard({ // deterministic height. No unit; the arrow slot below still // renders its muted placeholder so the row width stays stable. {emptyHint} ) : ( <> - - {latest !== null ? renderPair(latest, secondary?.latest) : "—"} - - - {unit} - + + {latest !== null ? renderPair(latest, secondary?.latest) : "—"} + + + {unit} + )} - {label} +
+ {label} {value}
); @@ -73,7 +67,7 @@ function Section({ children: React.ReactNode; }) { return ( -
+

{title}

{children}
@@ -100,8 +94,7 @@ export function ClinicianView({ const complianceEntries = sections.compliance ? Object.entries(report.compliance).filter(([, c]) => c.total > 0) : []; - const wellness = - report.wellnessScores?.filter((s) => s.count > 0) ?? []; + const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? []; return (
{label ? ( -

{label}

+

{label}

) : null} -

+

{t("clinicianView.period", { start: fmtDate(report.period.start), end: fmtDate(report.period.end), })}

-

+

{t("clinicianView.expires", { date: fmtDate(expiresAt) })}

-

+

{t("clinicianView.provenance")}

@@ -213,10 +206,10 @@ export function ClinicianView({ {/* ── Fenced wellness card (descriptive, NOT clinical) ────── */} {wellness.length > 0 ? (
-

+

{t("clinicianView.wellness.title")}

-

+

{t("clinicianView.wellness.disclaimer")}

@@ -239,7 +232,7 @@ export function ClinicianView({ ) : null}
-
+
{t("clinicianView.footer")}
diff --git a/src/components/clinician/share-unlock-gate.tsx b/src/components/clinician/share-unlock-gate.tsx index 790357525..a4f613e27 100644 --- a/src/components/clinician/share-unlock-gate.tsx +++ b/src/components/clinician/share-unlock-gate.tsx @@ -79,7 +79,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { return (
{ e.preventDefault(); void submit(passphrase); @@ -89,7 +89,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {

{t("clinicianView.unlock.title")}

-

+

{t("clinicianView.unlock.description")}

@@ -112,7 +112,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
{error && ( -

+

{t("clinicianView.unlock.error")}

)} diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index 75be6f152..9b65dcd69 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -11,10 +11,7 @@ function render(node: React.ReactNode) { ); } -function day( - date: string, - overrides: Partial = {}, -): CalendarDay { +function day(date: string, overrides: Partial = {}): CalendarDay { return { date, phase: "FOLLICULAR", diff --git a/src/components/cycle/__tests__/field-info.test.tsx b/src/components/cycle/__tests__/field-info.test.tsx index c97a58e63..4e80206d2 100644 --- a/src/components/cycle/__tests__/field-info.test.tsx +++ b/src/components/cycle/__tests__/field-info.test.tsx @@ -29,7 +29,10 @@ describe("", () => { // Radix renders the tooltip content lazily (portal, on open); the trigger // therefore reports a closed state until interacted with. const html = render( - , + , ); expect(html).toContain('data-state="closed"'); }); diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index f2e5a54cf..e7b281c00 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -107,7 +107,11 @@ describe("buildDayLogInput (new row → omit empties)", () => { ); expect(input.basalBodyTempC).toBe(36.5); expect(input.temperatureExcluded).toBe(true); - const patch = buildDayLogPatch({ ...blank(), bbt: "36.5", bbtDisturbed: true }); + const patch = buildDayLogPatch({ + ...blank(), + bbt: "36.5", + bbtDisturbed: true, + }); expect(patch.temperatureExcluded).toBe(true); }); @@ -145,7 +149,13 @@ describe("buildDayLogInput (new row → omit empties)", () => { describe("section summary-badge counts (v1.17.0)", () => { it("counts each logged symptom", () => { expect(symptomsCount(blank())).toBe(0); - const s = { ...blank(), symptoms: new Map([["cramps", 2], ["headache", null]]) }; + const s = { + ...blank(), + symptoms: new Map([ + ["cramps", 2], + ["headache", null], + ]), + }; expect(symptomsCount(s)).toBe(2); }); diff --git a/src/components/cycle/cycle-history-chart.tsx b/src/components/cycle/cycle-history-chart.tsx index 31bd337f2..4d5201969 100644 --- a/src/components/cycle/cycle-history-chart.tsx +++ b/src/components/cycle/cycle-history-chart.tsx @@ -131,9 +131,7 @@ export function CycleHistoryChart({ {t("cycle.history.chartTitle")} {hasData ? ( - - {t("cycle.history.chartCaption")} - + {t("cycle.history.chartCaption")} ) : null} @@ -144,7 +142,10 @@ export function CycleHistoryChart({ ) : ( <> {/* Stat chips — avg / variability / regularity classification. */} -
    +
      - t("cycle.history.days", { count: b.lengthDays }) - } + barTitle={(b) => t("cycle.history.days", { count: b.lengthDays })} /> {/* Inline legend mirroring the bar segments. */} @@ -279,8 +278,7 @@ function BarField({ const x = cx - barW / 2; const totalH = scale(b.lengthDays); const topY = baseY - totalH; - const periodH = - b.periodDays != null ? scale(b.periodDays) : 0; + const periodH = b.periodDays != null ? scale(b.periodDays) : 0; const periodTopY = baseY - periodH; const restH = totalH - periodH; const ovuY = @@ -353,9 +351,7 @@ function StatChip({ } & React.HTMLAttributes) { return (
    • -
      -
      -
      - - - {t(`cycle.phase.${wheel.phase}`)} - -
      +
      +
      +
      + + + {t(`cycle.phase.${wheel.phase}`)} + +
); diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index 68b323cca..2ee30642f 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -17,11 +17,7 @@ import { Switch } from "@/components/ui/switch"; import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; import { useTranslations } from "@/lib/i18n/context"; -import type { - CycleGoal, - CycleProfileDTO, - SecondarySymptom, -} from "./types"; +import type { CycleGoal, CycleProfileDTO, SecondarySymptom } from "./types"; import { useUpdateCyclePrefs } from "./use-cycle"; /** diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 37562ae52..43a63ac41 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -171,10 +171,7 @@ export function CycleView() { node never matched, leaving the tile stuck at the rise rule's `opacity: 0` (invisible but still boxed). The `contents` wrapper carries the flag without adding a layout box. */} -
+
- {loading ? ( -
- -
- ) : calendarError ? ( -
-

- {t("cycle.loadError")} + {loading ? ( +

+ +
+ ) : calendarError ? ( +
+

+ {t("cycle.loadError")} +

+ +
+ ) : ( + + )} + {!calendarError ? ( +

+ {t("cycle.ring.caption")}

+ ) : null} + {/* First-period CTA — only when no cycle is active yet. */} + {!loading && !calendarError && wheel.dayOfCycle == null ? ( -
- ) : ( - - )} - {!calendarError ? ( -

- {t("cycle.ring.caption")} -

- ) : null} - {/* First-period CTA — only when no cycle is active yet. */} - {!loading && !calendarError && wheel.dayOfCycle == null ? ( - - ) : null} + ) : null}
diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 79c76333a..00a78ba19 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -42,10 +42,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { - SheetSection, - SheetSectionCount, -} from "@/components/ui/sheet-section"; +import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { useTranslations } from "@/lib/i18n/context"; import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; import { FieldInfo } from "./field-info"; @@ -509,7 +506,9 @@ export function LogDaySheet({ const [cervixFirmness, setCervixFirmness] = useState( null, ); - const [cervixOpening, setCervixOpening] = useState(null); + const [cervixOpening, setCervixOpening] = useState( + null, + ); const [intercourse, setIntercourse] = useState(false); const [protectedSex, setProtectedSex] = useState(false); const [pregnancyTest, setPregnancyTest] = useState( @@ -884,121 +883,121 @@ export function LogDaySheet({ /> } > - setBbt(e.target.value)} - placeholder={t("cycle.sheet.temperaturePlaceholder")} - className="w-32" - aria-label={t("cycle.sheet.temperature")} - /> -

- {t("cycle.sheet.bbtHint")} -

- {resolveBbt(bbt) != null && ( -
-
- - {t("cycle.sheet.temperatureDisturbed")} - - - {t("cycle.sheet.temperatureDisturbedHint")} - -
- -
- )} - - - {/* Ovulation test */} - setBbt(e.target.value)} + placeholder={t("cycle.sheet.temperaturePlaceholder")} + className="w-32" + aria-label={t("cycle.sheet.temperature")} /> - } - > -
- {OPK_VALUES.map((v) => ( - setOpk((cur) => (cur === v ? null : v))} - > - {t(`cycle.ovulationTest.${v}`)} - - ))} -
-
- - {/* Symptothermal secondary symptom: cervical mucus (default) OR the - cervix observation, per the user's advanced-settings choice. Only one - of the two is offered so the log sheet never overwhelms. */} - {showCervix ? ( - -
- t(`cycle.cervixPosition.${v}`)} - onToggle={(v) => - setCervixPosition((cur) => (cur === v ? null : v)) - } - /> - t(`cycle.cervixFirmness.${v}`)} - onToggle={(v) => - setCervixFirmness((cur) => (cur === v ? null : v)) - } - /> - t(`cycle.cervixOpening.${v}`)} - onToggle={(v) => - setCervixOpening((cur) => (cur === v ? null : v)) - } - /> -
+

+ {t("cycle.sheet.bbtHint")} +

+ {resolveBbt(bbt) != null && ( +
+
+ + {t("cycle.sheet.temperatureDisturbed")} + + + {t("cycle.sheet.temperatureDisturbedHint")} + +
+ +
+ )}
- ) : ( + + {/* Ovulation test */} } >
- {MUCUS_VALUES.map((v) => ( + {OPK_VALUES.map((v) => ( setMucus((cur) => (cur === v ? null : v))} + active={opk === v} + onClick={() => setOpk((cur) => (cur === v ? null : v))} > - {t(`cycle.mucus.${v}`)} + {t(`cycle.ovulationTest.${v}`)} ))}
- )} + + {/* Symptothermal secondary symptom: cervical mucus (default) OR the + cervix observation, per the user's advanced-settings choice. Only one + of the two is offered so the log sheet never overwhelms. */} + {showCervix ? ( + +
+ t(`cycle.cervixPosition.${v}`)} + onToggle={(v) => + setCervixPosition((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixFirmness.${v}`)} + onToggle={(v) => + setCervixFirmness((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixOpening.${v}`)} + onToggle={(v) => + setCervixOpening((cur) => (cur === v ? null : v)) + } + /> +
+
+ ) : ( + + } + > +
+ {MUCUS_VALUES.map((v) => ( + setMucus((cur) => (cur === v ? null : v))} + > + {t(`cycle.mucus.${v}`)} + + ))} +
+
+ )} {/* Intimacy & contraception */} @@ -1008,48 +1007,48 @@ export function LogDaySheet({ > {/* Intercourse + protection */} -
-
-
- - -
- -
- {intercourse ? ( +
- ) : null} -
+ {intercourse ? ( +
+
+ + +
+ +
+ ) : null} +
{/* Contraceptive */} diff --git a/src/components/cycle/phase-education-card.tsx b/src/components/cycle/phase-education-card.tsx index eb8e65bcd..7bba9871f 100644 --- a/src/components/cycle/phase-education-card.tsx +++ b/src/components/cycle/phase-education-card.tsx @@ -124,83 +124,83 @@ export function PhaseEducationCard({ className, )} > - {/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */} -
-
+ {/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */} +
+
- {stillLearning ? ( -

- {t("cycle.phaseEducation.stillLearning")} -

- ) : ( - <> -

-

+ {stillLearning ? ( +

+ {t("cycle.phaseEducation.stillLearning")} +

+ ) : ( + <> +

+

- {/* Zone 2 — the curated, descriptive-only "what's happening" line, + {/* Zone 2 — the curated, descriptive-only "what's happening" line, followed by a deeper "context" line (what someone might notice + gentle framing of normal variation). Both stay descriptive, never clinical advice / a medical claim. */} -

- {t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])} -

-

- {t(PHASE_CONTEXT_KEY[phase as CyclePhase])} -

+

+ {t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])} +

+

+ {t(PHASE_CONTEXT_KEY[phase as CyclePhase])} +

- {/* Zone 3 — the user's OWN clustered symptoms for this phase + {/* Zone 3 — the user's OWN clustered symptoms for this phase ("you often notice this here"). */} - {chips.length > 0 ? ( -
-

- {t("cycle.phaseEducation.commonHere")} -

-
    - {chips.map((row) => { - const meta = byKey.get(row.symptomKey); - const Icon = meta?.icon; - const name = meta ? t(meta.labelKey) : row.symptomKey; - return ( -
  • - {Icon ? ( -
  • - ); - })} -
-
- ) : null} - - )} + {chips.length > 0 ? ( +
+

+ {t("cycle.phaseEducation.commonHere")} +

+
    + {chips.map((row) => { + const meta = byKey.get(row.symptomKey); + const Icon = meta?.icon; + const name = meta ? t(meta.labelKey) : row.symptomKey; + return ( +
  • + {Icon ? ( +
  • + ); + })} +
+
+ ) : null} + + )}
); diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d6f1ef287..591971cfd 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -193,8 +193,7 @@ export function useDeleteCustomSymptom() { export function useCycleProfile() { return useQuery({ queryKey: queryKeys.cycleProfile(), - queryFn: () => - apiGet("/api/cycle/profile"), + queryFn: () => apiGet("/api/cycle/profile"), staleTime: 5 * 60_000, }); } @@ -207,8 +206,7 @@ export function useCycleProfile() { export function useCycleInsights() { return useQuery({ queryKey: queryKeys.cycleInsights(), - queryFn: () => - apiGet("/api/cycle/insights"), + queryFn: () => apiGet("/api/cycle/insights"), staleTime: 5 * 60_000, }); } diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index 1bc2d2b61..750d97bab 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -40,10 +40,9 @@ let mockResult: { }; vi.mock("@/hooks/use-workouts", async () => { - const actual = - await vi.importActual( - "@/hooks/use-workouts", - ); + const actual = await vi.importActual( + "@/hooks/use-workouts", + ); return { ...actual, useWorkouts: () => mockResult, diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 95a975e9e..d13db1963 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -232,94 +232,94 @@ export function DashboardHero({ )} >
-
-
- {/* Greeting leads the typographic hierarchy: larger + heavier +
+
+ {/* Greeting leads the typographic hierarchy: larger + heavier than the verdict so the personalised line reads first. The verdict keeps its medium weight one step down — still the content lead, clearly subordinate to the greeting. Both stay on the existing foreground token; the band adds no new colour. */} -

- {welcomeText} -

-

- {sentence} + {welcomeText}

- {cta !== null && ctaLabelKey ? ( - cta.kind === "link" ? ( - - ) : ( - - ) - ) : null} -
-
-
-
- {/* Right column — fixed 120 px ring footprint. A null score + {/* Right column — fixed 120 px ring footprint. A null score renders the ring's provisional state (aria announces "not enough data", never 0) at identical geometry, so the column NEVER collapses. */} -
- +
+ +
-
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating (renders nothing unless an episode is active), value-free, and untinted so it frames the score without alarming. Closes the diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index e52db6cab..ad111d972 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -103,13 +103,14 @@ export function RecentWorkoutsTile() { return (
-