diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ebdea3088..e2367f2b7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -33,6 +33,12 @@ jobs: - name: ESLint run: pnpm lint + # Prettier formatting gate. The format pass excludes generator-owned + # artifacts via .prettierignore (notably docs/api/openapi.yaml, which + # the OpenAPI drift check below enforces byte-for-byte). + - name: Prettier format check + run: pnpm format:check + - name: TypeScript strict check run: pnpm typecheck diff --git a/.gitignore b/.gitignore index ec6b028a4..132725d27 100644 --- a/.gitignore +++ b/.gitignore @@ -46,8 +46,9 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# prisma generated -/src/generated/prisma +# prisma generated — ignore the whole path so a stray symlink at +# `src/generated` (e.g. from a worktree) can never be committed +/src/generated # v1.4.43 QoL (L3) — generated at build time by # scripts/generate-sw-version.mjs; loaded by public/sw.js to key diff --git a/.prettierignore b/.prettierignore index 06202557b..68f690586 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,5 +2,17 @@ node_modules .next out build +coverage + +# Prisma-generated client. src/generated + +# Lockfile — owned by pnpm. pnpm-lock.yaml + +# Generator-owned API contract. The openapi:check job enforces its exact +# bytes against the Zod registry, so prettier must never rewrite it. +docs/api/openapi.yaml + +# Build-generated service worker (the PWA build emits it). +public/sw.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c55c05f4..4b7edb69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ ## [Unreleased] +## [1.18.7] — 2026-06-19 — dashboard, Coach and insights polish + +A broad UI, insights, and AI-efficiency release. It ships database migrations (see the migration note below) and two client-facing contract changes: Coach budget responses now arrive as a stream error rather than a 429, and the measurement-reminder push carries the reminder id. + +### Added + +- The daily briefing surfaces "signals of the day" — today's readings against your 7- and 30-day trend, emerging slopes and recent outliers — and leads with one concrete, present-focused nudge. +- Preventive-care cards show a quiet seven-day strip of the metric, and their menu jumps straight to the measurement history filtered to that type. +- The Coach reads your active illness state and Rest Mode, can point you to the relevant in-depth guide, and draws on a tiered view of your history (recent days in full, older periods progressively summarised with peaks preserved). +- Dictate to the Coach by voice. +- The share-link section is back as its own Settings entry, next to the health-record export: mint a time-boxed, revocable, read-only link to share your record with a clinician. +- Share links now carry a passphrase second factor. A new link mints a one-time passphrase embedded in a QR code (in the URL fragment, never sent to the server); the public view stays locked until the passphrase is verified. Links created before this release keep working without one. +- Labs, preventive care, and the condition journal each have their settings as a proper section of the Settings shell, in the same layout as every other section, instead of a separate page. + +### Changed + +- The daily briefing regenerates every day and reads warmer and more motivating. +- The Coach interface is rebuilt around a calm, centred reading column with a collapsible conversation list, a thinking indicator that hands off to streaming, and a quieter scrollbar. +- The health-score rings are flattened to match the chart style — the glow, sheen and pulse are gone. +- Sleep drops the redundant "last night" card; the chronotype reads as a prominent summary with your natural sleep midpoint and an expandable detail. +- Every integration card follows one layout — a short description ending in an inline setup-guide link, with the credential hint above the save button. +- A warm insight cycle now sends far fewer AI requests: the per-metric status assessments are batched into a single call, and the comprehensive path recovers from a malformed reply with one corrective retry instead of failing outright. +- The daily briefing now varies its wording day to day while the underlying findings stay stable, so a quiet day no longer reads as a frozen cache. +- Prompt construction is consolidated: grounding, tone, and the medication-safety rules come from one shared source across the briefing, status cards, narratives, and the Coach. +- Settings cards share one card primitive and a consistent padding step, and touch controls across the app meet a 44-pixel target on phones while staying compact on wider screens. +- The local-AI private-host switch accepts an explicit host allowlist instead of an all-or-nothing flag (the previous `true` still means "any private host"). +- Coach conversations are pruned on a schedule, alongside the existing notification-attempt retention. + +### Fixed + +- The daily briefing no longer goes stale: a fresh daily signal forces regeneration instead of re-stamping an unchanged narrative. +- The sleep assessment no longer shows the "connect an AI provider" prompt when a provider is already connected. +- A medication's supply now updates on the next read: adding a container or registering a new medication evicts the cached list instead of serving the pre-change stock for the cache window, and the card and table refresh together. +- The seed for the public demo no longer produces an impossible blood-pressure reading, and reads as a current, healthy, fully-used account. + +### Security + +- The Coach budget gate reserves spend atomically before each request, so concurrent requests can no longer slip past the cap together, and tokens burned on an empty or refused reply are still counted. +- New share links require a passphrase to open the record; the passphrase is stored only as an HMAC hash, verified in constant time behind a rate limit, and unlocks a short-lived view cookie scoped to that one link. +- The session and authentication-challenge tables are indexed on their expiry column for the reaper. +- The development key-padding fallback fails closed unless the environment is explicitly development or test, so a missing `NODE_ENV` can no longer silently derive a weak key. + +### Migration + +- This release adds three migrations: expiry indexes on the session and challenge tables, a daily-briefing reroll marker, and the nullable share-link passphrase hash. Run `prisma migrate deploy` (the entrypoint does this automatically). All three are additive — no backfill, no downtime. + ## [1.18.6.1] — 2026-06-18 — settings and Coach UI follow-ups A small UI and settings patch on top of v1.18.6. No migration, no API contract change. @@ -2287,6 +2333,7 @@ iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neithe ### Tests Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11: + - BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline` - BP with null readings ever → tile NOT emitted - Weight within 7 days → behaviour unchanged (regression guard) @@ -2652,7 +2699,7 @@ self-heal via boot-time backfill on first reach. card; not exposed via any route yet. - **`computeLongWindowSummary(userId, type, windowDays)`** in `summaries-slice.ts` — granularity-routed `count / min / max / - mean / sum` aggregate for long-window consumers. +mean / sum` aggregate for long-window consumers. - **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` / `readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with legacy-NULL fallback. @@ -2682,7 +2729,7 @@ rides the post-deploy window. - **`/api/measurements?groupBy=day` cumulative path:** consumes `sum_value` directly; eliminates per-type JS aggregation on `ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE / - TIME_IN_DAYLIGHT / ACTIVE_ENERGY`. +TIME_IN_DAYLIGHT / ACTIVE_ENERGY`. - **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing 425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes @@ -2739,10 +2786,10 @@ false and stranded the whole helper on live SQL across the full ### Fixed - **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS + - PULSE + WEIGHT`** — the three types the helper actually reads. +PULSE + WEIGHT`** — the three types the helper actually reads. `isFullyCovered(coverage)` AND-term dropped. - **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA`**. Same fix. +BLOOD_PRESSURE_DIA`**. Same fix. - **`health-score-fast-path` gates only on `WEIGHT`**. Same fix. Coverage-probe semantics unchanged. Each helper now consults the @@ -3049,7 +3096,7 @@ without changing existing behaviour. `aps.category = "MEDICATION_REMINDER"` on every medication-reminder payload so iOS renders the three action-buttons (Genommen / Snooze 15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content - = 1` is set by default so a future Notification Service Extension += 1` is set by default so a future Notification Service Extension can hook the payload without a server-side change. The med-reminder metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min" action pins to the schedule slot rather than wall-clock delivery @@ -3248,7 +3295,7 @@ dashboard SQL surfaced. list through a Zod `refine` chained on the existing `isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at the write boundary so the new `to_char(measured_at AT TIME ZONE - $tz)` SQL in the dashboard summary route cannot be self-DoS'd by +$tz)` SQL in the dashboard summary route cannot be self-DoS'd by a corrupted stored value. ### Tests @@ -3576,7 +3623,7 @@ surfaces city + country when the carrier lookup misses. buckets + a freshly logged WEIGHT type) falls back to the live aggregate so no type silently underreports. - **Daily chart fetches opt into rollup buckets.** `GET - /api/measurements?aggregate=daily&source=rollup` reads DAY buckets +/api/measurements?aggregate=daily&source=rollup` reads DAY buckets from `measurement_rollups` instead of scanning the measurements table when the window exceeds seven days. The Insights chart fetchers pass `source=rollup`; iOS and other clients are @@ -3643,7 +3690,7 @@ surfaces city + country when the carrier lookup misses. `pickCumulativeDaySum` helper, source-priority-aware in the same shape SLEEP_DURATION already used. - **Insights nav strict-gate** flipped (`if (!availability) return - true` → `return false`). Types without measurements no longer +true` → `return false`). Types without measurements no longer appear in the strip. - **About → Admin Console.** "Über HealthLog" moved out of the personal dropdown into a new `/admin/about` slug. The public @@ -3669,7 +3716,7 @@ surfaces city + country when the carrier lookup misses. ### Schema - **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL - DEFAULT '{}'`). Migration +DEFAULT '{}'`). Migration `0068_v1436_insights_exclude_metrics` is idempotent under re-run. ### Refactor @@ -3679,7 +3726,7 @@ surfaces city + country when the carrier lookup misses. `applyInsightsExcludeFilter`, `pickCumulativeDaySum`, `probeRollupCoverage`, `isFullyCovered`. Tested standalone. - `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed: - true, rollup_refresh_error: ... }` on failure instead of swallowing +true, rollup_refresh_error: ... }` on failure instead of swallowing silently. The read path still returns `{ recomputed: false }` so the response never fails because of a populator hiccup. - Cumulative metric-key lookup hoisted out of a four-level nested @@ -3741,7 +3788,7 @@ zero reader-path impact (serial concurrency, runs in the background). in place. The discovery query finds users with at least one row in `measurements` and zero rows in `measurement_rollups`, then sends one job per uncovered account with `singletonKey: - boot-backfill|{userId}` so a fast restart while a fold is queued +boot-backfill|{userId}` so a fast restart while a fold is queued doesn't pile up duplicates. - **Best-effort:** any error during discovery is captured in the return value and logged through `workerLog`; the worker boot never @@ -3756,7 +3803,7 @@ zero reader-path impact (serial concurrency, runs in the background). user lands on the queue when a third already-folded user co-exists; zero jobs enqueued when every account is already covered. - Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm - typecheck` + `pnpm lint` clean. +typecheck` + `pnpm lint` clean. ## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap @@ -3774,7 +3821,7 @@ across every analytics surface is planned for a follow-up. - **New table `measurement_rollups`** keyed on `(user_id, type, granularity, bucket_start)` with `count / mean / min_value / max_value / sd / slope / r2 / - computed_at`. Backed by a composite-descending index on +computed_at`. Backed by a composite-descending index on `(user_id, type, granularity, bucket_start DESC)`. Additive only — no `ALTER` on existing tables. Migration `0067_v1434_measurement_rollups` is idempotent under re-run. @@ -3850,8 +3897,8 @@ viewport-zoom fix surfaced during the mobile-deep pass. - `tests/integration/withings-oauth.test.ts` covers connect / callback / disconnect plus state-token replay rejection. - `tests/integration/passkey-register.test.ts` covers register-options - + register-verify + tampered-attestation rejection + challenge - single-use. + - register-verify + tampered-attestation rejection + challenge + single-use. - `tests/integration/cache-invalidation-coverage.test.ts` grids the v1.4.34 IW-G invalidation matrix across 5 write surfaces. - Integration suite goes from 197/50 to 222/55. tsc + lint clean. @@ -3923,7 +3970,7 @@ freeze. - **Focus rings on mobile + tablet navigation.** The bottom-nav primary links, the More overflow trigger, and the mobile top-bar user menu trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2 - focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard +focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard and switch-control users can now see focus on every nav surface. - **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px @@ -4157,7 +4204,7 @@ without leaking userIds. gate; Bearer tokens never elevate). - **`ImportJob` schema model + migration.** New Prisma model captures the per-upload state machine (`queued | unpacking | parsing | - upserting | done | failed`), the content-hash for idempotency +upserting | done | failed`), the content-hash for idempotency short-circuits, and the per-`MeasurementType` ingestion counters the status route surfaces back to the client. - **`lastSeenByType` on `/api/analytics`.** Both the slim @@ -4408,8 +4455,7 @@ the affected surfaces. `very_late`.** Defensive fallback when the dose-window estimator can't resolve a window. - **`/api/insights/generate` POST gating.** Endpoint now refuses - on a disabled assistant master flag instead of returning a - 500. + on a disabled assistant master flag instead of returning a 500. - **Mood Log overflow on narrow viewports.** Cards no longer shred their internal grid below 360 CSS px. - **F13 username readability.** Header username now respects @@ -4756,7 +4802,7 @@ permission picker and the future Insights nav. ### Added - **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId, - date)` mints `"stats::"` +date)` mints `"stats::"` alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is locked in the iOS coordination notes (§12). iOS emits one row per day per cumulative type via @@ -4839,11 +4885,12 @@ encounter them in read paths because no source writes them yet, and the codegen path will pick them up on the next iOS regeneration. **Cutover sequence.** v1.4.30 ships with the helper + drain script -+ server tolerance for both shapes. The next iOS TestFlight build -adopts `HealthKitStatisticsService.swift` and starts posting daily- -aggregated rows for the five cumulative types. Operator runs the -drain script once after the new TestFlight cuts over; per-sample -row pressure on `Measurement` drops 50-200× for cumulative types. + +- server tolerance for both shapes. The next iOS TestFlight build + adopts `HealthKitStatisticsService.swift` and starts posting daily- + aggregated rows for the five cumulative types. Operator runs the + drain script once after the new TestFlight cuts over; per-sample + row pressure on `Measurement` drops 50-200× for cumulative types. ## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix @@ -5100,7 +5147,7 @@ internal-only `/api/internal/web-vitals` beacon route). GLP-1 row painted with a brand icon and a middle-dot separator; every other row painted two-line without an icon. Both routes through a shared `` now — line 1 is `{name} - {dose}`, line 2 is the class label plus state badges. The GLP-1 +{dose}`, line 2 is the class label plus state badges. The GLP-1 outlier shape is gone. - **HealthScore card came up short below the action+prompts column.** The card now opts into `flex h-full flex-col` with the disclaimer @@ -5138,7 +5185,7 @@ internal-only `/api/internal/web-vitals` beacon route). - **Medications detail-page chrome collapsed to one heading scale.** Every section on `/medications/[id]` mounts through `` (`text-base font-semibold leading-6 - tracking-tight`). DrugLevelChart's standalone header migrates to +tracking-tight`). DrugLevelChart's standalone header migrates to `

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

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

