From 1f4ba5939d4858a810dd350a2bbffbeeef074bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 22:20:31 +0200 Subject: [PATCH] style: apply prettier across the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore. --- CHANGELOG.md | 79 +- CLAUDE.md | 89 +- docs/adr/0001-insights-naming-convention.md | 4 +- docs/api/cycle-healthkit-mapping.md | 58 +- docs/apple-store-connect-checklist.md | 16 + docs/diagrams/README.md | 14 +- docs/integrations/ai-providers.md | 12 +- docs/integrations/apple-health.md | 26 +- docs/integrations/data-import.md | 174 +-- docs/integrations/whoop.md | 1 + docs/integrations/withings.md | 20 +- docs/ops/env-check.md | 12 +- docs/ops/intake-repair.md | 1 + docs/self-hosting/getting-started.md | 46 +- docs/self-hosting/notifications.md | 50 +- docs/self-hosting/reproductive-health-data.md | 4 +- docs/self-hosting/reverse-proxy.md | 24 +- docs/self-hosting/scaling.md | 2 +- docs/ui-guidelines.md | 2 +- e2e/coach-guided-questions.spec.ts | 4 +- e2e/dashboard.spec.ts | 7 +- e2e/insights-scroll-restoration.spec.ts | 18 +- e2e/medications-wizard-compose.spec.ts | 5 +- e2e/medications-wizard-helpers.ts | 10 +- e2e/mobile-viewport.spec.ts | 4 +- e2e/v1427-coach-launch.spec.ts | 38 +- e2e/v1427-insights-empty-state.spec.ts | 4 +- e2e/v1427-public-pages.spec.ts | 8 +- e2e/v1427-responsive-sheet.spec.ts | 8 +- eslint-plugins/healthlog/queryKey-factory.js | 4 +- .../healthlog/safe-fetch-required.js | 2 +- knip.json | 19 +- next.config.ts | 4 +- public/manifest.json | 12 +- scripts/__tests__/generate-sw-version.test.ts | 6 +- scripts/backfill-mood-note-column.ts | 8 +- scripts/backfill-rollups.ts | 9 +- scripts/check-env.ts | 9 +- scripts/env-manifest.json | 10 +- scripts/generate-sw-version.mjs | 4 +- scripts/migrate-demo-edge01.README.md | 46 +- scripts/promote-digest.ts | 6 +- scripts/repair-intake-anomalies.ts | 23 +- scripts/rotate-encryption-key.ts | 12 +- scripts/seed-demo.ts | 223 +++- src/__tests__/api/analytics/since-cap.test.ts | 42 +- .../api/auth/check-user/route.test.ts | 5 +- src/__tests__/i18n-call-site-coverage.test.ts | 21 +- src/__tests__/i18n-drift-guard.test.ts | 6 +- src/__tests__/i18n-reverse-coverage.test.ts | 6 +- .../proxy-demo-mode-allowlist.test.ts | 4 +- src/__tests__/service-worker.test.ts | 9 +- .../__tests__/dashboard-chart-reveal.test.tsx | 8 +- .../dashboard-tile-strip-skeleton.test.ts | 4 +- src/app/__tests__/insights-polish.test.ts | 8 +- src/app/about/page.tsx | 17 +- src/app/achievements/page.tsx | 9 +- .../module-route-gate-inventory.test.ts | 5 +- .../admin/ai-settings/__tests__/route.test.ts | 16 +- .../__tests__/route.test.ts | 52 +- .../admin/import-apple-health-export/route.ts | 5 +- src/app/api/admin/invites/route.ts | 9 +- .../diagnostic/__tests__/route.test.ts | 4 +- .../admin/notifications/diagnostic/route.ts | 7 +- .../api/admin/notifications/health/route.ts | 8 +- .../test/__tests__/route.test.ts | 9 +- src/app/api/admin/notifications/test/route.ts | 10 +- .../__tests__/route.test.ts | 16 +- .../settings/web-push-vapid/generate/route.ts | 9 +- .../api/admin/status/__tests__/route.test.ts | 1 - .../admin/users/[id]/__tests__/route.test.ts | 5 +- src/app/api/analytics/__tests__/route.test.ts | 44 +- src/app/api/analytics/route.ts | 9 +- .../api/auth/login/__tests__/route.test.ts | 3 +- .../__tests__/route.test.ts | 2 +- .../auth/me/modules/__tests__/route.test.ts | 31 +- .../me/research-mode/__tests__/route.test.ts | 8 +- src/app/api/auth/me/research-mode/route.ts | 3 +- .../unit-preference/__tests__/route.test.ts | 5 +- .../api/auth/passkey/login-options/route.ts | 5 +- .../__tests__/native-token.test.ts | 5 +- .../api/auth/password/__tests__/route.test.ts | 12 +- .../api/auth/register/__tests__/route.test.ts | 4 +- src/app/api/auth/register/route.ts | 5 +- src/app/api/bugreport/__tests__/route.test.ts | 4 +- src/app/api/c/[token]/unlock/route.ts | 25 +- src/app/api/coach/about-me/questions/route.ts | 6 +- .../api/coach/reminder-suggestions/route.ts | 5 +- .../api/consent/ai/__tests__/route.test.ts | 1 - .../consent/ai/latest/__tests__/route.test.ts | 11 +- src/app/api/consent/ai/latest/route.ts | 11 +- .../custom/__tests__/custom-crud.test.ts | 44 +- src/app/api/dashboard/snapshot/route.ts | 4 +- .../dashboard/summary/__tests__/route.test.ts | 85 +- src/app/api/dashboard/summary/route.ts | 37 +- .../dashboard/widgets/__tests__/route.test.ts | 72 +- src/app/api/dashboard/widgets/route.ts | 5 +- .../__tests__/soft-delete-filter.test.ts | 8 +- src/app/api/export/health-record/route.ts | 13 +- src/app/api/export/measurements/route.ts | 7 +- src/app/api/fhir/__tests__/route.test.ts | 3 +- .../credentials/__tests__/route.test.ts | 5 +- .../__tests__/module-gate.test.ts | 5 +- .../illness/episodes/[id]/resolve/route.ts | 6 +- .../[id]/restore/__tests__/route.test.ts | 7 +- src/app/api/illness/episodes/[id]/route.ts | 6 +- src/app/api/illness/insights/route.ts | 10 +- .../[jobId]/status/__tests__/route.test.ts | 34 +- .../__tests__/route.test.ts | 39 +- .../api/import/csv/__tests__/route.test.ts | 4 +- src/app/api/import/csv/route.ts | 15 +- .../ingest/medication/__tests__/route.test.ts | 14 +- .../__tests__/route.test.ts | 11 +- .../chat/__tests__/route-module-gate.test.ts | 13 +- src/app/api/insights/chat/route.ts | 7 +- .../coach/facts/__tests__/route.test.ts | 8 +- .../comprehensive/__tests__/route.test.ts | 233 ++-- src/app/api/insights/comprehensive/route.ts | 11 +- .../correlations/__tests__/route.test.ts | 4 +- src/app/api/insights/correlations/route.ts | 13 +- .../insights/derived/__tests__/route.test.ts | 52 +- src/app/api/insights/glp1-timeline/route.ts | 6 +- .../insights/layout/__tests__/route.test.ts | 58 +- .../metric-status/__tests__/route.test.ts | 7 +- src/app/api/insights/metric-status/route.ts | 5 +- .../mood-status/__tests__/route.test.ts | 8 +- src/app/api/insights/narrative/route.ts | 5 +- .../pregenerate/__tests__/route.test.ts | 14 +- .../rhythm-events/__tests__/route.test.ts | 11 +- src/app/api/insights/rhythm-events/route.ts | 4 +- .../insights/targets/__tests__/route.test.ts | 70 +- .../healthkit/__tests__/route.test.ts | 2 +- .../status/__tests__/route.test.ts | 4 +- .../api/integrations/withings/resume/route.ts | 6 +- .../web-vitals/__tests__/route.test.ts | 4 +- .../[id]/complete/__tests__/route.test.ts | 14 +- src/app/api/measurement-reminders/route.ts | 8 +- .../measurements/[id]/__tests__/route.test.ts | 10 +- .../__tests__/group-by-day.test.ts | 20 +- .../__tests__/put-duplicate-timestamp.test.ts | 27 +- .../__tests__/range-aggregation-route.test.ts | 4 +- .../__tests__/zod-multi-issue.test.ts | 12 +- src/app/api/measurements/batch/route.ts | 8 +- .../series-batch/__tests__/route.test.ts | 3 +- .../series/__tests__/route.test.ts | 76 +- src/app/api/measurements/series/route.ts | 7 +- .../medications/[id]/__tests__/route.test.ts | 32 +- .../[id]/cadence/__tests__/route.test.ts | 4 +- src/app/api/medications/[id]/cadence/route.ts | 10 +- .../[id]/compliance/__tests__/route.test.ts | 55 +- .../api/medications/[id]/compliance/route.ts | 6 +- .../[id]/dose-history/__tests__/route.test.ts | 4 +- .../medications/[id]/dose-history/route.ts | 6 +- .../[id]/glp1/__tests__/route.test.ts | 4 +- .../intake/[eventId]/__tests__/route.test.ts | 53 +- .../[id]/intake/__tests__/route.test.ts | 29 +- .../bulk-delete/__tests__/route.test.ts | 38 +- .../[id]/intake/purge/__tests__/route.test.ts | 9 +- .../[itemId]/__tests__/route.test.ts | 19 +- .../[id]/inventory/[itemId]/route.ts | 5 +- .../[id]/inventory/__tests__/route.test.ts | 9 +- .../[id]/phase-config/__tests__/route.test.ts | 14 +- src/app/api/medications/[id]/route.ts | 15 +- .../schedule-revisions/[revisionId]/route.ts | 13 +- .../__tests__/route.test.ts | 63 +- .../[id]/schedule-revisions/route.ts | 3 +- .../[id]/side-effects/__tests__/route.test.ts | 3 +- .../api/medications/__tests__/route.test.ts | 16 +- src/app/api/medications/compliance/route.ts | 6 +- .../extract/__tests__/route.test.ts | 29 +- .../intake/__tests__/route.test.ts | 10 +- src/app/api/medications/intake/route.ts | 5 +- src/app/api/medications/route.ts | 176 ++- .../meta/capabilities/__tests__/route.test.ts | 12 +- .../umami-script/__tests__/route.test.ts | 6 +- .../api/mood-entries/__tests__/route.test.ts | 22 +- .../mood/analytics/__tests__/route.test.ts | 10 +- .../mood/tags/__tests__/custom-crud.test.ts | 135 ++- .../mood/tags/__tests__/groups-crud.test.ts | 36 +- .../api/mood/tags/__tests__/layout.test.ts | 28 +- src/app/api/mood/tags/__tests__/route.test.ts | 72 +- src/app/api/mood/tags/groups/[key]/route.ts | 4 +- src/app/api/mood/tags/layout/route.ts | 6 +- src/app/api/mood/tags/route.ts | 4 +- .../connect/__tests__/route.test.ts | 5 +- src/app/api/nightscout/connect/route.ts | 5 +- src/app/api/nightscout/disconnect/route.ts | 6 +- src/app/api/nightscout/test/route.ts | 9 +- src/app/api/onboarding/step/route.ts | 5 +- .../api/oura/callback/__tests__/route.test.ts | 28 +- .../oura/disconnect/__tests__/route.test.ts | 6 +- src/app/api/oura/test/route.ts | 5 +- .../personal-records/__tests__/route.test.ts | 8 +- src/app/api/polar/callback/route.ts | 5 +- src/app/api/polar/connect/route.ts | 5 +- .../polar/disconnect/__tests__/route.test.ts | 6 +- src/app/api/polar/test/route.ts | 14 +- src/app/api/settings/email/route.ts | 5 +- src/app/api/settings/email/test/route.ts | 8 +- src/app/api/settings/telegram/test/route.ts | 10 +- src/app/api/settings/webhook/test/route.ts | 3 +- src/app/api/share-links/[id]/route.ts | 10 +- .../api/share-links/__tests__/route.test.ts | 3 +- src/app/api/share-links/route.ts | 9 +- .../api/sleep/night/__tests__/route.test.ts | 10 +- src/app/api/sync/changes/route.ts | 128 +-- .../telegram/webhook/__tests__/route.test.ts | 10 +- .../api/user/avatar/__tests__/route.test.ts | 4 +- src/app/api/user/avatar/route.ts | 20 +- src/app/api/version/__tests__/route.test.ts | 8 +- src/app/api/version/route.ts | 3 +- .../whoop/callback/__tests__/route.test.ts | 5 +- .../api/whoop/connect/__tests__/route.test.ts | 15 +- src/app/api/whoop/connect/ticket/route.ts | 5 +- .../whoop/credentials/__tests__/route.test.ts | 17 +- .../webhook/[token]/__tests__/route.test.ts | 19 +- .../withings/callback/__tests__/route.test.ts | 22 +- .../withings/connect/__tests__/route.test.ts | 7 +- src/app/api/withings/connect/route.ts | 5 +- src/app/api/withings/webhook/[token]/route.ts | 4 +- .../workouts/__tests__/batch-create.test.ts | 11 +- .../__tests__/canonical-dedup.test.ts | 8 +- src/app/api/workouts/__tests__/detail.test.ts | 28 +- src/app/c/[token]/__tests__/page.test.tsx | 5 +- src/app/c/[token]/page.tsx | 10 +- src/app/coach/page.tsx | 44 +- src/app/global-error.tsx | 6 +- .../__tests__/assessment-spine-order.test.ts | 15 +- .../__tests__/explainer-metric-keys.test.ts | 6 +- src/app/insights/bmi/page.tsx | 4 +- src/app/insights/medications/page.tsx | 14 +- src/app/insights/pulse/page.tsx | 10 +- .../sleep/__tests__/empty-state-href.test.ts | 5 +- src/app/insights/weight/page.tsx | 5 +- src/app/labs/page.tsx | 4 +- src/app/layout.tsx | 5 +- .../__tests__/add-token-resolution.test.ts | 7 +- src/app/onboarding/[step]/page.tsx | 5 +- src/app/onboarding/layout.tsx | 6 +- src/app/page.tsx | 3 +- src/app/privacy/__tests__/page.test.tsx | 10 +- src/app/privacy/page.tsx | 1005 ++++++++--------- .../motion-reduce-spin-coverage.test.ts | 3 +- src/components/admin/admin-shell.tsx | 4 +- src/components/admin/ai-quality-section.tsx | 120 +- src/components/admin/reminders-section.tsx | 394 +++---- .../charts/__tests__/bd-tile.test.tsx | 4 +- .../__tests__/chart-error-state.test.tsx | 9 +- ...compliance-chart-empty-state-gate.test.tsx | 11 +- .../mood-chart-empty-state-gate.test.tsx | 1 - .../trend-card-baseline-alignment.test.tsx | 6 +- .../__tests__/trend-card-stale-days.test.tsx | 4 +- .../charts/chart-overlay-controls.tsx | 2 +- src/components/charts/chart-skeleton.tsx | 5 +- src/components/charts/compliance-heatmap.tsx | 10 +- src/components/charts/health-chart.tsx | 49 +- .../charts/medication-compliance-chart.tsx | 6 +- src/components/charts/mood-heatmap.tsx | 25 +- .../charts/scatter-correlation-chart.tsx | 2 +- src/components/charts/trend-card.tsx | 84 +- src/components/clinician/clinician-view.tsx | 31 +- .../clinician/share-unlock-gate.tsx | 6 +- .../cycle/__tests__/bbt-chart.test.tsx | 5 +- .../cycle/__tests__/field-info.test.tsx | 5 +- .../cycle/__tests__/log-day-sheet.test.ts | 14 +- src/components/cycle/cycle-history-chart.tsx | 20 +- src/components/cycle/cycle-ring-tile.tsx | 44 +- src/components/cycle/cycle-settings.tsx | 6 +- src/components/cycle/cycle-view.tsx | 81 +- src/components/cycle/log-day-sheet.tsx | 269 +++-- src/components/cycle/phase-education-card.tsx | 140 +-- src/components/cycle/use-cycle.ts | 6 +- .../__tests__/recent-workouts-tile.test.tsx | 7 +- .../dashboard/hero/dashboard-hero.tsx | 148 +-- .../dashboard/recent-workouts-tile.tsx | 13 +- src/components/data-list/delete-button.tsx | 9 +- src/components/data-list/selection.ts | 5 +- .../achievement-unlock-notifier.test.tsx | 9 +- .../recent-achievements-card.test.tsx | 12 +- .../gamification/recent-achievements-card.tsx | 5 +- .../__tests__/maintainership-banner.test.tsx | 9 +- .../illness/illness-correlation-card.tsx | 9 +- .../illness/illness-day-timeline.tsx | 4 +- .../illness/illness-episode-detail.tsx | 11 +- .../illness/illness-insights-card.tsx | 10 +- src/components/illness/illness-view.tsx | 8 +- src/components/illness/log-day-sheet.tsx | 13 +- src/components/illness/new-episode-sheet.tsx | 21 +- src/components/illness/use-illness.ts | 12 +- .../__tests__/daily-briefing.test.tsx | 9 +- .../health-score-card-provenance.test.tsx | 12 +- .../__tests__/health-score-card.test.tsx | 5 +- .../health-score-delta-explainer.test.tsx | 20 +- .../healthkit-metric-page-status.test.tsx | 18 +- .../insights/__tests__/hero-strip.test.tsx | 4 +- .../__tests__/insight-status-card.test.tsx | 5 +- .../insights-tab-strip-memo.test.tsx | 3 +- .../__tests__/insights-tab-strip.test.tsx | 15 +- .../__tests__/sleep-hypnogram.test.tsx | 5 +- .../__tests__/trend-annotation.test.tsx | 4 +- .../trend-descriptor-caption.test.tsx | 10 +- .../insights/__tests__/trends-row.test.tsx | 13 +- .../use-insights-advisor-timeout.test.ts | 17 +- .../__tests__/message-thread.test.tsx | 10 +- .../__tests__/mobile-rail-tray.test.tsx | 6 +- .../coach-panel/coach-conversation.tsx | 8 +- .../coach-panel/coach-drawer-body.tsx | 4 +- .../insights/coach-panel/coach-input.tsx | 6 +- .../coach-panel/guided-dialog-bubbles.tsx | 10 +- .../insights/coach-panel/history-rail.tsx | 3 +- .../coach-panel/self-context-adopt-offer.tsx | 5 +- .../insights/coach-panel/use-coach.ts | 4 +- src/components/insights/daily-briefing.tsx | 5 +- .../__tests__/provenance-explainer.test.tsx | 5 +- .../__tests__/score-anatomy-view.test.tsx | 5 +- .../__tests__/vitals-dashboard.test.tsx | 58 +- .../__tests__/wellness-scores.test.tsx | 20 +- .../derived/composite-score-anatomy.tsx | 11 +- .../insights/derived/coverage-meter.tsx | 2 +- .../insights/derived/provenance-explainer.tsx | 2 +- .../insights/derived/score-anatomy-view.tsx | 7 +- .../insights/derived/sparkline-delta-tile.tsx | 14 +- src/components/insights/derived/standards.ts | 251 ++-- .../derived/trajectory-forecast-card.tsx | 15 +- .../insights/derived/use-dashboard-derived.ts | 5 +- .../insights/derived/use-derived-metric.ts | 10 +- .../insights/derived/vitals-dashboard.tsx | 44 +- src/components/insights/device-score-tile.tsx | 10 +- .../__tests__/glucose-clinical-panel.test.tsx | 18 +- .../glucose/glucose-advanced-disclosure.tsx | 8 +- .../glucose/glucose-clinical-panel.tsx | 10 +- .../insights/glucose/glucose-tir-bar.tsx | 32 +- src/components/insights/health-score-card.tsx | 21 +- .../insights/health-score-delta-explainer.tsx | 9 +- .../insights/healthkit-metric-page.tsx | 3 +- src/components/insights/hero-strip.tsx | 2 +- .../insights/insights-edit-mode.tsx | 8 +- .../insights/insights-layout-shell.tsx | 7 +- src/components/insights/layout-coach-fab.tsx | 6 +- .../insights/measurement-diversity-nudge.tsx | 2 +- .../insights/metric-correlation-card.tsx | 4 +- .../insights/metric-target-summary.tsx | 108 +- .../mood/__tests__/mood-better-days.test.tsx | 15 +- .../mood-notes-timeline-removed.test.ts | 5 +- .../insights/mood/mood-better-days.tsx | 4 +- .../insights/mood/mood-distribution-chart.tsx | 5 +- .../insights/mood/mood-insights-sections.tsx | 1 - .../insights/mood/mood-tag-breakdown.tsx | 5 +- .../insights/mood/mood-tag-influence.tsx | 9 +- .../mood/mood-tag-metric-crosstab.tsx | 4 +- .../insights/mood/mood-what-stands-out.tsx | 5 +- .../insights/personal-record-badge.tsx | 5 +- .../insights/recommendation-card.tsx | 6 +- .../insights/recommendation-feedback.tsx | 20 +- .../insights/recovery/recovery-section.tsx | 6 +- src/components/insights/rest-mode-banner.tsx | 4 +- .../insights/sleep/chronotype-card.tsx | 8 +- .../insights/sleep/chronotype-section.tsx | 5 +- .../insights/sleep/sleep-debt-card.tsx | 5 +- .../sleep/sleep-quality-grounded-note.tsx | 8 +- .../insights/sleep/sleep-quality-section.tsx | 4 +- src/components/insights/sub-page-shell.tsx | 2 +- .../insights/use-insights-advisor.ts | 5 +- src/components/insights/workout-detail.tsx | 33 +- src/components/insights/workout-list.tsx | 9 +- src/components/labs/biomarker-form.tsx | 4 +- src/components/labs/biomarker-manager.tsx | 5 +- src/components/labs/lab-biomarker-chart.tsx | 3 +- src/components/labs/lab-biomarker-detail.tsx | 5 +- src/components/labs/lab-form.tsx | 4 +- src/components/labs/lab-history-list.tsx | 16 +- src/components/labs/lab-list.tsx | 13 +- src/components/labs/labs-settings.tsx | 4 +- .../__tests__/auth-shell-coach-hoist.test.ts | 14 +- .../layout/__tests__/nav-model.test.ts | 15 +- src/components/layout/auth-shell.tsx | 2 +- src/components/layout/bottom-nav.tsx | 11 +- src/components/layout/capture-picker.tsx | 7 +- src/components/layout/nav-model.ts | 7 +- src/components/layout/sidebar-nav.tsx | 11 +- .../vorsorge-dashboard-card.tsx | 69 +- .../vorsorge-section.tsx | 19 +- .../vorsorge-settings.tsx | 4 +- .../measurement-list-filter-select.test.tsx | 2 +- .../measurement-list-step-grouping.test.tsx | 5 +- .../measurements/measurement-list.tsx | 4 +- .../__tests__/add-inventory-dialog.test.tsx | 6 +- .../medications/__tests__/card-parts.test.tsx | 23 +- .../__tests__/detail-page-cascade.test.tsx | 11 +- .../dose-history-ledger-compute.test.ts | 16 +- .../__tests__/dose-strength-curve.test.tsx | 17 +- .../__tests__/drug-level-chart.test.tsx | 7 +- .../intake-edit-dialog-seed.test.tsx | 14 +- ...ntake-history-list-v2-row-actions.test.tsx | 20 +- .../log-injection-site-dialog.test.tsx | 13 +- .../__tests__/medication-card-header.test.tsx | 4 +- .../medication-card-symmetry.test.tsx | 4 +- .../__tests__/medication-table.test.tsx | 19 +- .../__tests__/supply-runway.test.ts | 52 +- .../__tests__/take-all-due.test.tsx | 13 +- .../__tests__/titration-timeline.test.ts | 7 +- .../__tests__/use-medication-intake.test.ts | 14 +- .../card-parts/displayed-slot-instant.ts | 8 +- .../card-parts/medication-compliance-bars.tsx | 5 +- .../detail/medication-detail-tabs.tsx | 38 +- .../medications/detail/supply-runway.ts | 7 +- .../dose-history-ledger-compute.ts | 11 +- .../medications/dose-history-ledger.tsx | 27 +- .../medications/dose-strength-curve.tsx | 5 +- .../medications/drug-level-chart.tsx | 135 ++- .../medications/glp1-medication-card.tsx | 3 +- .../medications/injection-site-picker.tsx | 8 +- .../medications/intake-history-list-v2.tsx | 6 +- .../medications/log-intake-dialog.tsx | 218 ++-- .../medications/medication-card-header.tsx | 2 +- .../medications/medication-card.tsx | 8 +- .../medications/medication-detail-section.tsx | 2 +- .../medications/medication-table.tsx | 20 +- .../__tests__/cadence-picker.test.tsx | 16 +- .../__tests__/course-window-row.test.tsx | 22 +- .../__tests__/times-of-day-chips.test.tsx | 17 +- .../medications/scheduling/cadence-picker.tsx | 16 +- .../medications/scheduling/dose-window.ts | 5 +- .../medications/scheduling/legacy-bridge.ts | 7 +- .../scheduling/natural-language-extractor.tsx | 17 +- .../scheduling/schedule-times-editor.tsx | 4 +- .../__tests__/notifications-section.test.tsx | 5 +- .../medications/sections/api-tokens-row.tsx | 10 +- .../sections/destructive-zone-section.tsx | 1 - .../sections/inventory-section.tsx | 30 +- .../medications/sections/settings-section.tsx | 1 - src/components/medications/settings-group.tsx | 2 +- .../medications/titration-timeline.tsx | 15 +- .../medications/use-medication-intake.ts | 11 +- .../wizard/__tests__/wizard-payload.test.ts | 75 +- .../medications/wizard/steps/step2-class.tsx | 5 +- .../medications/wizard/steps/step4-window.tsx | 4 +- .../wizard/steps/step6-sub-cadence.tsx | 2 +- .../medications/wizard/steps/step7-times.tsx | 6 +- .../wizard/steps/step8-summary.tsx | 41 +- .../medications/wizard/wizard-payload.ts | 31 +- .../medications/wizard/wizard-stepper.tsx | 198 ++-- .../mood-list-filter-select.test.tsx | 2 +- .../mood/__tests__/mood-qol-fixes.test.tsx | 28 +- .../mood/__tests__/recent-tags.test.ts | 22 +- src/components/mood/mood-form.tsx | 5 +- src/components/mood/mood-list.tsx | 8 +- .../__tests__/anamnesis-card.test.tsx | 5 +- .../__tests__/tour-launcher-gate.test.ts | 9 +- .../__tests__/tour-launcher-keys.test.ts | 5 +- src/components/onboarding/anamnesis-card.tsx | 6 +- src/components/onboarding/baseline-form.tsx | 10 +- src/components/onboarding/done-screen.tsx | 13 +- .../onboarding/goals-chip-picker.tsx | 8 +- .../onboarding/onboarding-shell.tsx | 5 +- .../onboarding/source-card-grid.tsx | 19 +- src/components/onboarding/tour-launcher.tsx | 6 +- src/components/onboarding/tour.tsx | 53 +- .../onboarding/welcome-carousel.tsx | 12 +- .../__tests__/disable-coach-card.test.tsx | 4 +- .../__tests__/oauth-return-outcome.test.ts | 5 +- src/components/settings/advanced-section.tsx | 12 +- .../settings/ai/disable-coach-card.tsx | 4 +- .../settings/ai/fallback-chain-card.tsx | 6 +- .../settings/coach-prefs-section.tsx | 4 +- .../settings/dashboard-layout-section.tsx | 5 +- src/components/settings/email-card.tsx | 6 +- src/components/settings/import-panel.tsx | 20 +- .../integrations/connections-panel.tsx | 10 +- .../settings/integrations/fitbit-card.tsx | 4 +- .../settings/integrations/nightscout-card.tsx | 34 +- .../integrations/oauth-provider-card.tsx | 17 +- .../integrations/setup-guide-link.tsx | 3 +- src/components/settings/low-stock-card.tsx | 3 +- .../settings/medications-section.tsx | 10 +- .../settings/mood-reminder-card.tsx | 5 +- src/components/settings/mood-section.tsx | 15 +- .../settings/notification-status-card.tsx | 4 +- .../settings/settings-section-frame.tsx | 3 +- src/components/settings/settings-shell.tsx | 4 +- src/components/settings/sharing-section.tsx | 4 +- .../settings/thresholds-editor-section.tsx | 5 +- src/components/settings/webhook-card.tsx | 10 +- .../ui/__tests__/responsive-sheet.test.tsx | 24 +- src/components/ui/dialog.tsx | 4 +- src/components/ui/empty-state.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/learning-gate.tsx | 9 +- src/components/ui/select.tsx | 2 +- src/components/ui/sheet-section.tsx | 7 +- src/components/ui/sheet.tsx | 2 +- src/components/ui/sonner.tsx | 4 +- src/hooks/__tests__/use-auth-retry.test.ts | 6 +- .../__tests__/use-feature-flags.test.tsx | 36 +- .../__tests__/use-insight-status.test.ts | 12 +- .../__tests__/use-roving-radio-group.test.ts | 8 +- src/hooks/use-count-up.ts | 10 +- src/hooks/use-insights-analytics.ts | 5 +- src/hooks/use-measurement-reminders.ts | 5 +- src/hooks/use-roving-radio-group.ts | 4 +- src/hooks/use-workouts.ts | 10 +- src/instrumentation.ts | 5 +- .../api-handler-safe-request-prop.test.ts | 6 +- src/lib/__tests__/api-response-zod.test.ts | 9 +- src/lib/__tests__/avatar.test.ts | 43 +- src/lib/__tests__/doctor-report-data.test.ts | Bin 16814 -> 16827 bytes .../__tests__/format-active-locale.test.ts | 9 +- .../__tests__/format-date-or-relative.test.ts | 41 +- src/lib/__tests__/geo-asn.test.ts | 12 +- .../__tests__/geo-offline-detection.test.ts | 10 +- src/lib/__tests__/get-client-ip.test.ts | 20 +- src/lib/__tests__/insights-layout.test.ts | 10 +- src/lib/__tests__/query-keys.test.ts | 12 +- src/lib/__tests__/reference-ranges.test.ts | 31 +- .../__tests__/safe-fetch-dispatcher.test.ts | 72 +- .../ai/__tests__/codex-slug-fallback.test.ts | 3 +- src/lib/ai/__tests__/consent-guard.test.ts | 5 +- .../ai/__tests__/local-host-allowlist.test.ts | 5 +- src/lib/ai/coach/__tests__/clusters.test.ts | 8 +- .../__tests__/conversation-summary.test.ts | 11 +- .../ai/coach/__tests__/cycle-snapshot.test.ts | 8 +- .../coach/__tests__/derived-snapshot.test.ts | 63 +- .../ai/coach/__tests__/facts-block.test.ts | 33 +- .../__tests__/facts-deterministic.test.ts | 11 +- src/lib/ai/coach/__tests__/facts.test.ts | 66 +- .../coach/__tests__/memory-snapshot.test.ts | 6 +- .../ai/coach/__tests__/persistence.test.ts | 4 +- .../__tests__/reference-grounding.test.ts | 12 +- .../coach/__tests__/snapshot-budget.test.ts | 6 +- .../__tests__/snapshot-new-metrics.test.ts | 11 +- src/lib/ai/coach/__tests__/snapshot.test.ts | 32 +- .../ai/coach/__tests__/suggest-gate.test.ts | 12 +- .../coach/__tests__/suggest-reminder.test.ts | 3 +- .../__tests__/trajectory-snapshot.test.ts | 47 +- src/lib/ai/coach/about-me.ts | 17 +- src/lib/ai/coach/conversation-summary.ts | 12 +- src/lib/ai/coach/cycle-snapshot.ts | 5 +- src/lib/ai/coach/derived-snapshot.ts | 15 +- src/lib/ai/coach/facts.ts | 52 +- src/lib/ai/coach/glp1-snapshot.ts | 2 - src/lib/ai/coach/illness-snapshot.ts | 7 +- src/lib/ai/coach/medication-extract-prompt.ts | 38 +- src/lib/ai/coach/memory-snapshot.ts | 6 +- src/lib/ai/coach/persistence.ts | 1 - src/lib/ai/coach/reference-grounding.ts | 4 +- src/lib/ai/coach/self-context-questions.ts | 5 +- src/lib/ai/coach/snapshot.ts | 273 ++++- src/lib/ai/coach/suggest-reminder.ts | 7 +- src/lib/ai/coach/trajectory-snapshot.ts | 5 +- src/lib/ai/codex-client.ts | 84 +- src/lib/ai/feedback-attribution.ts | 5 +- .../__tests__/coach-prompt-locale.test.ts | 41 +- .../drug-level-refusal.probe.test.ts | 43 +- .../__tests__/locale-skeleton-parity.test.ts | 16 +- src/lib/ai/prompts/general-status.ts | 1 - src/lib/ai/prompts/metric-archetypes.ts | 10 +- src/lib/ai/prompts/safety-contracts.ts | 5 +- src/lib/ai/prompts/status-batch.ts | 8 +- src/lib/ai/provider-health-ledger.ts | 7 +- src/lib/ai/provider.ts | 5 +- src/lib/analytics/__tests__/bp-grade.test.ts | 18 +- .../__tests__/bp-in-target-fast-path.test.ts | 23 +- .../__tests__/compliance-bundle.test.ts | 42 +- .../analytics/__tests__/compliance.test.ts | 189 +++- .../__tests__/correlations-fast-path.test.ts | 23 +- .../analytics/__tests__/correlations.test.ts | 1 - .../__tests__/glucose-metrics.test.ts | 18 +- .../__tests__/glucose-panel-coherence.test.ts | 8 +- .../__tests__/glucose-panel-window.test.ts | 9 +- .../__tests__/health-score-fast-path.test.ts | 61 +- .../__tests__/health-score-inputs.test.ts | 6 +- .../analytics/__tests__/health-score.test.ts | 15 +- .../analytics/__tests__/resting-pulse.test.ts | 16 +- .../analytics/__tests__/sleep-night.test.ts | 4 +- .../__tests__/source-priority.test.ts | 35 +- .../__tests__/summaries-slice.test.ts | 23 +- .../__tests__/window-confidence.test.ts | 3 +- src/lib/analytics/bp-grade.ts | 5 +- src/lib/analytics/bp-in-target-fast-path.ts | 7 +- src/lib/analytics/compliance.ts | 113 +- src/lib/analytics/correlations-fast-path.ts | 9 +- src/lib/analytics/correlations.ts | 1 - src/lib/analytics/glucose-metrics.ts | 9 +- src/lib/analytics/health-score-fast-path.ts | 22 +- src/lib/analytics/health-score-inputs.ts | 3 +- src/lib/analytics/health-score.ts | 6 +- src/lib/analytics/merge-slim-thick.ts | 8 +- src/lib/analytics/range-delta.ts | 12 +- src/lib/analytics/resting-pulse.ts | 5 +- src/lib/analytics/source-priority.ts | 4 +- src/lib/analytics/summaries-slice.ts | 12 +- src/lib/api-handler.ts | 11 +- src/lib/api-response.ts | 11 +- src/lib/api/__tests__/api-fetch.test.ts | 4 +- src/lib/api/__tests__/read-error.test.ts | 6 +- src/lib/api/api-fetch.ts | 6 +- src/lib/auth/__tests__/invite-token.test.ts | 4 +- src/lib/auth/__tests__/native-client.test.ts | 5 +- src/lib/auth/session.ts | 1 - src/lib/avatar.ts | 12 +- src/lib/charts/window-stats.ts | 4 +- .../resolve-share-gate-state.test.ts | 4 +- ...share-token-reject-on-authed-route.test.ts | 4 +- .../__tests__/unlock-cookie.test.ts | 6 +- src/lib/consent/__tests__/receipts.test.ts | 8 +- src/lib/consent/__tests__/web-grant.test.ts | 9 +- .../__tests__/encrypted-columns.test.ts | 6 +- .../cycle/__tests__/custom-symptoms.test.ts | 6 +- src/lib/cycle/__tests__/day-math.test.ts | 9 +- .../cycle/__tests__/engine-adapter.test.ts | 60 +- src/lib/cycle/__tests__/gate.test.ts | 7 +- .../cycle/__tests__/healthkit-mapping.test.ts | 20 +- .../cycle/__tests__/phase-crosstab.test.ts | 37 +- src/lib/cycle/__tests__/phase.test.ts | 16 +- src/lib/cycle/__tests__/prediction.test.ts | 131 ++- src/lib/cycle/backup.ts | 5 +- src/lib/cycle/dto.ts | 12 +- src/lib/dashboard-layout.ts | 4 +- .../dashboard/__tests__/snapshot-flag.test.ts | 2 +- src/lib/dashboard/__tests__/snapshot.test.ts | 36 +- src/lib/dashboard/meds-today.ts | 7 +- src/lib/dashboard/snapshot.ts | 26 +- src/lib/dashboard/widget-modules.ts | 27 +- src/lib/doctor-report-data.ts | 30 +- src/lib/doctor-report-pdf-core.ts | 48 +- src/lib/fhir/__tests__/build-bundle.test.ts | 49 +- .../fhir/__tests__/cycle-observations.test.ts | 18 +- .../glucose-clinical-observations.test.ts | 22 +- .../fhir/__tests__/lab-observations.test.ts | 8 +- src/lib/fhir/build-bundle.ts | 5 +- src/lib/fhir/loinc-map.ts | 5 +- src/lib/fhir/resources.ts | 39 +- src/lib/fitbit/__tests__/client.test.ts | 42 +- .../__tests__/response-classifier.test.ts | 6 +- .../fitbit/__tests__/sync-user-fitbit.test.ts | 4 +- src/lib/fitbit/__tests__/sync.test.ts | 37 +- src/lib/fitbit/client.ts | 84 +- src/lib/fitbit/mapping.md | 36 +- src/lib/fitbit/sync-metrics.ts | 6 +- src/lib/fitbit/sync.ts | 27 +- src/lib/format.ts | 4 +- src/lib/gamification/achievements-result.ts | 12 +- src/lib/gamification/care-metrics.ts | 4 +- src/lib/geo.ts | 17 +- src/lib/i18n/context.tsx | 5 +- .../__tests__/correlation-read.test.ts | 53 +- src/lib/illness/__tests__/correlation.test.ts | 62 +- .../illness/__tests__/day-log-write.test.ts | 14 +- .../illness/__tests__/retrospective.test.ts | 8 +- .../__tests__/safety-floor-notify.test.ts | 4 +- src/lib/illness/correlation-read.ts | 41 +- src/lib/illness/correlation.ts | 49 +- src/lib/illness/day-log-write.ts | 5 +- src/lib/illness/rest-mode.ts | 3 +- .../import/__tests__/csv-measurements.test.ts | 10 +- .../import/__tests__/unzip-export-xml.test.ts | 11 +- src/lib/import/csv-measurements.ts | 5 +- src/lib/import/unzip-export-xml.ts | 55 +- src/lib/insights-layout.ts | 8 +- .../__tests__/assessment-context.test.ts | 10 +- .../__tests__/coach-launch-context.test.tsx | 5 +- .../comprehensive-aggregator.test.ts | 40 +- .../comprehensive-json-retry.test.ts | 6 +- .../__tests__/correlation-discovery.test.ts | 27 +- .../__tests__/derived-briefing.test.ts | 55 +- src/lib/insights/__tests__/features.test.ts | 6 +- .../insights/__tests__/graded-series.test.ts | 53 +- .../invalidate-status-insights.test.ts | 4 +- .../insights/__tests__/metric-signal.test.ts | 18 +- .../__tests__/mood-aggregates.test.ts | 206 +++- .../__tests__/mood-narratives.test.ts | 116 +- .../insights/__tests__/recovery-score.test.ts | 34 +- .../insights/__tests__/status-batch.test.ts | 11 +- .../insights/__tests__/status-shared.test.ts | 12 +- .../insights/__tests__/strain-score.test.ts | 6 +- .../__tests__/trend-chart-select.test.ts | 20 +- .../__tests__/trend-descriptor.test.ts | 5 +- src/lib/insights/blood-pressure-status.ts | 12 +- src/lib/insights/chart-tokens.ts | 1 - src/lib/insights/comprehensive-aggregator.ts | 133 ++- src/lib/insights/comprehensive-generate.ts | 10 +- src/lib/insights/correlations.ts | 7 +- src/lib/insights/derived-briefing.ts | 4 +- .../derived/__tests__/baseline.test.ts | 10 +- .../insights/derived/__tests__/bmi.test.ts | 29 +- .../__tests__/coincident-deviation.test.ts | 28 +- .../__tests__/derived-assessment.test.ts | 63 +- .../derived/__tests__/dispatch.test.ts | 8 +- .../derived/__tests__/fitness-age.test.ts | 9 +- .../derived/__tests__/hrv-balance.test.ts | 110 +- .../insights/derived/__tests__/norms.test.ts | 8 +- .../derived/__tests__/readiness.test.ts | 52 +- .../derived/__tests__/sleep-debt.test.ts | 5 +- .../derived/__tests__/sleep-score.test.ts | 10 +- .../derived/__tests__/vascular-age.test.ts | 9 +- .../derived/__tests__/wellness-scores.test.ts | 29 +- src/lib/insights/derived/baseline.ts | 17 +- src/lib/insights/derived/chronotype.ts | 14 +- .../insights/derived/coincident-deviation.ts | 5 +- src/lib/insights/derived/coverage.ts | 7 +- .../insights/derived/derived-assessment-ai.ts | 3 +- .../insights/derived/derived-assessment.ts | 46 +- src/lib/insights/derived/dispatch.ts | 17 +- src/lib/insights/derived/hrv-balance.ts | 4 +- src/lib/insights/derived/index.ts | 5 +- src/lib/insights/derived/norms.ts | 4 +- src/lib/insights/derived/readiness.ts | 4 +- src/lib/insights/derived/recovery-resolve.ts | 3 +- src/lib/insights/derived/registry.ts | 4 +- src/lib/insights/derived/sleep-debt.ts | 5 +- src/lib/insights/derived/sleep-rhythm.ts | 5 +- src/lib/insights/derived/sleep-score.ts | 4 +- src/lib/insights/derived/trajectory.ts | 17 +- src/lib/insights/derived/vascular-age.ts | 4 +- src/lib/insights/exclude-filter.ts | 11 +- src/lib/insights/features.ts | 44 +- src/lib/insights/general-status.ts | 5 +- src/lib/insights/graded-series.ts | 3 +- src/lib/insights/metric-signal.ts | 11 +- src/lib/insights/metric-status-registry.ts | 8 +- src/lib/insights/metric-status.ts | 4 +- src/lib/insights/mood-aggregates.ts | 99 +- src/lib/insights/mood-narratives.ts | 4 +- src/lib/insights/mood-status.ts | 17 +- .../period-narrative-deterministic.test.ts | 22 +- .../period-narrative-generate.test.ts | 23 +- .../__tests__/period-narrative.test.ts | 95 +- .../period-narrative-deterministic.ts | 10 +- .../insights/narrative/period-narrative.ts | 11 +- src/lib/insights/pulse-status.ts | 34 +- src/lib/insights/status-batch.ts | 4 +- src/lib/insights/strain-score.ts | 17 +- src/lib/insights/stress-score.ts | 5 +- src/lib/insights/sub-page-metric.ts | 39 +- src/lib/insights/trend-chart-select.ts | 5 +- src/lib/insights/trend-descriptor.ts | 65 +- src/lib/insights/trend-sentiment.ts | 1 - src/lib/insights/weight-status.ts | 12 +- src/lib/integrations/__tests__/status.test.ts | 10 +- .../coach-message-cleanup-queue.test.ts | 5 +- src/lib/jobs/__tests__/coach-nudge.test.ts | 63 +- src/lib/jobs/__tests__/cycle-reminder.test.ts | 75 +- .../jobs/__tests__/fitbit-backfill.test.ts | 4 +- .../__tests__/insight-pregenerate.test.ts | 5 +- .../jobs/__tests__/intake-auto-skip.test.ts | 38 +- .../integration-status-null-probe.test.ts | 4 +- .../__tests__/lab-biomarker-backfill.test.ts | 6 +- .../mean-consolidation-queue.test.ts | 4 +- .../measurement-reminder-queue.test.ts | 12 +- .../measurement-tombstone-cleanup.test.ts | 4 +- .../medication-inventory-expire.test.ts | 4 +- .../__tests__/medication-low-stock.test.ts | 4 +- .../medication-reminder-check.test.ts | 3 +- src/lib/jobs/__tests__/mood-reminder.test.ts | 28 +- .../jobs/__tests__/nightscout-queues.test.ts | 4 +- .../__tests__/recovery-score-queue.test.ts | 11 +- .../jobs/__tests__/reminder-satisfy.test.ts | 27 +- .../__tests__/sleep-timeline-queues.test.ts | 4 +- .../stress-strain-retention-queue.test.ts | 6 +- .../__tests__/whoop-resource-sync.test.ts | 24 +- .../withings-oauth-state-cleanup.test.ts | 5 +- src/lib/jobs/apple-health-import-worker.ts | 4 +- src/lib/jobs/cycle-reminder.ts | 4 +- src/lib/jobs/insight-pregenerate.ts | 20 +- src/lib/jobs/intake-auto-skip.ts | 4 +- src/lib/jobs/measurement-reminder.ts | 19 +- src/lib/jobs/period-narrative-warm.ts | 16 +- src/lib/jobs/recovery-score.ts | 4 +- src/lib/jobs/reminder/mood-cycle-checks.ts | 4 +- .../reminder/register-integration-sync.ts | 3 +- src/lib/jobs/reminder/register-rollup.ts | 10 +- src/lib/jobs/reminder/register-status.ts | 6 +- src/lib/jobs/strain-score.ts | 7 +- src/lib/jobs/stress-score.ts | 4 +- .../labs/__tests__/biomarker-store.test.ts | 3 +- .../resolve-or-mint-biomarker.test.ts | 24 +- src/lib/labs/biomarker-catalog.ts | 208 +++- src/lib/logging/__tests__/redact.test.ts | 10 +- src/lib/logging/background.ts | 1 - .../__tests__/dto.test.ts | 4 +- .../__tests__/resolve.test.ts | 5 +- .../__tests__/satisfy.test.ts | 11 +- .../__tests__/scheduling.test.ts | 4 +- src/lib/measurement-reminders/satisfy.ts | 5 +- .../__tests__/apple-health-mapping.test.ts | 16 +- .../__tests__/consolidate-daily-mean.test.ts | 30 +- .../__tests__/cross-source-merge.test.ts | 5 +- .../dense-intraday-retention.test.ts | 10 +- .../__tests__/display-transform.test.ts | 7 +- .../drain-per-sample-cumulative.test.ts | 79 +- .../import-apple-health-export.test.ts | 114 +- .../pick-canonical-workout-rows.test.ts | 9 +- .../__tests__/range-aggregation.test.ts | 8 +- src/lib/measurements/apple-health-mapping.ts | 76 +- .../measurements/consolidate-daily-mean.ts | 4 +- .../measurements/consolidate-legacy-steps.ts | 1 - src/lib/measurements/consolidation-base.ts | 16 +- src/lib/measurements/daily-series-read.ts | 8 +- .../measurements/dense-intraday-retention.ts | 10 +- .../drain-per-sample-cumulative.ts | 4 +- .../hk-workout-activity-type-map.ts | 7 +- .../import-apple-health-export.ts | 67 +- .../pick-canonical-workout-rows.ts | 4 +- src/lib/measurements/range-aggregation.ts | 5 +- .../__tests__/glp1-knowledge-drift.test.ts | 141 ++- .../__tests__/glp1-knowledge.test.ts | 18 +- src/lib/medications/__tests__/glp1-pk.test.ts | 29 +- .../__tests__/injection-site-write.test.ts | 5 +- .../__tests__/injection-sites.test.ts | 10 +- .../intake-slot-dedup-inventory.test.ts | 10 +- .../__tests__/intake-slot-dedup-pin.test.ts | 4 +- .../medications/__tests__/lifecycle.test.ts | 10 +- .../__tests__/route-guards.test.ts | 6 +- src/lib/medications/category-label.ts | 5 +- src/lib/medications/glp1-knowledge.ts | 8 +- src/lib/medications/glp1-pk.ts | 11 +- .../inventory/__tests__/consumption.test.ts | 21 +- .../inventory/__tests__/state-machine.test.ts | 4 +- .../inventory/__tests__/summary.test.ts | 3 +- src/lib/medications/inventory/consumption.ts | 9 +- src/lib/medications/lifecycle.ts | 4 +- .../apply-canonical-slot-write.test.ts | 10 +- .../scheduling/__tests__/band-minter.test.ts | 52 +- .../scheduling/__tests__/cadence.test.ts | 9 +- .../scheduling/__tests__/compliance.test.ts | 23 +- .../scheduling/__tests__/dose-history.test.ts | 20 +- .../scheduling/__tests__/next-due.test.ts | 13 +- .../resolve-slot-by-band-eras.test.ts | 4 +- .../__tests__/resolve-slot-instant.test.ts | 90 +- .../__tests__/schedule-eras.test.ts | 24 +- .../__tests__/worker-helpers.test.ts | 4 +- .../scheduling/attribute-intake.ts | 5 +- src/lib/medications/scheduling/band-minter.ts | 26 +- src/lib/medications/scheduling/cadence.ts | 18 +- src/lib/medications/scheduling/compliance.ts | 10 +- .../medications/scheduling/dose-history.ts | 6 +- .../scheduling/project-today-intakes.ts | 9 +- src/lib/medications/scheduling/recurrence.ts | 4 +- .../scheduling/resolve-slot-instant.ts | 5 +- .../medications/scheduling/schedule-eras.ts | 22 +- src/lib/medications/scheduling/slot-upsert.ts | 5 +- .../__tests__/drift-guard.test.ts | 4 +- src/lib/medications/side-effects/taxonomy.ts | 12 +- .../medications/side-effects/validators.ts | 1 - .../medications/use-injection-site-prefs.ts | 4 +- src/lib/modules/__tests__/gate.test.ts | 60 +- src/lib/modules/operator-availability.ts | 6 +- src/lib/modules/registry.ts | 10 +- src/lib/mood/__tests__/custom-tags.test.ts | 73 +- src/lib/mood/__tests__/note-backfill.test.ts | 12 +- src/lib/mood/__tests__/tag-layout.test.ts | 11 +- src/lib/mood/__tests__/tag-links.test.ts | 9 +- src/lib/mood/icon-catalog.ts | 294 ++++- src/lib/mood/tag-layout.ts | 9 +- src/lib/moodlog/__tests__/push.test.ts | 22 +- src/lib/moodlog/push.ts | 6 +- src/lib/moodlog/sync.ts | 4 +- .../__tests__/stream-to-disk.test.ts | 58 +- src/lib/multipart/stream-to-disk.ts | 16 +- src/lib/nightscout/__tests__/client.test.ts | 4 +- src/lib/nightscout/__tests__/sync.test.ts | 9 +- src/lib/nightscout/sync.ts | 5 +- .../__tests__/web-push-dose-clear.test.ts | 4 +- src/lib/notifications/dispatch-localised.ts | 10 +- .../notifications/medication-intake-sync.ts | 4 +- .../senders/__tests__/apns.test.ts | 78 +- .../senders/__tests__/email.test.ts | 25 +- .../__tests__/push-attempt-record.test.ts | 5 +- .../senders/__tests__/urgency.test.ts | 37 +- src/lib/notifications/senders/web-push.ts | 7 +- src/lib/notifications/web-push-clear.ts | 6 +- src/lib/oauth/__tests__/signed-state.test.ts | 8 +- src/lib/onboarding/goals.ts | 4 +- src/lib/openapi/routes/coach.ts | 26 +- src/lib/openapi/routes/health-record.ts | 5 +- src/lib/openapi/routes/illness.ts | 6 +- src/lib/openapi/routes/import.ts | 10 +- src/lib/openapi/routes/insights.ts | 6 +- src/lib/openapi/routes/labs.ts | 12 +- .../openapi/routes/measurement-reminders.ts | 289 +++-- src/lib/openapi/routes/measurements.ts | 13 +- src/lib/openapi/routes/medications.ts | 16 +- src/lib/openapi/routes/mood.ts | 4 +- src/lib/openapi/routes/onboarding.ts | 10 +- src/lib/openapi/routes/profile.ts | 4 +- src/lib/oura/__tests__/client.test.ts | 38 +- src/lib/oura/client.ts | 29 +- src/lib/oura/credentials.ts | 4 +- src/lib/oura/sync.ts | 29 +- .../__tests__/pr-detection-worker.test.ts | 145 +-- .../personal-records/pr-detection-worker.ts | 6 +- src/lib/polar/__tests__/client.test.ts | 6 +- src/lib/polar/client.ts | 7 +- src/lib/polar/credentials.ts | 4 +- src/lib/polar/sync.ts | 4 +- src/lib/pwa/__tests__/query-persister.test.ts | 4 +- src/lib/pwa/__tests__/sw-cache-policy.test.ts | 5 +- src/lib/pwa/sw-cache-policy.ts | 3 +- .../queries/__tests__/retry-transient.test.ts | 6 +- .../__tests__/use-dashboard-snapshot.test.tsx | 12 +- .../use-medication-list-layout.test.ts | 22 +- .../use-medication-compliance-summary.ts | 4 +- src/lib/queries/use-medication-list-layout.ts | 9 +- src/lib/query-keys/coach.ts | 3 +- src/lib/query-keys/illness.ts | 3 +- src/lib/query-keys/measurement-reminders.ts | 9 +- src/lib/query-keys/measurements.ts | 4 +- src/lib/reference-ranges.ts | 162 ++- .../collapse-rollup-rows-by-source.test.ts | 25 +- .../__tests__/measurement-rollups.test.ts | 6 +- .../medication-compliance-rollups.test.ts | 4 +- .../rollups/__tests__/tiered-context.test.ts | 5 +- src/lib/rollups/measurement-rollups.ts | 10 +- .../rollups/medication-compliance-rollups.ts | 11 +- src/lib/rollups/mood-rollups.ts | 38 +- src/lib/rollups/tiered-context.ts | 36 +- .../__tests__/reconstruct-timeline.test.ts | 14 +- src/lib/sleep/reconstruct-timeline.ts | 6 +- .../__tests__/pick-canonical-workout.test.ts | 4 +- ...urce-priority-withings-applehealth.test.ts | 7 +- src/lib/sources/pick-canonical-workout.ts | 3 +- .../targets/__tests__/glucose-targets.test.ts | 12 +- src/lib/targets/__tests__/source-link.test.ts | 7 +- src/lib/targets/build-response.ts | 6 +- src/lib/targets/source-link.ts | 4 +- src/lib/tls/__tests__/leaf-spki.test.ts | 11 +- src/lib/tz/resolver.ts | 5 +- .../validations/__tests__/biomarkers.test.ts | 5 +- .../__tests__/entry-instant.test.ts | 20 +- .../validations/__tests__/measurement.test.ts | 5 +- .../__tests__/medication-inventory.test.ts | 38 +- src/lib/validations/__tests__/moodlog.test.ts | 3 +- .../__tests__/source-priority.test.ts | 27 +- src/lib/validations/__tests__/workout.test.ts | 20 +- src/lib/validations/consent.ts | 11 +- src/lib/validations/illness.ts | 8 +- src/lib/validations/invite.ts | 7 +- src/lib/validations/labs.ts | 11 +- src/lib/validations/measurement.ts | 18 +- src/lib/validations/medication.ts | 51 +- src/lib/validations/notifications.ts | 6 +- src/lib/validations/schedule-revision.ts | 14 +- src/lib/validations/series-batch.ts | 7 +- src/lib/validations/source-priority.ts | 39 +- src/lib/validations/workout.ts | 5 +- src/lib/whoop/__tests__/client.test.ts | 15 +- src/lib/whoop/__tests__/return-scheme.test.ts | 9 +- src/lib/whoop/__tests__/sync-body.test.ts | 5 +- src/lib/whoop/__tests__/sync.test.ts | 28 +- src/lib/whoop/client.ts | 36 +- src/lib/whoop/mapping.md | 72 +- src/lib/whoop/sync-body.ts | 6 +- src/lib/whoop/sync-recovery.ts | 6 +- src/lib/whoop/sync-workout.ts | 11 +- src/lib/whoop/sync.ts | 3 +- .../__tests__/response-classifier.test.ts | 40 +- src/lib/withings/__tests__/sync-sleep.test.ts | 5 +- src/lib/withings/client.ts | 4 +- src/lib/withings/webhook-handler.ts | 5 +- .../workouts/__tests__/canonical-rows.test.ts | 27 +- src/lib/workouts/canonical-rows.ts | 3 +- src/types/analytics.ts | 1 - .../admin-drain-cumulative-endpoint.test.ts | 15 +- .../integration/admin-reset-password.test.ts | 25 +- .../analytics-bp-aggregate-paged.test.ts | 4 +- .../integration/auth-password-change.test.ts | 6 +- .../bulk-delete-source-filter.test.ts | 133 ++- .../cache-invalidation-coverage.test.ts | 4 +- .../coach-budget-reservation.test.ts | 16 +- .../consolidate-legacy-steps.test.ts | 6 +- tests/integration/cycle-import-export.test.ts | 8 +- tests/integration/cycle-routes.test.ts | 10 +- .../dashboard-widgets-save.test.ts | 4 +- .../dense-intraday-retention.test.ts | 3 +- .../doctor-report-sections.test.ts | 7 +- ...fitbit-activity-sleep-workout-sync.test.ts | 5 +- tests/integration/fitbit-metrics-sync.test.ts | 3 +- .../insights-comprehensive-cache.test.ts | 13 +- .../insights-status-real-text.test.ts | 10 +- .../intake-scheduled-for-targeting.test.ts | 28 +- tests/integration/integration-status.test.ts | 4 +- tests/integration/measurement-rollups.test.ts | 13 +- .../measurement-soft-delete.test.ts | 8 +- tests/integration/measurements-batch.test.ts | 42 +- .../measurements-sleep-list.test.ts | 16 +- .../medication-cadence-worker.test.ts | 16 +- .../medication-weekly-compliance.test.ts | 42 +- .../medications-intake-bulk.test.ts | 18 +- .../mood-insights-crosstab.test.ts | 8 +- tests/integration/mood-tag-groups.test.ts | 23 +- .../partial-unique-live-rows.test.ts | 27 +- tests/integration/passkey-register.test.ts | 27 +- tests/integration/server-cache-routes.test.ts | 42 +- tests/integration/setup.ts | 12 +- tests/integration/sync-changes.test.ts | 14 +- tests/integration/sync-state.test.ts | 18 +- tests/integration/user-avatar.test.ts | 6 +- tests/integration/withings-oauth-flow.test.ts | 4 +- .../integration/workout-batch-create.test.ts | 5 +- tests/integration/workout-batch-race.test.ts | 4 +- 1000 files changed, 12130 insertions(+), 8385 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 979f94a36..4b7edb69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2333,6 +2333,7 @@ iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neithe ### Tests Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11: + - BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline` - BP with null readings ever → tile NOT emitted - Weight within 7 days → behaviour unchanged (regression guard) @@ -2698,7 +2699,7 @@ self-heal via boot-time backfill on first reach. card; not exposed via any route yet. - **`computeLongWindowSummary(userId, type, windowDays)`** in `summaries-slice.ts` — granularity-routed `count / min / max / - mean / sum` aggregate for long-window consumers. +mean / sum` aggregate for long-window consumers. - **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` / `readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with legacy-NULL fallback. @@ -2728,7 +2729,7 @@ rides the post-deploy window. - **`/api/measurements?groupBy=day` cumulative path:** consumes `sum_value` directly; eliminates per-type JS aggregation on `ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE / - TIME_IN_DAYLIGHT / ACTIVE_ENERGY`. +TIME_IN_DAYLIGHT / ACTIVE_ENERGY`. - **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing 425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes @@ -2785,10 +2786,10 @@ false and stranded the whole helper on live SQL across the full ### Fixed - **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS + - PULSE + WEIGHT`** — the three types the helper actually reads. +PULSE + WEIGHT`** — the three types the helper actually reads. `isFullyCovered(coverage)` AND-term dropped. - **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA`**. Same fix. +BLOOD_PRESSURE_DIA`**. Same fix. - **`health-score-fast-path` gates only on `WEIGHT`**. Same fix. Coverage-probe semantics unchanged. Each helper now consults the @@ -3095,7 +3096,7 @@ without changing existing behaviour. `aps.category = "MEDICATION_REMINDER"` on every medication-reminder payload so iOS renders the three action-buttons (Genommen / Snooze 15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content - = 1` is set by default so a future Notification Service Extension += 1` is set by default so a future Notification Service Extension can hook the payload without a server-side change. The med-reminder metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min" action pins to the schedule slot rather than wall-clock delivery @@ -3294,7 +3295,7 @@ dashboard SQL surfaced. list through a Zod `refine` chained on the existing `isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at the write boundary so the new `to_char(measured_at AT TIME ZONE - $tz)` SQL in the dashboard summary route cannot be self-DoS'd by +$tz)` SQL in the dashboard summary route cannot be self-DoS'd by a corrupted stored value. ### Tests @@ -3622,7 +3623,7 @@ surfaces city + country when the carrier lookup misses. buckets + a freshly logged WEIGHT type) falls back to the live aggregate so no type silently underreports. - **Daily chart fetches opt into rollup buckets.** `GET - /api/measurements?aggregate=daily&source=rollup` reads DAY buckets +/api/measurements?aggregate=daily&source=rollup` reads DAY buckets from `measurement_rollups` instead of scanning the measurements table when the window exceeds seven days. The Insights chart fetchers pass `source=rollup`; iOS and other clients are @@ -3689,7 +3690,7 @@ surfaces city + country when the carrier lookup misses. `pickCumulativeDaySum` helper, source-priority-aware in the same shape SLEEP_DURATION already used. - **Insights nav strict-gate** flipped (`if (!availability) return - true` → `return false`). Types without measurements no longer +true` → `return false`). Types without measurements no longer appear in the strip. - **About → Admin Console.** "Über HealthLog" moved out of the personal dropdown into a new `/admin/about` slug. The public @@ -3715,7 +3716,7 @@ surfaces city + country when the carrier lookup misses. ### Schema - **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL - DEFAULT '{}'`). Migration +DEFAULT '{}'`). Migration `0068_v1436_insights_exclude_metrics` is idempotent under re-run. ### Refactor @@ -3725,7 +3726,7 @@ surfaces city + country when the carrier lookup misses. `applyInsightsExcludeFilter`, `pickCumulativeDaySum`, `probeRollupCoverage`, `isFullyCovered`. Tested standalone. - `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed: - true, rollup_refresh_error: ... }` on failure instead of swallowing +true, rollup_refresh_error: ... }` on failure instead of swallowing silently. The read path still returns `{ recomputed: false }` so the response never fails because of a populator hiccup. - Cumulative metric-key lookup hoisted out of a four-level nested @@ -3787,7 +3788,7 @@ zero reader-path impact (serial concurrency, runs in the background). in place. The discovery query finds users with at least one row in `measurements` and zero rows in `measurement_rollups`, then sends one job per uncovered account with `singletonKey: - boot-backfill|{userId}` so a fast restart while a fold is queued +boot-backfill|{userId}` so a fast restart while a fold is queued doesn't pile up duplicates. - **Best-effort:** any error during discovery is captured in the return value and logged through `workerLog`; the worker boot never @@ -3802,7 +3803,7 @@ zero reader-path impact (serial concurrency, runs in the background). user lands on the queue when a third already-folded user co-exists; zero jobs enqueued when every account is already covered. - Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm - typecheck` + `pnpm lint` clean. +typecheck` + `pnpm lint` clean. ## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap @@ -3820,7 +3821,7 @@ across every analytics surface is planned for a follow-up. - **New table `measurement_rollups`** keyed on `(user_id, type, granularity, bucket_start)` with `count / mean / min_value / max_value / sd / slope / r2 / - computed_at`. Backed by a composite-descending index on +computed_at`. Backed by a composite-descending index on `(user_id, type, granularity, bucket_start DESC)`. Additive only — no `ALTER` on existing tables. Migration `0067_v1434_measurement_rollups` is idempotent under re-run. @@ -3896,8 +3897,8 @@ viewport-zoom fix surfaced during the mobile-deep pass. - `tests/integration/withings-oauth.test.ts` covers connect / callback / disconnect plus state-token replay rejection. - `tests/integration/passkey-register.test.ts` covers register-options - + register-verify + tampered-attestation rejection + challenge - single-use. + - register-verify + tampered-attestation rejection + challenge + single-use. - `tests/integration/cache-invalidation-coverage.test.ts` grids the v1.4.34 IW-G invalidation matrix across 5 write surfaces. - Integration suite goes from 197/50 to 222/55. tsc + lint clean. @@ -3969,7 +3970,7 @@ freeze. - **Focus rings on mobile + tablet navigation.** The bottom-nav primary links, the More overflow trigger, and the mobile top-bar user menu trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2 - focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard +focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard and switch-control users can now see focus on every nav surface. - **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px @@ -4203,7 +4204,7 @@ without leaking userIds. gate; Bearer tokens never elevate). - **`ImportJob` schema model + migration.** New Prisma model captures the per-upload state machine (`queued | unpacking | parsing | - upserting | done | failed`), the content-hash for idempotency +upserting | done | failed`), the content-hash for idempotency short-circuits, and the per-`MeasurementType` ingestion counters the status route surfaces back to the client. - **`lastSeenByType` on `/api/analytics`.** Both the slim @@ -4454,8 +4455,7 @@ the affected surfaces. `very_late`.** Defensive fallback when the dose-window estimator can't resolve a window. - **`/api/insights/generate` POST gating.** Endpoint now refuses - on a disabled assistant master flag instead of returning a - 500. + on a disabled assistant master flag instead of returning a 500. - **Mood Log overflow on narrow viewports.** Cards no longer shred their internal grid below 360 CSS px. - **F13 username readability.** Header username now respects @@ -4802,7 +4802,7 @@ permission picker and the future Insights nav. ### Added - **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId, - date)` mints `"stats::"` +date)` mints `"stats::"` alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is locked in the iOS coordination notes (§12). iOS emits one row per day per cumulative type via @@ -4885,11 +4885,12 @@ encounter them in read paths because no source writes them yet, and the codegen path will pick them up on the next iOS regeneration. **Cutover sequence.** v1.4.30 ships with the helper + drain script -+ server tolerance for both shapes. The next iOS TestFlight build -adopts `HealthKitStatisticsService.swift` and starts posting daily- -aggregated rows for the five cumulative types. Operator runs the -drain script once after the new TestFlight cuts over; per-sample -row pressure on `Measurement` drops 50-200× for cumulative types. + +- server tolerance for both shapes. The next iOS TestFlight build + adopts `HealthKitStatisticsService.swift` and starts posting daily- + aggregated rows for the five cumulative types. Operator runs the + drain script once after the new TestFlight cuts over; per-sample + row pressure on `Measurement` drops 50-200× for cumulative types. ## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix @@ -5146,7 +5147,7 @@ internal-only `/api/internal/web-vitals` beacon route). GLP-1 row painted with a brand icon and a middle-dot separator; every other row painted two-line without an icon. Both routes through a shared `` now — line 1 is `{name} - {dose}`, line 2 is the class label plus state badges. The GLP-1 +{dose}`, line 2 is the class label plus state badges. The GLP-1 outlier shape is gone. - **HealthScore card came up short below the action+prompts column.** The card now opts into `flex h-full flex-col` with the disclaimer @@ -5184,7 +5185,7 @@ internal-only `/api/internal/web-vitals` beacon route). - **Medications detail-page chrome collapsed to one heading scale.** Every section on `/medications/[id]` mounts through `` (`text-base font-semibold leading-6 - tracking-tight`). DrugLevelChart's standalone header migrates to +tracking-tight`). DrugLevelChart's standalone header migrates to `

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

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

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

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

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

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

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

). */} -

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

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

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

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

). */} +

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

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

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

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

{title}

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

{label}

+

{label}

) : null} -

+

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

-

+

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

-

+

{t("clinicianView.provenance")}

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

+

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

-

+

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

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

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

-

+

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

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

+

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

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

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

+ +
+ ) : calendarError ? ( +
+

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

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

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

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

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

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

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

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

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

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

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

- ) : ( - <> -

-

+ {stillLearning ? ( +

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

+ ) : ( + <> +

+

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

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

-

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

+

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

+

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

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

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

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

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

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

- {welcomeText} -

-

- {sentence} + {welcomeText}

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