+

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

+
+ +
+ + setPassphrase(e.target.value)} + placeholder="XXXX-XXXX-XXXX-XXXX" + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + aria-invalid={error} + className="font-mono" + /> +
+ + {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 74e6b9bea..43a63ac41 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -171,10 +171,7 @@ export function CycleView() { node never matched, leaving the tile stuck at the rise rule's `opacity: 0` (invisible but still boxed). The `contents` wrapper carries the flag without adding a layout box. */} -
+
- {loading ? ( -
- -
- ) : calendarError ? ( -
-

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

+ +
+ ) : calendarError ? ( +
+

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

+ +
+ ) : ( + + )} + {!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}
@@ -255,7 +252,7 @@ export function CycleView() { triggers size to their content (`text-xs`, no `flex-1`) and the strip scrolls cleanly if needed; from `sm` up they reclaim the equal-width `flex-1` layout. */} - + ( null, ); - const [cervixOpening, setCervixOpening] = useState(null); + const [cervixOpening, setCervixOpening] = useState( + null, + ); const [intercourse, setIntercourse] = useState(false); const [protectedSex, setProtectedSex] = useState(false); const [pregnancyTest, setPregnancyTest] = useState( @@ -884,121 +883,121 @@ export function LogDaySheet({ /> } > - setBbt(e.target.value)} - placeholder={t("cycle.sheet.temperaturePlaceholder")} - className="w-32" - aria-label={t("cycle.sheet.temperature")} - /> -

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

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

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

+ {resolveBbt(bbt) != null && ( +
+
+ + {t("cycle.sheet.temperatureDisturbed")} + + + {t("cycle.sheet.temperatureDisturbedHint")} + +
+ +
+ )}
- ) : ( + + {/* Ovulation test */} } >
- {MUCUS_VALUES.map((v) => ( + {OPK_VALUES.map((v) => ( setMucus((cur) => (cur === v ? null : v))} + active={opk === v} + onClick={() => setOpk((cur) => (cur === v ? null : v))} > - {t(`cycle.mucus.${v}`)} + {t(`cycle.ovulationTest.${v}`)} ))}
- )} + + {/* Symptothermal secondary symptom: cervical mucus (default) OR the + cervix observation, per the user's advanced-settings choice. Only one + of the two is offered so the log sheet never overwhelms. */} + {showCervix ? ( + +
+ t(`cycle.cervixPosition.${v}`)} + onToggle={(v) => + setCervixPosition((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixFirmness.${v}`)} + onToggle={(v) => + setCervixFirmness((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixOpening.${v}`)} + onToggle={(v) => + setCervixOpening((cur) => (cur === v ? null : v)) + } + /> +
+
+ ) : ( + + } + > +
+ {MUCUS_VALUES.map((v) => ( + setMucus((cur) => (cur === v ? null : v))} + > + {t(`cycle.mucus.${v}`)} + + ))} +
+
+ )} {/* Intimacy & contraception */} @@ -1008,48 +1007,48 @@ export function LogDaySheet({ > {/* Intercourse + protection */} -
-
-
- - -
- -
- {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 02154f6de..d13db1963 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -232,92 +232,94 @@ export function DashboardHero({ )} >
-
-
- {/* Greeting leads the typographic hierarchy: larger + heavier +
+
+ {/* Greeting leads the typographic hierarchy: larger + heavier than the verdict so the personalised line reads first. The verdict keeps its medium weight one step down — still the content lead, clearly subordinate to the greeting. Both stay on the existing foreground token; the band adds no new colour. */} -

- {welcomeText} -

-

- {sentence} + {welcomeText}

- {cta !== null && ctaLabelKey ? ( - cta.kind === "link" ? ( - - ) : ( - - ) - ) : null} -
-
-
-
- {/* Right column — fixed 120 px ring footprint. A null score + {/* Right column — fixed 120 px ring footprint. A null score renders the ring's provisional state (aria announces "not enough data", never 0) at identical geometry, so the column NEVER collapses. */} -
- +
+ +
-
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating (renders nothing unless an episode is active), value-free, and untinted so it frames the score without alarming. Closes the diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index b85d45fa2..25d7f1c4a 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -281,7 +281,7 @@ export function MedicationIntakeQuickAdd({ // Fan-out to every inline compliance chart key (one per medication) // so the detail-page tile refreshes when the user re-enters it. await queryClient.invalidateQueries({ - queryKey: ["compliance-chart-inline"], + queryKey: queryKeys.complianceChartInline(), }); toast.success(t("common.saved")); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index e52db6cab..ad111d972 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -103,13 +103,14 @@ export function RecentWorkoutsTile() { return (
-