From 832a8cf9fcd4d45a641a489a2962c1406e3061a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 14:10:52 +0200 Subject: [PATCH 1/5] chore(deps): Bump actions/upload-artifact from 4 to 7 (#163) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1ac9674fa..97af0f5ac 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -74,7 +74,7 @@ jobs: - name: Upload Playwright HTML report on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report path: playwright-report/ From e05273c6246738af76fa4b3cf7be02b26a176db5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 14:10:56 +0200 Subject: [PATCH 2/5] chore(deps): Bump actions/cache from 4 to 5 (#164) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 97af0f5ac..7ede691c6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -57,7 +57,7 @@ jobs: - run: pnpm db:migrate:deploy - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} From de0689e4192e3de0f84fab72dd842cb9bfbb4526 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 14:11:01 +0200 Subject: [PATCH 3/5] chore(deps): Bump actions/setup-node from 4 to 6 (#166) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependabot-auto-merge.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/integration.yml | 2 +- .github/workflows/security.yml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index ec2d54183..bf13c29a6 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -16,7 +16,7 @@ jobs: - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7ede691c6..8adb5672a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,7 +45,7 @@ jobs: - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 115491970..ff62802a3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -15,7 +15,7 @@ jobs: - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4ba396512..af3b6f30b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -19,7 +19,7 @@ jobs: - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" @@ -68,7 +68,7 @@ jobs: - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" From 17b8d8d2c262ecf0af3463a705a9d2a7810d1574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 23:49:32 +0200 Subject: [PATCH 4/5] chore(release): v1.4.25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta. --- .github/ISSUE_TEMPLATE/translation.yml | 81 + .github/workflows/docker-publish.yml | 227 +- .github/workflows/security.yml | 16 +- .planning/ROADMAP.md | 89 +- .planning/STATE.md | 38 +- .planning/phase-Fix-N-v1425-report.md | 142 + .../phase-FixG-v1425-ci-hotfix-report.md | 116 + ...se-FixH-v1425-integration-hotfix-report.md | 123 + ...phase-FixI-v1425-e2e-brittleness-report.md | 123 + .../phase-W11-v1425-release-prep-report.md | 151 + ...ase-W11a-v1425-multi-arch-docker-report.md | 164 + .planning/phase-W11b-demo-deploy-report.md | 191 ++ ...ase-W14a-v1425-openapi-hard-flip-report.md | 86 + .planning/phase-W14b-Content-v1425-report.md | 272 ++ .../phase-W14b-Foundation-v1425-report.md | 216 ++ ...-W14c-v1425-native-coach-prompts-report.md | 188 ++ .planning/phase-W15-v1425-hygiene-report.md | 312 ++ ...6a-v1425-schema-logic-quick-wins-report.md | 169 + .../phase-W16b-v1425-workout-ingest-report.md | 228 ++ .../phase-W16c-v1425-pr-detection-report.md | 59 + ...7a-v1425-withings-webhook-secret-report.md | 146 + ...bc-v1425-withings-activity-sleep-report.md | 62 + ...phase-W18-v1425-low-care-cluster-report.md | 262 ++ ...-W19a-v1425-glp1-knowledge-layer-report.md | 73 + .../phase-W19b-v1425-pen-inventory-report.md | 44 + .planning/phase-W19c-Backend-v1425-report.md | 252 ++ .planning/phase-W19c-Frontend-v1425-report.md | 369 ++ .planning/phase-W19c-Safety-v1425-report.md | 354 ++ .planning/phase-W19d-v1425-report.md | 178 + .planning/phase-W19e-v1425-report.md | 206 ++ .planning/phase-W19f-v1425-report.md | 212 ++ .planning/phase-W2-v1425-ci-fix-report.md | 151 + .../phase-W20-rest-v1425-p6-polish-report.md | 151 + ...W20a-v1425-dashboard-tile-polish-report.md | 134 + .planning/phase-W21-reconcile-plan.md | 354 ++ .../phase-W22-v1425-release-redo-report.md | 97 + .../phase-W3-v1425-insights-polish-report.md | 223 ++ ...ase-W3e-v1425-zielwerte-redesign-report.md | 290 ++ .../phase-W3f-v1425-polish-wins-report.md | 232 ++ ...hase-W4-v1425-insights-sub-pages-report.md | 153 + ...tests-v1425-glp1-component-tests-report.md | 203 ++ .planning/phase-W4d-v1425-glp1-full-report.md | 151 + .../phase-W5-v1425-coach-polish-report.md | 185 ++ .../phase-W5b-v1425-token-leak-report.md | 193 ++ .../phase-W6-v1425-dashboard-fixes-report.md | 208 ++ ...-W6c-v1425-doctor-report-toggles-report.md | 82 + .planning/phase-W7-v1425-timezone-report.md | 270 ++ ...hase-W7b-v1425-timezone-followup-report.md | 191 ++ ...hase-W7d-v1425-dev-server-repair-report.md | 160 + ...hase-W7e-v1425-dead-code-cleanup-report.md | 208 ++ ...-W8-v1425-cross-page-consistency-report.md | 177 + ...hase-W8b-v1425-admin-login-fixes-report.md | 200 ++ ...c-v1425-source-priority-two-axis-report.md | 140 + .../phase-W8d-v1425-ah-server-prep-report.md | 204 ++ ...8e-v1425-health-score-provenance-report.md | 111 + .../phase-W9-v1425-repo-polish-report.md | 342 ++ .../phase-W9e-v1425-translations-report.md | 260 ++ .../research/apple-health-ecosystem-scan.md | 246 ++ .../research/apple-health-sync-deep-dive.md | 746 +++++ .../research/glp1-feature-inspiration.md | 756 +++++ .planning/research/glp1-injection-tracking.md | 427 +++ .../research/health-score-provenance-ux.md | 539 +++ .planning/research/insights-sub-pages-ux.md | 831 +++++ .../research/open-wearables-comparison.md | 283 ++ .../research/source-priority-two-axis.md | 426 +++ .../research/w10-code-review-findings.md | 80 + .../research/w10-dead-code-candidates.md | 514 +++ .../research/w10-design-review-findings.md | 117 + .planning/research/w10-i18n-runtime-gaps.md | 258 ++ .../research/w10-product-lead-assessment.md | 381 +++ .../research/w10-reconcile-A-design-report.md | 122 + .../w10-reconcile-B-architecture-report.md | 177 + .../w10-reconcile-C-security-auth-report.md | 61 + .../w10-reconcile-D-simplifier-report.md | 72 + .../research/w10-security-review-findings.md | 138 + .planning/research/w10-senior-dev-findings.md | 287 ++ .planning/research/w10-simplifier-findings.md | 339 ++ .planning/research/w14b-onboarding-rebuild.md | 462 +++ .../research/w14c-native-coach-prompts.md | 514 +++ .planning/research/w16b-workout-ingest.md | 441 +++ .planning/research/w16c-pr-detection.md | 235 ++ .../w17b-c-withings-activity-sleep.md | 398 +++ .../research/w21-code-review-findings.md | 144 + .planning/research/w21-dead-code-findings.md | 317 ++ .planning/research/w21-design-findings.md | 335 ++ .../research/w21-i18n-runtime-findings.md | 324 ++ .../research/w21-product-lead-assessment.md | 349 ++ .planning/research/w21-security-findings.md | 380 +++ .planning/research/w21-senior-dev-findings.md | 344 ++ .planning/research/w21-simplifier-findings.md | 591 ++++ .../research/w8d-implementation-outline.md | 631 ++++ .planning/research/withings-api-coverage.md | 383 +++ .../research/withings-plus-comparison.md | 383 +++ .planning/research/zielwerte-redesign.md | 827 +++++ .planning/v1425-handoff-session-2.md | 275 ++ .planning/v1425-handoff.md | 258 ++ .planning/v15-ios-handoff/00-philosophy.md | 187 ++ .planning/v15-ios-handoff/01-repo-tour.md | 283 ++ .../v15-ios-handoff/02-server-architecture.md | 511 +++ .planning/v15-ios-handoff/03-api-contracts.md | 1057 ++++++ .planning/v15-ios-handoff/04-data-model.md | 589 ++++ .planning/v15-ios-handoff/05-auth-flows.md | 382 +++ .../06-ios-responsibilities.md | 398 +++ .../07-server-responsibilities.md | 364 ++ .../v15-ios-handoff/08-locked-contracts.md | 474 +++ .../v15-ios-handoff/09-recommended-flow.md | 622 ++++ .../v15-ios-handoff/10-research-pointers.md | 369 ++ .planning/v15-ios-handoff/11-web-ui-tour.md | 545 +++ .planning/v15-ios-handoff/12-design-system.md | 357 ++ .../v15-ios-handoff/13-state-management.md | 429 +++ .../v15-ios-handoff/14-coach-mental-model.md | 413 +++ .../15-insights-architecture.md | 347 ++ .../v15-ios-handoff/16-health-score-logic.md | 290 ++ .../v15-ios-handoff/17-error-handling.md | 339 ++ .../v15-ios-handoff/18-pattern-cookbook.md | 508 +++ .planning/v15-ios-handoff/19-i18n-system.md | 343 ++ .planning/v15-ios-handoff/20-glossary.md | 334 ++ .planning/v15-ios-handoff/README.md | 157 + CHANGELOG.md | 758 +++++ CONTRIBUTING.md | 39 + README.md | 10 +- docs/api/openapi.yaml | 1465 +++++++- docs/audit/v1423-summary.md | 110 + e2e/chart-overlay-controls.spec.ts | 17 +- e2e/charts-mobile.spec.ts | 58 +- e2e/dashboard.spec.ts | 90 +- e2e/locale-switch.spec.ts | 2 +- messages/de.json | 1041 +++--- messages/en.json | 1041 +++--- messages/es.json | 2955 +++++++++++++++++ messages/fr.json | 2955 +++++++++++++++++ messages/it.json | 2955 +++++++++++++++++ messages/pl.json | 2955 +++++++++++++++++ next.config.ts | 10 + package.json | 2 +- .../0043_per_user_timezone/migration.sql | 23 + .../0044_mood_entry_tz_column/migration.sql | 22 + .../0045_doctor_report_prefs/migration.sql | 24 + .../0046_glp1_dose_history/migration.sql | 127 + .../migration.sql | 24 + .../0048_user_source_priority/migration.sql | 25 + .../0049_withings_full_metrics/migration.sql | 25 + .../migration.sql | 20 + .../migration.sql | 13 + .../migration.sql | 30 + .../0053_workout_and_route/migration.sql | 69 + .../0054_personal_record/migration.sql | 50 + .../migration.sql | 35 + .../migration.sql | 72 + .../0057_user_onboarding_step/migration.sql | 28 + .../0058_user_research_mode/migration.sql | 35 + .../0059_medication_side_effect/migration.sql | 79 + .../migration.sql | 25 + prisma/schema.prisma | 967 ++++-- scripts/check-openapi.ts | 6 +- scripts/i18n/build-locale.py | 404 +++ src/app/__tests__/insights-polish.test.ts | 39 +- .../__tests__/targets-coach-mount.test.tsx | 133 + src/app/__tests__/targets-i18n.test.tsx | 46 +- src/app/__tests__/targets-responsive.test.tsx | 133 + src/app/__tests__/targets-spacing.test.tsx | 52 +- src/app/__tests__/targets-sparkline.test.tsx | 123 - .../admin/settings/__tests__/route.test.ts | 71 + src/app/api/admin/settings/route.ts | 33 + .../api/admin/status/__tests__/route.test.ts | 17 + src/app/api/admin/status/route.ts | 7 + src/app/api/analytics/route.ts | 342 +- .../__tests__/route.test.ts | 191 ++ .../api/auth/me/doctor-report-prefs/route.ts | 112 + .../me/research-mode/__tests__/route.test.ts | 304 ++ src/app/api/auth/me/research-mode/route.ts | 218 ++ .../source-priority/__tests__/route.test.ts | 138 + src/app/api/auth/me/source-priority/route.ts | 94 + .../auth/me/timezone/__tests__/route.test.ts | 114 + src/app/api/auth/me/timezone/route.ts | 78 + src/app/api/auth/register/route.ts | 23 +- src/app/api/dashboard/glp1/route.ts | 203 ++ src/app/api/dashboard/summary/route.ts | 57 +- src/app/api/dashboard/widgets/route.ts | 25 +- .../api/doctor-report/availability/route.ts | 121 + src/app/api/doctor-report/pdf/route.ts | 31 +- src/app/api/doctor-report/route.ts | 17 +- src/app/api/export/measurements/route.ts | 14 +- src/app/api/export/medications/route.ts | 36 +- src/app/api/export/mood/route.ts | 14 +- src/app/api/export/route.ts | 14 +- src/app/api/insights/chat/route.ts | 26 +- src/app/api/insights/general-status/route.ts | 32 - src/app/api/insights/generate/route.ts | 17 +- src/app/api/insights/glp1-timeline/route.ts | 169 + src/app/api/insights/targets/route.ts | 742 ++++- .../batch/__tests__/pr-detection-hook.test.ts | 119 + src/app/api/measurements/batch/route.ts | 136 +- .../api/measurements/by-external-ids/route.ts | 107 + src/app/api/measurements/route.ts | 11 +- .../medications/[id]/api-endpoint/route.ts | 14 +- .../[id]/cadence/__tests__/route.test.ts | 168 + src/app/api/medications/[id]/cadence/route.ts | 144 + .../api/medications/[id]/compliance/route.ts | 7 +- .../[id]/glp1/__tests__/route.test.ts | 337 ++ src/app/api/medications/[id]/glp1/route.ts | 220 ++ src/app/api/medications/[id]/intake/route.ts | 64 +- .../[id]/inventory/[itemId]/route.ts | 183 + .../[id]/inventory/__tests__/route.test.ts | 351 ++ .../api/medications/[id]/inventory/route.ts | 123 + .../medications/[id]/phase-config/route.ts | 31 +- src/app/api/medications/[id]/route.ts | 14 +- .../[id]/side-effects/[logId]/route.ts | 72 + .../[id]/side-effects/__tests__/route.test.ts | 352 ++ .../medications/[id]/side-effects/route.ts | 163 + .../[id]/titration/__tests__/route.test.ts | 227 ++ .../api/medications/[id]/titration/route.ts | 136 + src/app/api/medications/intake/route.ts | 22 +- src/app/api/medications/route.ts | 12 +- src/app/api/mood-entries/[id]/route.ts | 16 +- src/app/api/mood-entries/route.ts | 15 +- src/app/api/onboarding/__tests__/step.test.ts | 251 ++ src/app/api/onboarding/step/route.ts | 178 + .../personal-records/__tests__/route.test.ts | 161 + src/app/api/personal-records/route.ts | 75 + src/app/api/withings/callback/route.ts | 12 +- src/app/api/withings/disconnect/route.ts | 19 +- src/app/api/withings/status/route.ts | 10 +- .../webhook/[token]/__tests__/route.test.ts | 214 ++ src/app/api/withings/webhook/[token]/route.ts | 83 + .../withings/webhook/__tests__/route.test.ts | 172 +- src/app/api/withings/webhook/route.ts | 173 +- .../workouts/__tests__/batch-create.test.ts | 401 +++ src/app/api/workouts/batch/route.ts | 503 +++ src/app/auth/register/page.tsx | 9 +- src/app/globals.css | 30 +- src/app/insights/blutdruck/page.tsx | 95 + src/app/insights/bmi/page.tsx | 95 + src/app/insights/gewicht/page.tsx | 73 + src/app/insights/layout.tsx | 21 + src/app/insights/medikamente/page.tsx | 268 ++ src/app/insights/page.tsx | 1717 +--------- src/app/insights/puls/page.tsx | 134 + src/app/insights/schlaf/page.tsx | 25 + src/app/insights/stimmung/page.tsx | 102 + src/app/layout.tsx | 2 +- src/app/medications/[id]/history/page.tsx | 61 +- src/app/medications/page.tsx | 61 +- src/app/notifications/page.tsx | 11 +- src/app/onboarding/[step]/page.tsx | 178 + src/app/onboarding/__tests__/page.test.tsx | 159 +- src/app/onboarding/layout.tsx | 15 + src/app/onboarding/page.tsx | 505 +-- src/app/page.tsx | 74 +- src/app/settings/[section]/page.tsx | 2 + src/app/targets/page.tsx | 782 +---- .../__tests__/coach-feedback-section.test.tsx | 30 + .../__tests__/login-overview-csv.test.ts | 258 ++ .../login-overview-no-collapse.test.tsx | 94 + .../login-overview-provider.test.tsx | 68 + src/components/admin/_shared.tsx | 187 ++ src/components/admin/admin-shell.tsx | 17 +- .../admin/coach-feedback-section.tsx | 205 +- .../admin/general-settings-section.tsx | 47 +- .../admin/login-overview-section.tsx | 595 ++-- .../__tests__/chart-overlay-controls.test.tsx | 132 + .../health-chart-vertical-markers.test.ts | 105 + .../trend-card-baseline-alignment.test.tsx | 221 ++ .../charts/chart-overlay-controls.tsx | 239 +- src/components/charts/compliance-charts.tsx | 145 - .../charts/compliance-line-chart.tsx | 21 +- src/components/charts/health-chart.tsx | 198 +- .../charts/medication-compliance-chart.tsx | 55 +- src/components/charts/mood-chart.tsx | 59 +- .../charts/scatter-correlation-chart.tsx | 6 +- src/components/charts/trend-card.tsx | 44 +- .../dashboard/__tests__/glp1-tile.test.tsx | 302 ++ src/components/dashboard/glp1-tile.tsx | 338 ++ .../__tests__/doctor-report-dialog.test.tsx | 30 + .../doctor-report/doctor-report-dialog.tsx | 333 +- .../__tests__/maintainership-banner.test.tsx | 126 + src/components/i18n/maintainership-banner.tsx | 124 + .../health-score-card-provenance.test.tsx | 377 +++ .../insights/__tests__/hero-strip.test.tsx | 28 +- .../__tests__/insights-page-hero.test.tsx | 107 - .../__tests__/personal-record-badge.test.tsx | 109 + .../sleep-stage-stacked-bar.test.tsx | 179 + .../__tests__/sub-page-shell.test.tsx | 53 + .../insights/__tests__/trends-row.test.tsx | 14 +- .../__tests__/vo2-max-chart-row.test.tsx | 127 + .../__tests__/coach-input.test.tsx | 74 +- .../__tests__/coach-settings-sheet.test.tsx | 10 + .../__tests__/message-thread.test.tsx | 151 +- .../insights/coach-panel/coach-drawer.tsx | 177 +- .../insights/coach-panel/coach-input.tsx | 121 +- .../coach-panel/coach-settings-sheet.tsx | 51 + .../insights/coach-panel/message-thread.tsx | 114 +- .../insights/coach-panel/use-coach.ts | 78 +- src/components/insights/daily-briefing.tsx | 14 +- src/components/insights/health-score-card.tsx | 344 +- src/components/insights/hero-strip.tsx | 55 +- .../insights/insight-advisor-card.tsx | 11 +- src/components/insights/insights-card.tsx | 3 +- .../insights/insights-layout-shell.tsx | 36 + .../insights/insights-page-hero.tsx | 170 - .../insights/insights-tab-strip.tsx | 181 + .../insights/personal-record-badge.tsx | 147 + .../insights/recommendation-card.tsx | 15 +- .../insights/sleep-duration-chart.tsx | 54 + src/components/insights/sleep-overview.tsx | 151 + .../insights/sleep-stage-stacked-bar.tsx | 352 ++ src/components/insights/sub-page-shell.tsx | 93 + src/components/insights/therapy-timeline.tsx | 201 ++ src/components/insights/trend-annotation.tsx | 5 +- src/components/insights/trends-row.tsx | 27 +- src/components/insights/vo2-max-chart-row.tsx | 223 ++ .../insights/weekly-report-view.tsx | 13 +- src/components/layout/auth-shell.tsx | 24 +- src/components/layout/top-bar.tsx | 7 +- .../measurements/measurement-list-meta.ts | 53 + src/components/medications/DrugLevelChart.tsx | 537 +++ .../ResearchModeAcknowledgmentDialog.tsx | 280 ++ .../medications/SchedulingSection.tsx | 314 ++ .../medications/SideEffectsSection.tsx | 486 +++ .../medications/TitrationSection.tsx | 235 ++ .../__tests__/DrugLevelChart.test.tsx | 339 ++ .../ResearchModeAcknowledgmentDialog.test.tsx | 287 ++ .../__tests__/SchedulingSection.test.tsx | 195 ++ .../__tests__/SideEffectsSection.test.tsx | 213 ++ .../__tests__/TitrationSection.test.tsx | 221 ++ .../__tests__/injection-site-picker.test.tsx | 237 ++ .../__tests__/medication-card-glp1.test.tsx | 480 +++ .../medications/glp1-medication-card.tsx | 515 +++ .../medications/injection-site-picker.tsx | 209 ++ .../medications/intake-timeline.tsx | 159 - .../medications/inventory-section.tsx | 509 +++ .../medications/medication-card.tsx | 8 + .../medications/medication-detail-section.tsx | 73 + .../medications/medication-form.tsx | 182 +- src/components/mood/mood-form.tsx | 65 + src/components/onboarding/BaselineForm.tsx | 251 ++ src/components/onboarding/DoneScreen.tsx | 56 + src/components/onboarding/GoalsChipPicker.tsx | 233 ++ src/components/onboarding/OnboardingShell.tsx | 168 + src/components/onboarding/SourceCardGrid.tsx | 312 ++ src/components/onboarding/WelcomeCarousel.tsx | 297 ++ .../__tests__/advanced-research-mode.test.tsx | 191 ++ .../__tests__/settings-shell.test.tsx | 7 +- src/components/settings/account-section.tsx | 52 +- src/components/settings/advanced-section.tsx | 181 +- .../settings/dashboard-layout-section.tsx | 2 + src/components/settings/export-section.tsx | 10 +- .../settings/integrations-section.tsx | 32 + .../settings/notification-status-card.tsx | 19 +- .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 1 + src/components/settings/settings-shell.tsx | 18 +- src/components/settings/sources-section.tsx | 566 ++++ src/components/settings/timezone-picker.tsx | 123 + .../__tests__/consistency-strip.test.tsx | 75 + .../targets/__tests__/range-bar.test.tsx | 60 + .../targets/__tests__/target-card.test.tsx | 180 + .../__tests__/target-coach-button.test.tsx | 45 + .../__tests__/target-edit-sheet.test.tsx | 198 ++ .../__tests__/targets-summary-header.test.tsx | 63 + src/components/targets/consistency-strip.tsx | 145 + src/components/targets/range-bar.tsx | 176 + src/components/targets/target-card.tsx | 707 ++++ .../targets/target-coach-button.tsx | 75 + src/components/targets/target-edit-sheet.tsx | 463 +++ .../targets/targets-summary-header.tsx | 91 + src/hooks/use-coach-handoff.ts | 52 + src/hooks/use-insight-status.ts | 82 + src/hooks/use-insights-layout-prefs.ts | 42 + .../api-handler-safe-request-prop.test.ts | 164 + src/lib/__tests__/export.test.ts | 106 +- src/lib/__tests__/format-locale.test.ts | 36 +- .../__tests__/i18n-locale-integrity.test.ts | 230 +- .../measurement-type-enum-coverage.test.ts | 33 +- src/lib/__tests__/query-keys.test.ts | 4 +- src/lib/__tests__/require-auth-bearer.test.ts | 59 +- .../ai/__tests__/coach-prompt-v423.test.ts | 56 +- .../ai/coach/__tests__/glp1-snapshot.test.ts | 137 + .../ai/coach/__tests__/system-prompt.test.ts | 18 +- .../ai/coach/__tests__/target-prompts.test.ts | 129 + src/lib/ai/coach/glp1-snapshot.ts | 421 +++ src/lib/ai/coach/snapshot.ts | 161 +- src/lib/ai/coach/system-prompt.ts | 195 +- src/lib/ai/coach/target-prompts.ts | 117 + src/lib/ai/coach/target-scope.ts | 48 + .../__tests__/coach-prompt-locale.test.ts | 247 ++ .../drug-level-refusal.probe.test.ts | 264 ++ .../prompts/__tests__/refusal-probe.test.ts | 540 +++ .../__tests__/safety-contracts-parity.test.ts | 152 + src/lib/ai/prompts/base-system.ts | 3 - src/lib/ai/prompts/insight-generator.ts | 133 +- src/lib/ai/prompts/native-prompts.ts | 535 +++ src/lib/ai/prompts/safety-contracts.de.yaml | 357 ++ src/lib/ai/prompts/safety-contracts.en.yaml | 432 +++ src/lib/ai/prompts/safety-contracts.es.yaml | 357 ++ src/lib/ai/prompts/safety-contracts.fr.yaml | 360 ++ src/lib/ai/prompts/safety-contracts.it.yaml | 361 ++ src/lib/ai/prompts/safety-contracts.pl.yaml | 362 ++ src/lib/ai/prompts/safety-contracts.ts | 232 ++ src/lib/ai/schema.ts | 7 + .../analytics/__tests__/health-score.test.ts | 146 + .../__tests__/source-priority.test.ts | 542 +++ src/lib/analytics/health-score.ts | 204 +- src/lib/analytics/source-priority.ts | 264 ++ src/lib/api-handler.ts | 138 +- src/lib/api-response.ts | 24 +- src/lib/api/__tests__/read-error.test.ts | 48 + src/lib/api/read-error.ts | 27 + .../comparison-shift-edge-cases.test.ts | 225 ++ .../charts/__tests__/x-axis-density.test.ts | 114 +- src/lib/charts/x-axis-density.ts | 154 +- src/lib/dashboard-layout.ts | 15 + src/lib/doctor-report-data.ts | Bin 12588 -> 20585 bytes src/lib/doctor-report-pdf-core.ts | 159 +- src/lib/export.ts | 91 +- src/lib/format-locale.ts | 25 +- .../i18n/__tests__/fallback-chain.test.tsx | 46 + src/lib/i18n/config.ts | 19 +- src/lib/i18n/context.tsx | 8 + src/lib/i18n/server-translator.ts | 8 + .../insights/__tests__/chart-tokens.test.ts | 118 + .../insights/__tests__/correlations.test.ts | 101 +- .../insights/__tests__/glp1-plateau.test.ts | 323 ++ src/lib/insights/chart-tokens.ts | 90 + src/lib/insights/correlations.ts | 132 +- src/lib/insights/glp1-plateau.ts | 160 + src/lib/insights/no-key-fallbacks.ts | 14 +- src/lib/insights/prompt.ts | 3 - src/lib/insights/sub-page-metric.ts | 51 + .../medication-inventory-expire.test.ts | 24 + src/lib/jobs/__tests__/pr-detection.test.ts | 53 + .../jobs/__tests__/withings-queues.test.ts | 70 + src/lib/jobs/medication-inventory-expire.ts | 34 + src/lib/jobs/pr-detection.ts | 72 + src/lib/jobs/reminder-worker.ts | 266 ++ .../logging/__tests__/event-builder.test.ts | 112 + src/lib/logging/__tests__/redact.test.ts | 73 +- src/lib/logging/context.ts | 10 + src/lib/logging/event-builder.ts | 15 +- src/lib/logging/redact.ts | 46 +- .../__tests__/apple-health-mapping.test.ts | 145 + src/lib/measurements/apple-health-mapping.ts | 222 +- .../medications/__tests__/dose-string.test.ts | 55 + .../__tests__/glp1-knowledge-drift.test.ts | 233 ++ .../__tests__/glp1-knowledge.test.ts | 306 ++ src/lib/medications/__tests__/glp1-pk.test.ts | 194 ++ .../__tests__/injection-sites.test.ts | 81 + .../__tests__/research-mode-types.test.ts | 69 + .../__tests__/route-guards.test.ts | 70 + src/lib/medications/dose-string.ts | 38 + src/lib/medications/glp1-knowledge.ts | 609 ++++ src/lib/medications/glp1-pk.ts | 338 ++ src/lib/medications/injection-sites.ts | 112 + .../inventory/__tests__/service.test.ts | 202 ++ .../inventory/__tests__/state-machine.test.ts | 255 ++ src/lib/medications/inventory/service.ts | 156 + .../medications/inventory/state-machine.ts | 243 ++ src/lib/medications/research-mode-types.ts | 45 + src/lib/medications/route-guards.ts | 62 + .../scheduling/__tests__/cadence.test.ts | 458 +++ .../scheduling/__tests__/compliance.test.ts | 207 ++ src/lib/medications/scheduling/cadence.ts | 439 +++ src/lib/medications/scheduling/compliance.ts | 162 + .../__tests__/drift-guard.test.ts | 115 + .../side-effects/__tests__/taxonomy.test.ts | 185 ++ src/lib/medications/side-effects/taxonomy.ts | 196 ++ .../medications/side-effects/validators.ts | 77 + .../titration/__tests__/ladder.test.ts | 198 ++ src/lib/medications/titration/ladder.ts | 201 ++ src/lib/mood/__tests__/date-key.test.ts | 101 + src/lib/mood/date-key.ts | 59 + .../__tests__/dispatcher.test.ts | 74 + src/lib/notifications/dispatcher.ts | 17 +- src/lib/notifications/types.ts | 25 + src/lib/openapi/registry.ts | 6 +- src/lib/openapi/routes.ts | 175 +- .../__tests__/pr-detection-worker.test.ts | 758 +++++ .../__tests__/pr-direction.test.ts | 85 + .../personal-records/pr-detection-worker.ts | 465 +++ src/lib/personal-records/pr-direction.ts | 93 + src/lib/query-keys.ts | 19 +- .../__tests__/pick-canonical-workout.test.ts | 217 ++ ...urce-priority-withings-applehealth.test.ts | 329 ++ src/lib/sources/pick-canonical-workout.ts | 245 ++ .../__tests__/mood-stability-label.test.ts | 28 + src/lib/targets/mood-stability-label.ts | 24 + src/lib/time-window-format.ts | 14 +- src/lib/tz/__tests__/resolver.test.ts | 268 ++ src/lib/tz/format.ts | 217 ++ src/lib/tz/resolver.ts | 149 + .../validations/__tests__/coach-prefs.test.ts | 27 + .../__tests__/doctor-report-prefs.test.ts | 97 + .../validations/__tests__/measurement.test.ts | 42 + .../__tests__/source-priority.test.ts | 372 +++ src/lib/validations/__tests__/workout.test.ts | 301 ++ src/lib/validations/admin.ts | 5 + src/lib/validations/auth.ts | 10 + src/lib/validations/coach-prefs.ts | 25 + src/lib/validations/doctor-report-prefs.ts | 99 + src/lib/validations/measurement.ts | 76 + src/lib/validations/medication.ts | 130 + src/lib/validations/source-priority.ts | 417 +++ src/lib/validations/workout.ts | 181 + src/lib/withings/__tests__/client.test.ts | 378 +++ .../withings/__tests__/sync-activity.test.ts | 257 ++ src/lib/withings/__tests__/sync-sleep.test.ts | 256 ++ src/lib/withings/__tests__/sync.test.ts | 58 + src/lib/withings/client.ts | 83 +- src/lib/withings/mapping.md | 91 + src/lib/withings/sync-activity.ts | 287 ++ src/lib/withings/sync-sleep.ts | 250 ++ src/lib/withings/sync.ts | 117 +- src/lib/withings/webhook-handler.ts | 209 ++ .../integration/analytics-weekday-tz.test.ts | 219 ++ tests/integration/coach-prefs.test.ts | 23 +- .../dashboard-widgets-save.test.ts | 286 ++ .../doctor-report-sections.test.ts | 241 ++ .../measurements-batch-delete.test.ts | 210 ++ tests/integration/measurements-batch.test.ts | 130 +- .../pr-detection-end-to-end.test.ts | 188 ++ tests/integration/setup.ts | 3 + .../source-priority-two-axis.test.ts | 263 ++ tests/integration/timezone-per-user.test.ts | 443 +++ .../withings-activity-sync.test.ts | 115 + .../withings-sleep-stage-composite.test.ts | 173 + tests/integration/withings-sleep-sync.test.ts | 123 + .../integration/workout-batch-create.test.ts | 409 +++ tests/integration/workout-batch-race.test.ts | 220 ++ 528 files changed, 104033 insertions(+), 6524 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/translation.yml create mode 100644 .planning/phase-Fix-N-v1425-report.md create mode 100644 .planning/phase-FixG-v1425-ci-hotfix-report.md create mode 100644 .planning/phase-FixH-v1425-integration-hotfix-report.md create mode 100644 .planning/phase-FixI-v1425-e2e-brittleness-report.md create mode 100644 .planning/phase-W11-v1425-release-prep-report.md create mode 100644 .planning/phase-W11a-v1425-multi-arch-docker-report.md create mode 100644 .planning/phase-W11b-demo-deploy-report.md create mode 100644 .planning/phase-W14a-v1425-openapi-hard-flip-report.md create mode 100644 .planning/phase-W14b-Content-v1425-report.md create mode 100644 .planning/phase-W14b-Foundation-v1425-report.md create mode 100644 .planning/phase-W14c-v1425-native-coach-prompts-report.md create mode 100644 .planning/phase-W15-v1425-hygiene-report.md create mode 100644 .planning/phase-W16a-v1425-schema-logic-quick-wins-report.md create mode 100644 .planning/phase-W16b-v1425-workout-ingest-report.md create mode 100644 .planning/phase-W16c-v1425-pr-detection-report.md create mode 100644 .planning/phase-W17a-v1425-withings-webhook-secret-report.md create mode 100644 .planning/phase-W17bc-v1425-withings-activity-sleep-report.md create mode 100644 .planning/phase-W18-v1425-low-care-cluster-report.md create mode 100644 .planning/phase-W19a-v1425-glp1-knowledge-layer-report.md create mode 100644 .planning/phase-W19b-v1425-pen-inventory-report.md create mode 100644 .planning/phase-W19c-Backend-v1425-report.md create mode 100644 .planning/phase-W19c-Frontend-v1425-report.md create mode 100644 .planning/phase-W19c-Safety-v1425-report.md create mode 100644 .planning/phase-W19d-v1425-report.md create mode 100644 .planning/phase-W19e-v1425-report.md create mode 100644 .planning/phase-W19f-v1425-report.md create mode 100644 .planning/phase-W2-v1425-ci-fix-report.md create mode 100644 .planning/phase-W20-rest-v1425-p6-polish-report.md create mode 100644 .planning/phase-W20a-v1425-dashboard-tile-polish-report.md create mode 100644 .planning/phase-W21-reconcile-plan.md create mode 100644 .planning/phase-W22-v1425-release-redo-report.md create mode 100644 .planning/phase-W3-v1425-insights-polish-report.md create mode 100644 .planning/phase-W3e-v1425-zielwerte-redesign-report.md create mode 100644 .planning/phase-W3f-v1425-polish-wins-report.md create mode 100644 .planning/phase-W4-v1425-insights-sub-pages-report.md create mode 100644 .planning/phase-W4d-tests-v1425-glp1-component-tests-report.md create mode 100644 .planning/phase-W4d-v1425-glp1-full-report.md create mode 100644 .planning/phase-W5-v1425-coach-polish-report.md create mode 100644 .planning/phase-W5b-v1425-token-leak-report.md create mode 100644 .planning/phase-W6-v1425-dashboard-fixes-report.md create mode 100644 .planning/phase-W6c-v1425-doctor-report-toggles-report.md create mode 100644 .planning/phase-W7-v1425-timezone-report.md create mode 100644 .planning/phase-W7b-v1425-timezone-followup-report.md create mode 100644 .planning/phase-W7d-v1425-dev-server-repair-report.md create mode 100644 .planning/phase-W7e-v1425-dead-code-cleanup-report.md create mode 100644 .planning/phase-W8-v1425-cross-page-consistency-report.md create mode 100644 .planning/phase-W8b-v1425-admin-login-fixes-report.md create mode 100644 .planning/phase-W8c-v1425-source-priority-two-axis-report.md create mode 100644 .planning/phase-W8d-v1425-ah-server-prep-report.md create mode 100644 .planning/phase-W8e-v1425-health-score-provenance-report.md create mode 100644 .planning/phase-W9-v1425-repo-polish-report.md create mode 100644 .planning/phase-W9e-v1425-translations-report.md create mode 100644 .planning/research/apple-health-ecosystem-scan.md create mode 100644 .planning/research/apple-health-sync-deep-dive.md create mode 100644 .planning/research/glp1-feature-inspiration.md create mode 100644 .planning/research/glp1-injection-tracking.md create mode 100644 .planning/research/health-score-provenance-ux.md create mode 100644 .planning/research/insights-sub-pages-ux.md create mode 100644 .planning/research/open-wearables-comparison.md create mode 100644 .planning/research/source-priority-two-axis.md create mode 100644 .planning/research/w10-code-review-findings.md create mode 100644 .planning/research/w10-dead-code-candidates.md create mode 100644 .planning/research/w10-design-review-findings.md create mode 100644 .planning/research/w10-i18n-runtime-gaps.md create mode 100644 .planning/research/w10-product-lead-assessment.md create mode 100644 .planning/research/w10-reconcile-A-design-report.md create mode 100644 .planning/research/w10-reconcile-B-architecture-report.md create mode 100644 .planning/research/w10-reconcile-C-security-auth-report.md create mode 100644 .planning/research/w10-reconcile-D-simplifier-report.md create mode 100644 .planning/research/w10-security-review-findings.md create mode 100644 .planning/research/w10-senior-dev-findings.md create mode 100644 .planning/research/w10-simplifier-findings.md create mode 100644 .planning/research/w14b-onboarding-rebuild.md create mode 100644 .planning/research/w14c-native-coach-prompts.md create mode 100644 .planning/research/w16b-workout-ingest.md create mode 100644 .planning/research/w16c-pr-detection.md create mode 100644 .planning/research/w17b-c-withings-activity-sleep.md create mode 100644 .planning/research/w21-code-review-findings.md create mode 100644 .planning/research/w21-dead-code-findings.md create mode 100644 .planning/research/w21-design-findings.md create mode 100644 .planning/research/w21-i18n-runtime-findings.md create mode 100644 .planning/research/w21-product-lead-assessment.md create mode 100644 .planning/research/w21-security-findings.md create mode 100644 .planning/research/w21-senior-dev-findings.md create mode 100644 .planning/research/w21-simplifier-findings.md create mode 100644 .planning/research/w8d-implementation-outline.md create mode 100644 .planning/research/withings-api-coverage.md create mode 100644 .planning/research/withings-plus-comparison.md create mode 100644 .planning/research/zielwerte-redesign.md create mode 100644 .planning/v1425-handoff-session-2.md create mode 100644 .planning/v1425-handoff.md create mode 100644 .planning/v15-ios-handoff/00-philosophy.md create mode 100644 .planning/v15-ios-handoff/01-repo-tour.md create mode 100644 .planning/v15-ios-handoff/02-server-architecture.md create mode 100644 .planning/v15-ios-handoff/03-api-contracts.md create mode 100644 .planning/v15-ios-handoff/04-data-model.md create mode 100644 .planning/v15-ios-handoff/05-auth-flows.md create mode 100644 .planning/v15-ios-handoff/06-ios-responsibilities.md create mode 100644 .planning/v15-ios-handoff/07-server-responsibilities.md create mode 100644 .planning/v15-ios-handoff/08-locked-contracts.md create mode 100644 .planning/v15-ios-handoff/09-recommended-flow.md create mode 100644 .planning/v15-ios-handoff/10-research-pointers.md create mode 100644 .planning/v15-ios-handoff/11-web-ui-tour.md create mode 100644 .planning/v15-ios-handoff/12-design-system.md create mode 100644 .planning/v15-ios-handoff/13-state-management.md create mode 100644 .planning/v15-ios-handoff/14-coach-mental-model.md create mode 100644 .planning/v15-ios-handoff/15-insights-architecture.md create mode 100644 .planning/v15-ios-handoff/16-health-score-logic.md create mode 100644 .planning/v15-ios-handoff/17-error-handling.md create mode 100644 .planning/v15-ios-handoff/18-pattern-cookbook.md create mode 100644 .planning/v15-ios-handoff/19-i18n-system.md create mode 100644 .planning/v15-ios-handoff/20-glossary.md create mode 100644 .planning/v15-ios-handoff/README.md create mode 100644 docs/audit/v1423-summary.md create mode 100644 messages/es.json create mode 100644 messages/fr.json create mode 100644 messages/it.json create mode 100644 messages/pl.json create mode 100644 prisma/migrations/0043_per_user_timezone/migration.sql create mode 100644 prisma/migrations/0044_mood_entry_tz_column/migration.sql create mode 100644 prisma/migrations/0045_doctor_report_prefs/migration.sql create mode 100644 prisma/migrations/0046_glp1_dose_history/migration.sql create mode 100644 prisma/migrations/0047_medication_schedule_window_snake_case/migration.sql create mode 100644 prisma/migrations/0048_user_source_priority/migration.sql create mode 100644 prisma/migrations/0049_withings_full_metrics/migration.sql create mode 100644 prisma/migrations/0050_withings_connection_scope/migration.sql create mode 100644 prisma/migrations/0051_measurement_device_type/migration.sql create mode 100644 prisma/migrations/0052_apple_health_enum_extensions/migration.sql create mode 100644 prisma/migrations/0053_workout_and_route/migration.sql create mode 100644 prisma/migrations/0054_personal_record/migration.sql create mode 100644 prisma/migrations/0055_measurement_sleepstage_composite/migration.sql create mode 100644 prisma/migrations/0056_medication_inventory_item/migration.sql create mode 100644 prisma/migrations/0057_user_onboarding_step/migration.sql create mode 100644 prisma/migrations/0058_user_research_mode/migration.sql create mode 100644 prisma/migrations/0059_medication_side_effect/migration.sql create mode 100644 prisma/migrations/0060_onboarding_step_not_null/migration.sql create mode 100644 scripts/i18n/build-locale.py create mode 100644 src/app/__tests__/targets-coach-mount.test.tsx create mode 100644 src/app/__tests__/targets-responsive.test.tsx delete mode 100644 src/app/__tests__/targets-sparkline.test.tsx create mode 100644 src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/doctor-report-prefs/route.ts create mode 100644 src/app/api/auth/me/research-mode/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/research-mode/route.ts create mode 100644 src/app/api/auth/me/source-priority/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/source-priority/route.ts create mode 100644 src/app/api/auth/me/timezone/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/timezone/route.ts create mode 100644 src/app/api/dashboard/glp1/route.ts create mode 100644 src/app/api/doctor-report/availability/route.ts delete mode 100644 src/app/api/insights/general-status/route.ts create mode 100644 src/app/api/insights/glp1-timeline/route.ts create mode 100644 src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts create mode 100644 src/app/api/measurements/by-external-ids/route.ts create mode 100644 src/app/api/medications/[id]/cadence/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/cadence/route.ts create mode 100644 src/app/api/medications/[id]/glp1/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/glp1/route.ts create mode 100644 src/app/api/medications/[id]/inventory/[itemId]/route.ts create mode 100644 src/app/api/medications/[id]/inventory/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/inventory/route.ts create mode 100644 src/app/api/medications/[id]/side-effects/[logId]/route.ts create mode 100644 src/app/api/medications/[id]/side-effects/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/side-effects/route.ts create mode 100644 src/app/api/medications/[id]/titration/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/titration/route.ts create mode 100644 src/app/api/onboarding/__tests__/step.test.ts create mode 100644 src/app/api/onboarding/step/route.ts create mode 100644 src/app/api/personal-records/__tests__/route.test.ts create mode 100644 src/app/api/personal-records/route.ts create mode 100644 src/app/api/withings/webhook/[token]/__tests__/route.test.ts create mode 100644 src/app/api/withings/webhook/[token]/route.ts create mode 100644 src/app/api/workouts/__tests__/batch-create.test.ts create mode 100644 src/app/api/workouts/batch/route.ts create mode 100644 src/app/insights/blutdruck/page.tsx create mode 100644 src/app/insights/bmi/page.tsx create mode 100644 src/app/insights/gewicht/page.tsx create mode 100644 src/app/insights/layout.tsx create mode 100644 src/app/insights/medikamente/page.tsx create mode 100644 src/app/insights/puls/page.tsx create mode 100644 src/app/insights/schlaf/page.tsx create mode 100644 src/app/insights/stimmung/page.tsx create mode 100644 src/app/onboarding/[step]/page.tsx create mode 100644 src/app/onboarding/layout.tsx create mode 100644 src/components/admin/__tests__/login-overview-csv.test.ts create mode 100644 src/components/admin/__tests__/login-overview-no-collapse.test.tsx create mode 100644 src/components/admin/__tests__/login-overview-provider.test.tsx create mode 100644 src/components/charts/__tests__/health-chart-vertical-markers.test.ts create mode 100644 src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx delete mode 100644 src/components/charts/compliance-charts.tsx create mode 100644 src/components/dashboard/__tests__/glp1-tile.test.tsx create mode 100644 src/components/dashboard/glp1-tile.tsx create mode 100644 src/components/i18n/__tests__/maintainership-banner.test.tsx create mode 100644 src/components/i18n/maintainership-banner.tsx create mode 100644 src/components/insights/__tests__/health-score-card-provenance.test.tsx delete mode 100644 src/components/insights/__tests__/insights-page-hero.test.tsx create mode 100644 src/components/insights/__tests__/personal-record-badge.test.tsx create mode 100644 src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx create mode 100644 src/components/insights/__tests__/sub-page-shell.test.tsx create mode 100644 src/components/insights/__tests__/vo2-max-chart-row.test.tsx create mode 100644 src/components/insights/insights-layout-shell.tsx delete mode 100644 src/components/insights/insights-page-hero.tsx create mode 100644 src/components/insights/insights-tab-strip.tsx create mode 100644 src/components/insights/personal-record-badge.tsx create mode 100644 src/components/insights/sleep-duration-chart.tsx create mode 100644 src/components/insights/sleep-overview.tsx create mode 100644 src/components/insights/sleep-stage-stacked-bar.tsx create mode 100644 src/components/insights/sub-page-shell.tsx create mode 100644 src/components/insights/therapy-timeline.tsx create mode 100644 src/components/insights/vo2-max-chart-row.tsx create mode 100644 src/components/medications/DrugLevelChart.tsx create mode 100644 src/components/medications/ResearchModeAcknowledgmentDialog.tsx create mode 100644 src/components/medications/SchedulingSection.tsx create mode 100644 src/components/medications/SideEffectsSection.tsx create mode 100644 src/components/medications/TitrationSection.tsx create mode 100644 src/components/medications/__tests__/DrugLevelChart.test.tsx create mode 100644 src/components/medications/__tests__/ResearchModeAcknowledgmentDialog.test.tsx create mode 100644 src/components/medications/__tests__/SchedulingSection.test.tsx create mode 100644 src/components/medications/__tests__/SideEffectsSection.test.tsx create mode 100644 src/components/medications/__tests__/TitrationSection.test.tsx create mode 100644 src/components/medications/__tests__/injection-site-picker.test.tsx create mode 100644 src/components/medications/__tests__/medication-card-glp1.test.tsx create mode 100644 src/components/medications/glp1-medication-card.tsx create mode 100644 src/components/medications/injection-site-picker.tsx delete mode 100644 src/components/medications/intake-timeline.tsx create mode 100644 src/components/medications/inventory-section.tsx create mode 100644 src/components/medications/medication-detail-section.tsx create mode 100644 src/components/onboarding/BaselineForm.tsx create mode 100644 src/components/onboarding/DoneScreen.tsx create mode 100644 src/components/onboarding/GoalsChipPicker.tsx create mode 100644 src/components/onboarding/OnboardingShell.tsx create mode 100644 src/components/onboarding/SourceCardGrid.tsx create mode 100644 src/components/onboarding/WelcomeCarousel.tsx create mode 100644 src/components/settings/__tests__/advanced-research-mode.test.tsx create mode 100644 src/components/settings/sources-section.tsx create mode 100644 src/components/settings/timezone-picker.tsx create mode 100644 src/components/targets/__tests__/consistency-strip.test.tsx create mode 100644 src/components/targets/__tests__/range-bar.test.tsx create mode 100644 src/components/targets/__tests__/target-card.test.tsx create mode 100644 src/components/targets/__tests__/target-coach-button.test.tsx create mode 100644 src/components/targets/__tests__/target-edit-sheet.test.tsx create mode 100644 src/components/targets/__tests__/targets-summary-header.test.tsx create mode 100644 src/components/targets/consistency-strip.tsx create mode 100644 src/components/targets/range-bar.tsx create mode 100644 src/components/targets/target-card.tsx create mode 100644 src/components/targets/target-coach-button.tsx create mode 100644 src/components/targets/target-edit-sheet.tsx create mode 100644 src/components/targets/targets-summary-header.tsx create mode 100644 src/hooks/use-coach-handoff.ts create mode 100644 src/hooks/use-insight-status.ts create mode 100644 src/hooks/use-insights-layout-prefs.ts create mode 100644 src/lib/__tests__/api-handler-safe-request-prop.test.ts create mode 100644 src/lib/ai/coach/__tests__/glp1-snapshot.test.ts create mode 100644 src/lib/ai/coach/__tests__/target-prompts.test.ts create mode 100644 src/lib/ai/coach/glp1-snapshot.ts create mode 100644 src/lib/ai/coach/target-prompts.ts create mode 100644 src/lib/ai/coach/target-scope.ts create mode 100644 src/lib/ai/prompts/__tests__/coach-prompt-locale.test.ts create mode 100644 src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts create mode 100644 src/lib/ai/prompts/__tests__/refusal-probe.test.ts create mode 100644 src/lib/ai/prompts/__tests__/safety-contracts-parity.test.ts create mode 100644 src/lib/ai/prompts/native-prompts.ts create mode 100644 src/lib/ai/prompts/safety-contracts.de.yaml create mode 100644 src/lib/ai/prompts/safety-contracts.en.yaml create mode 100644 src/lib/ai/prompts/safety-contracts.es.yaml create mode 100644 src/lib/ai/prompts/safety-contracts.fr.yaml create mode 100644 src/lib/ai/prompts/safety-contracts.it.yaml create mode 100644 src/lib/ai/prompts/safety-contracts.pl.yaml create mode 100644 src/lib/ai/prompts/safety-contracts.ts create mode 100644 src/lib/analytics/__tests__/source-priority.test.ts create mode 100644 src/lib/analytics/source-priority.ts create mode 100644 src/lib/api/__tests__/read-error.test.ts create mode 100644 src/lib/api/read-error.ts create mode 100644 src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts create mode 100644 src/lib/i18n/__tests__/fallback-chain.test.tsx create mode 100644 src/lib/insights/__tests__/glp1-plateau.test.ts create mode 100644 src/lib/insights/glp1-plateau.ts create mode 100644 src/lib/insights/sub-page-metric.ts create mode 100644 src/lib/jobs/__tests__/medication-inventory-expire.test.ts create mode 100644 src/lib/jobs/__tests__/pr-detection.test.ts create mode 100644 src/lib/jobs/__tests__/withings-queues.test.ts create mode 100644 src/lib/jobs/medication-inventory-expire.ts create mode 100644 src/lib/jobs/pr-detection.ts create mode 100644 src/lib/logging/__tests__/event-builder.test.ts create mode 100644 src/lib/medications/__tests__/dose-string.test.ts create mode 100644 src/lib/medications/__tests__/glp1-knowledge-drift.test.ts create mode 100644 src/lib/medications/__tests__/glp1-knowledge.test.ts create mode 100644 src/lib/medications/__tests__/glp1-pk.test.ts create mode 100644 src/lib/medications/__tests__/injection-sites.test.ts create mode 100644 src/lib/medications/__tests__/research-mode-types.test.ts create mode 100644 src/lib/medications/__tests__/route-guards.test.ts create mode 100644 src/lib/medications/dose-string.ts create mode 100644 src/lib/medications/glp1-knowledge.ts create mode 100644 src/lib/medications/glp1-pk.ts create mode 100644 src/lib/medications/injection-sites.ts create mode 100644 src/lib/medications/inventory/__tests__/service.test.ts create mode 100644 src/lib/medications/inventory/__tests__/state-machine.test.ts create mode 100644 src/lib/medications/inventory/service.ts create mode 100644 src/lib/medications/inventory/state-machine.ts create mode 100644 src/lib/medications/research-mode-types.ts create mode 100644 src/lib/medications/route-guards.ts create mode 100644 src/lib/medications/scheduling/__tests__/cadence.test.ts create mode 100644 src/lib/medications/scheduling/__tests__/compliance.test.ts create mode 100644 src/lib/medications/scheduling/cadence.ts create mode 100644 src/lib/medications/scheduling/compliance.ts create mode 100644 src/lib/medications/side-effects/__tests__/drift-guard.test.ts create mode 100644 src/lib/medications/side-effects/__tests__/taxonomy.test.ts create mode 100644 src/lib/medications/side-effects/taxonomy.ts create mode 100644 src/lib/medications/side-effects/validators.ts create mode 100644 src/lib/medications/titration/__tests__/ladder.test.ts create mode 100644 src/lib/medications/titration/ladder.ts create mode 100644 src/lib/mood/__tests__/date-key.test.ts create mode 100644 src/lib/mood/date-key.ts create mode 100644 src/lib/personal-records/__tests__/pr-detection-worker.test.ts create mode 100644 src/lib/personal-records/__tests__/pr-direction.test.ts create mode 100644 src/lib/personal-records/pr-detection-worker.ts create mode 100644 src/lib/personal-records/pr-direction.ts create mode 100644 src/lib/sources/__tests__/pick-canonical-workout.test.ts create mode 100644 src/lib/sources/__tests__/source-priority-withings-applehealth.test.ts create mode 100644 src/lib/sources/pick-canonical-workout.ts create mode 100644 src/lib/targets/__tests__/mood-stability-label.test.ts create mode 100644 src/lib/targets/mood-stability-label.ts create mode 100644 src/lib/tz/__tests__/resolver.test.ts create mode 100644 src/lib/tz/format.ts create mode 100644 src/lib/tz/resolver.ts create mode 100644 src/lib/validations/__tests__/doctor-report-prefs.test.ts create mode 100644 src/lib/validations/__tests__/source-priority.test.ts create mode 100644 src/lib/validations/__tests__/workout.test.ts create mode 100644 src/lib/validations/doctor-report-prefs.ts create mode 100644 src/lib/validations/source-priority.ts create mode 100644 src/lib/validations/workout.ts create mode 100644 src/lib/withings/__tests__/client.test.ts create mode 100644 src/lib/withings/__tests__/sync-activity.test.ts create mode 100644 src/lib/withings/__tests__/sync-sleep.test.ts create mode 100644 src/lib/withings/__tests__/sync.test.ts create mode 100644 src/lib/withings/mapping.md create mode 100644 src/lib/withings/sync-activity.ts create mode 100644 src/lib/withings/sync-sleep.ts create mode 100644 src/lib/withings/webhook-handler.ts create mode 100644 tests/integration/analytics-weekday-tz.test.ts create mode 100644 tests/integration/dashboard-widgets-save.test.ts create mode 100644 tests/integration/doctor-report-sections.test.ts create mode 100644 tests/integration/measurements-batch-delete.test.ts create mode 100644 tests/integration/pr-detection-end-to-end.test.ts create mode 100644 tests/integration/source-priority-two-axis.test.ts create mode 100644 tests/integration/timezone-per-user.test.ts create mode 100644 tests/integration/withings-activity-sync.test.ts create mode 100644 tests/integration/withings-sleep-stage-composite.test.ts create mode 100644 tests/integration/withings-sleep-sync.test.ts create mode 100644 tests/integration/workout-batch-create.test.ts create mode 100644 tests/integration/workout-batch-race.test.ts diff --git a/.github/ISSUE_TEMPLATE/translation.yml b/.github/ISSUE_TEMPLATE/translation.yml new file mode 100644 index 000000000..65b56c608 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation.yml @@ -0,0 +1,81 @@ +name: Translation feedback +description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL). +title: "[Translation] " +labels: ["i18n", "translation-feedback"] +body: + - type: markdown + attributes: + value: | + HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, and Polish ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary. + + This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle. + + For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead. + - type: dropdown + id: locale + attributes: + label: Locale + options: + - French (fr) + - Spanish (es) + - Italian (it) + - Polish (pl) + - Other (specify under "Surface") + validations: + required: true + - type: textarea + id: surface + attributes: + label: Where you saw it + description: | + Page / component / dialog name. Include the route (e.g. `/insights`, `/settings/notifications`) and, if possible, the i18n key from the rendered DOM (open DevTools, look for the visible string). + placeholder: | + Route: /insights/blood-pressure + Component: blood-pressure trend card header + Key (if known): insights.bp.trendCard.title + validations: + required: true + - type: textarea + id: current + attributes: + label: Current wording + description: Paste the exact phrase as it appears in the app today. + validations: + required: true + - type: textarea + id: suggested + attributes: + label: Suggested wording + description: | + How should it read instead? Include reasoning if the change is more than a typo — register (formal vs. informal), regional vocabulary, medical terminology, or grammar. + validations: + required: true + - type: dropdown + id: kind + attributes: + label: Kind of issue + options: + - Grammar / spelling + - Wrong word (translation error) + - Awkward / unnatural phrasing + - Wrong register (too formal / too casual) + - Medical terminology + - Regional vocabulary + - Untranslated string (English fall-through) + - Other + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Screenshots, links to related issues, related strings on the same page, etc. + - type: checkboxes + id: terms + attributes: + label: Pre-flight checks + options: + - label: I confirmed this is not a duplicate of an existing translation issue. + required: true + - label: My suggestion respects the maintainer's editorial voice (professional, neutral, plain prose — no marketing-speak). + required: true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e764ff85b..c327d1d30 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,16 +20,43 @@ concurrency: cancel-in-progress: true jobs: - build-and-push: - name: Build & push image - runs-on: ubuntu-latest - # 25 min wall clock — a healthy amd64-only build finishes inside 12 min. - # The qemu-arm64 path that was hanging at 47 min on `[linux/amd64 runner - # 2/14]` is gone; see commit message for v1.4.16 C5 rationale below. + # ── Stage 1 ──────────────────────────────────────────────────────── + # Build each architecture on its own native GitHub-hosted runner so + # Next.js's static-page workers run on real silicon instead of QEMU + # user-mode emulation. The v1.4.16 C5 attempt at + # `platforms: linux/amd64,linux/arm64` on a single amd64 runner + # deterministically SIGILL-crashed Next 15's static export under + # qemu-arm64 (`Next.js build worker exited with code: null and signal: + # SIGILL`); v8's optimising tier emits instructions qemu cannot + # reliably translate. ubuntu-24.04-arm is a standard GitHub-hosted + # arm64 runner, free for public repositories, and native — no qemu in + # the loop. + # + # Each platform pushes its layer set by digest only (`outputs: push-by-digest=true`). + # The `merge` job below stitches the two digests into a multi-arch + # manifest in a third step, so consumers see a single tag that + # auto-selects on `docker pull` per local platform. + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + # 25 min wall clock — native amd64 build finished inside 12 min + # at v1.4.24; the native arm64 path is comparable. The qemu-arm64 + # hang at 47 min on `[linux/amd64 runner 2/14]` is what motivated + # this matrix in the first place. timeout-minutes: 25 permissions: contents: read packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + cache_scope: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + cache_scope: arm64 steps: - name: Checkout @@ -38,6 +65,18 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # GHCR rejects repository refs with uppercase letters. `IMAGE_NAME` + # is derived from `github.repository`, which preserves the case of + # the GitHub org/user (`MBombeck/HealthLog`). `docker/metadata-action` + # lowercases its `images:` input transparently, so the legacy + # single-job workflow worked; the multi-arch matrix now feeds + # `outputs:` of build-push-action directly and needs the lowercase + # form there too. Resolve it once at the top of the job and reuse + # via $GITHUB_ENV. + - name: Lowercase image ref + run: | + echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV" + # Skip login on PRs from forks — they don't have package:write. # Image is built but not pushed in that case. - name: Log in to GitHub Container Registry @@ -48,11 +87,105 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute image labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_REF }} + labels: | + org.opencontainers.image.title=HealthLog + org.opencontainers.image.description=Self-hosted health-tracking PWA + org.opencontainers.image.licenses=AGPL-3.0-only + org.opencontainers.image.source=https://github.com/${{ github.repository }} + + # Per-ref + per-platform gha cache scope. Tag builds warm-start + # off main's last good layer set for the same platform; tag and + # main builds at the same SHA do not contend on writes, and + # amd64 / arm64 caches stay isolated from each other (a stale + # arm64 layer never poisons an amd64 build). + - name: Build & push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: | + type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: | + type=gha,scope=build-${{ matrix.cache_scope }}-${{ github.ref_name }} + type=gha,scope=build-${{ matrix.cache_scope }}-main + cache-to: type=gha,mode=max,scope=build-${{ matrix.cache_scope }}-${{ github.ref_name }} + provenance: true + sbom: true + + # Persist the per-platform digest so the `merge` job can stitch + # both into a single multi-arch manifest. One artifact per + # platform avoids upload races. + - name: Export digest + if: github.event_name != 'pull_request' + run: | + mkdir -p /tmp/digests + digest='${{ steps.build.outputs.digest }}' + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.cache_scope }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # ── Stage 2 ──────────────────────────────────────────────────────── + # Stitch the per-platform digests into a single multi-arch manifest + # under all the tags `docker/metadata-action` would normally apply. + # `docker buildx imagetools create` is a manifest-only operation — + # no layers are re-uploaded, the existing per-platform digests are + # referenced in place. This is the same end state as the + # single-buildx `platforms:` path would produce, just assembled in + # the registry instead of on one cross-emulated runner. + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + needs: [build] + timeout-minutes: 10 + permissions: + contents: read + packages: write + + steps: + - name: Download digest artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # GHCR requires the repository ref to be lowercase. See the same + # step in the `build` job above for the longer rationale. + - name: Lowercase image ref + run: | + echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute image tags & labels id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.IMAGE_REF }} tags: | type=ref,event=branch type=ref,event=pr @@ -66,44 +199,16 @@ jobs: org.opencontainers.image.licenses=AGPL-3.0-only org.opencontainers.image.source=https://github.com/${{ github.repository }} - # v1.4.16 C5 — drop linux/arm64 from the multi-arch build. - # - # Root cause of the main-branch hang since v1.4.14: the qemu-arm64 - # path (`docker/setup-qemu-action` + `tonistiigi/binfmt`) was - # SIGILL-crashing Next.js's static-page-generation workers - # (`Next.js build worker exited with code: null and signal: SIGILL` - # at 64-66/86 pages). V8's optimising tier emits CPU instructions - # that qemu's user-mode emulation cannot reliably translate under - # heavy load, and Node 22 + Alpine 3.21 hit it deterministically. - # Tag-builds appeared to "work" only because they happened to - # finish their static pages before the crash window; main-branch - # builds rarely got that lucky. - # - # Marc's prod target is x86_64 only (Coolify on apps-01, all other - # self-hosted instances we know of); the arm64 image was a - # courtesy build for OSS users, not production. v1.5 will re-add - # arm64 via a matrix that uses the GitHub-hosted `ubuntu-24.04-arm` - # runner (free for public repos, native arm64, no qemu) — see - # `docs/audit/v1416-auto-deploy-fix.md` for the broader CI plan. - # - # Per-ref gha cache scope (kept from v1.4.15 C3): tag + main - # builds at the same SHA do not contend on writes; tag warm-starts - # off main's last good build for layer reuse. - - name: Build & push image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: | - type=gha,scope=build-${{ github.ref_name }} - type=gha,scope=build-main - cache-to: type=gha,mode=max,scope=build-${{ github.ref_name }} - provenance: true - sbom: true + - name: Create multi-arch manifest + working-directory: /tmp/digests + run: | + # shellcheck disable=SC2046,SC2086 + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE_REF }}@sha256:%s ' *) + + - name: Inspect published manifest + run: | + docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}' # Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has # the new image. Removes the manual SSH + force-pull recipe documented @@ -115,21 +220,47 @@ jobs: # outage must not retroactively fail the build. Marc still has the # manual recipe as fallback. # - # Required GitHub repository secrets (set by Marc once): + # Required GitHub repository secrets (set by the maintainer once): # COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false # COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens # Both secrets are referenced exclusively here. If either is unset, the # step short-circuits to a skip rather than calling Coolify with empty # auth. + # + # v1.4.26 P6-3 — maintainer toggle. + # Three releases in a row shipped via the host-side SSH-retag fallback + # because the secrets above silently absent + the warn-only skip + # below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY` + # repository variable now distinguishes the two intended modes: + # + # COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent, + # FAIL the step so the maintainer sees the + # missing-secret gap on the next release. + # COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use + # this when the host-side SSH recipe is + # the intended deploy path. + # (unset / any other) → warn-and-skip (legacy behaviour). + # + # Set the variable under repo Settings → Secrets and variables → + # Actions → Variables (NOT Secrets — it is intentionally readable). - name: Trigger Coolify deploy - if: github.event_name != 'pull_request' continue-on-error: true env: COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }} COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} + COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }} run: | + mode="${COOLIFY_AUTO_DEPLOY:-}" + if [ "$mode" = "off" ]; then + echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook. Maintainer uses the host-side SSH recipe instead." + exit 0 + fi if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then - echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable." + if [ "$mode" = "on" ]; then + echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence." + exit 1 + fi + echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning." exit 0 fi # v1.4.22 C3 — force=true tells Coolify to skip its image-cache diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index af3b6f30b..6d292677a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -41,20 +41,18 @@ jobs: - name: Unit tests run: pnpm test - # v1.4.23 W4 F5c — OpenAPI spec drift check. + # OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25). # - # `continue-on-error: true` is intentional for v1.4.23: the - # registry covers only the iOS-touched routes today, and the - # legacy hand-maintained spec at docs/api/openapi-v1422-legacy.yaml - # carries the rest. Once the registry hits ~95% coverage in - # v1.4.24+, flip this to a hard gate by removing - # `continue-on-error`. + # The Zod registry is the source of truth for the public API contract. + # Any route signature change must be reflected in `docs/api/openapi.yaml` + # so downstream consumers (notably the v1.5 iOS Swift codegen) stay in + # lockstep. If this step fails, run `pnpm openapi:generate` locally and + # commit the regenerated spec. - name: OpenAPI spec drift check - continue-on-error: true run: | pnpm openapi:generate if ! git diff --exit-code docs/api/openapi.yaml; then - echo "::warning::OpenAPI spec out of date. Run 'pnpm openapi:generate' locally and commit the changes." + echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract." exit 1 fi echo "::notice::OpenAPI spec in sync with Zod source schemas." diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8ef916931..9253c12ec 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,47 +1,68 @@ -# HealthLog v1.4.23 — roadmap +# HealthLog v1.4.24 — roadmap -Milestone: **v1.4.23 — pre-iOS hygiene + sentinel hardening** (not -yet kicked off) -Latest tag at start: v1.4.22 (live in prod, image digest -`sha256:865154614303…`) +Milestone: **v1.4.24 — pre-iOS final polish** (not yet kicked off) +Latest tag at start: v1.4.23 (live in prod) Carry-over candidates already captured in -`.planning/v1422-backlog.md`: +`.planning/v1423-backlog.md`: -- Sentinel parser malformed-enum hardening (Sr-M5, ~30 LOC, - highest signal-to-effort ratio of the deferred MEDs). -- Analytics-route unbounded `findMany` paging. -- Targets-route 7-pass sparkline coalesce. -- `` controlled-prop refactor - (Sr-HIGH-4) — drop the React-key reset weaponisation before - the iOS Coach surface multiplies the pattern's footprint. -- Per-user prompt-tuning surface (the v1.4.22 settings cog was - removed pending this). -- Schema drift on `medication_schedules.days_of_week` — either - deploy the column or drop it from `schema.prisma`. Last call - before v1.5 P0. -- OpenAPI spec drift CI guard — pull forward from v1.5 D.5 if - possible. Cheaper than discovering drift via an iOS build - break. -- Pearson p-value normal-approx replacement (Code-MED-03) — - 30-LOC incomplete-beta or raise df ≥ 20 surfacing gate. Fix - before v1.5/v1.6 auto-discovery ships. -- Coach helpful/unhelpful first-week observation — does the new - warm persona land or does the disclosure-open rate stay below - ~30%? If low, tone pull-back before iOS multiplies the - audience. +- Pearson incomplete-beta replacement — v1.4.23 W5 H6 raised + `MIN_PAIRED_N` from 14 → 20 as a conservative surfacing-gate + patch. Land the rigorous incomplete-beta or normal-approx + replacement before v1.5/v1.6 auto-correlation discovery ships. +- OpenAPI drift gate flip from warn-only to hard-fail — generator + covers ~880 lines vs the legacy hand-maintained spec's 5 468; + complete registry coverage in parallel so the lockstep happens + before the iOS DTO codegen is downloading the spec + automatically. +- Settings-cog vs per-message-controls UX consolidation — Design + pushback during W6 raised concern that the dual surface + duplicates intent. Wait on first-week thumbs data, decide + whether per-user prompt prefs drift from per-message ratings. +- `coach-prefs.test.ts` integration `NextRequest` URL mock + regression — pre-existing failure that predates v1.4.23. + Investigate the harness's `cookies()` mock interaction with + `NextRequest`'s URL parsing. +- Sec-MED-1 + LOW cluster — intra-batch dedup accounting + (Sec-LOW-1), idempotency 422 retry hint in the OpenAPI 422 + description (Sec-LOW-2), APNs key-file path redaction in + wide-event meta (Sec-LOW-3), refresh-token failure audit + `userId` extension (Sec-LOW-4). +- S-05 simplify deferral (Session B) — touched too many call + sites for a single reconcile commit. Reserved next strategic milestones: - **v1.5** — iOS app + Apple Health integration + per-metric APNs - alerts. Strategic plan at - `.planning/phase-W5-v1422-product-lead-review.md`. Per-user - timezone (issue #167) is a candidate for this milestone — see - `.planning/feature-user-timezone.md` for the full proposal. + alerts. Refreshed strategic plan with concrete file paths at + `.planning/phase-W6-v1423-product-lead-review.md`. P1 is now a + ~5-day iOS Swift sprint because every server-side contract + landed in v1.4.23. Per-user timezone foundation + (`.planning/feature-user-timezone.md`, issue #167) is now scoped + into v1.4.25 — Marc directive 2026-05-14, packs the iOS-launch + prep into the v1.4.25 polish window instead of slipping it. - **v1.6+** — Auto-correlation discovery (FDR-controlled), Coach full-page route at `/insights/coach`, conversation-driven goal - setting. Per-user timezone slips here if v1.5 stays - iOS-focused. + setting. + +--- + +## Previous milestone — v1.4.23 (completed 2026-05-11) + +| Wave | Goal | State | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| 1 | Research — Apple Health type mapping, APNs library decision (`@parse/node-apn`), OpenAPI tooling choice (`zod-openapi` + `yaml@^2`) | done | +| 2 | Apple Health foundation — F1 `MeasurementType` extension + sleep-stage enum + composite dedup index, F2 `APPLE_HEALTH` source + UI badge, F3 `POST /api/measurements/batch` + sleep-stage analytics aggregation | done | +| 3 | APNs scaffolding — F4 `@parse/node-apn` sender + dispatcher cascade rewire, `Device` columns + paired CHECK constraint, `POST /api/devices` apnsToken acceptance + cross-user-hijack guard | done | +| 4 | OpenAPI generator + Coach metric slot + native-auth hardening + Coolify runbook — F5 `zod-openapi` registry + warn-only CI gate, F6 nine HealthKit categories in strict schema + PROMPT_VERSION 4.23.0, F7 per-device refresh-token replay-detection + device-management endpoints, F8 Coolify runbook + `::notice::` line | done | +| 5 | Hygiene (H1-H7) — sentinel parser malformed-enum annotation, analytics chunked-paged aggregate, drawer controlled-prop refactor, per-user prompt prefs surface, `medication_schedules.days_of_week` deploy, Pearson n≥20 gate, Coach feedback observation view | done | +| 6 | Multi-agent QA + reconcile — six reviews (code, security, design, senior-dev, simplify, product-lead); 1 CRIT + 9 HIGH + 4 simplify + 3 MED applied; LOWs triaged into v1423-backlog | done | +| 7 | Release v1.4.23 — bump, CHANGELOG, release-merge, tag, GHCR, Coolify deploy, /api/version=1.4.23, smoke, GH release, docs + landing sync, brief | done | + +Milestone completed 2026-05-11 — v1.4.23 LIVE in prod. +Release brief: `docs/audit/v1423-summary.md`. +Backlog seeded to `.planning/v1423-backlog.md`. v1.5 strategic plan refresh +at `.planning/phase-W6-v1423-product-lead-review.md`. --- diff --git a/.planning/STATE.md b/.planning/STATE.md index 6b00989b4..d4dff4c10 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,7 +1,7 @@ # v1.4.23 marathon — state log -Status: Wave 5 shipped (7 hygiene items + 8 atomic commits) -Last update: 2026-05-11T02:00+02:00 +Status: SHIPPED — v1.4.23 LIVE in prod +Last update: 2026-05-11 (release wave) > Previous milestone: v1.4.22 live (image digest > `sha256:865154614303…`, `/api/version=1.4.22`). @@ -221,17 +221,25 @@ Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts` Session F added the forged-X-Device-Id regression — final at 2236 unit (+45 across the W5+W6 reconcile arc). -## Wave 7 — Release v1.4.23 (next) +## Wave 7 — Release v1.4.23 -- [ ] Pre-release verify -- [ ] Bump `package.json` 1.4.22 → 1.4.23 + CHANGELOG -- [ ] Release-merge develop → main -- [ ] Tag + push v1.4.23 -- [ ] GHCR build green -- [ ] Coolify deploy (auto, if F8 works; else host-side fallback) -- [ ] /api/version=1.4.23 confirmed -- [ ] Production smoke + e2e workflow on main passes -- [ ] GH release -- [ ] Docs site + landing site sync -- [ ] `docs/audit/v1423-summary.md` (release brief) -- [ ] v1.5 P1 plan refresh recorded +- [x] Pre-release verify (typecheck / lint / openapi:check / 2236 + unit / 110 of 112 integration — 2 failures are the pre-existing + `coach-prefs.test.ts` `NextRequest` URL mock carryover from + v1.4.22, documented in `v1423-backlog.md`, non-blocking) +- [x] Format sweep commit (`f1e6630` prettier sweep on `develop`) + + W1 research + W5/W6 review packs commit (`766a9ae`) + + OpenAPI regen (`4183552`) +- [x] Bump `package.json` 1.4.22 → 1.4.23 + CHANGELOG (`d2331d9` + `chore(release): v1.4.23`) +- [x] Release-merge develop → main (`0dc0e16 Release v1.4.23`) +- [x] Tag + push v1.4.23 +- [x] GHCR build green +- [x] Coolify deploy + `/api/version=1.4.23` confirmed +- [x] Production smoke (gated routes 307; `/api/version=1.4.23`) +- [x] GH release published +- [x] Docs site + landing site sync (image pins 1.4.22 → 1.4.23 + + ai-insights v1.4.23 callout; softwareVersion JSON-LD bump) +- [x] `docs/audit/v1423-summary.md` release brief +- [x] v1.5 P1 plan refresh recorded + (`.planning/phase-W6-v1423-product-lead-review.md` section C) diff --git a/.planning/phase-Fix-N-v1425-report.md b/.planning/phase-Fix-N-v1425-report.md new file mode 100644 index 000000000..d57dac79c --- /dev/null +++ b/.planning/phase-Fix-N-v1425-report.md @@ -0,0 +1,142 @@ +# Phase Fix-N — v1.4.25 reconcile (final bucket) + +**Branch**: `develop` +**Date**: 2026-05-14 +**Status**: COMPLETE — reconcile pass CLOSED. + +## Scope + +Fix-N is the largest bucket of the W21 reconcile pass: shared helper +modules, the medication-detail section wrapper, drift-guard tightening +on the side-effect taxonomy, and route-ownership hoisting across nine +medication sub-routes. All items applied; no scope deferred. + +Items landed (per `.planning/phase-W21-reconcile-plan.md`): + +- simp-H1 — onboarding `readError` helper hoisted (one source, four callers). +- simp-H2 — dropped the `templateFill` helper in TitrationSection; the + i18n `t()` already accepts the params bag. +- simp-H3 — extracted `researchModeStatusLabel` helper out of the + Settings advanced-section's nested ternary. +- simp-H4 — shared `ResearchModeStatus` DTO + tri-state gate classifier + consumed by DrugLevelChart and the Settings re-prompt banner. +- simp-H5 — added `findDrugIdByBrand` to the GLP-1 knowledge layer. +- simp-H6 — hoisted `assertMedicationOwnership` to a shared route-guard + module; rolled out across 9 medication sub-routes. +- simp-H7 — replaced the local `InventoryState` union with the canonical + Prisma `MedicationInventoryState` enum. +- simp-M1 + code-M1 — derived `SIDE_EFFECT_CATEGORY_VALUES` / + `SIDE_EFFECT_ENTRY_VALUES` via `z.nativeEnum`; drift-guard test asserts + Prisma enum, taxonomy map, and validator triangle stay aligned. +- simp-M3 — extracted `` wrapper across the + three Wave-4b sections (Titration, Scheduling, SideEffects). The + inventory disclosure stays on `
` (see Deviations). +- simp-M4 — collapsed the two near-identical dose-string parsers + (`parseDoseMg` in DrugLevelChart, `parseDoseString` in the titration + route) into a single module at `src/lib/medications/dose-string.ts`. + The `parseDose` helper in `medication-form.tsx` was left alone — its + contract is form-splitting (returns `{ amount, unit }`), not mg + extraction. +- simp-M5 — widened `daysRemainingInUse` in the inventory state-machine + to accept either the full `InventoryItemView` or a thin + `{ firstUseAt }` shape; the inventory-section client now calls the + pure helper instead of re-implementing it. +- simp-M8 — the three W19 routes (glp1, inventory, side-effects) now + use `apiError("Too many requests", 429, { headers: rateLimitHeaders(rl) })` + instead of the 4-line `for` loop. `apiError` was extended to accept + a `headers` key in its third-argument option bag. +- design-M1 — swapped the gender native `` in SideEffectsSection for the + shadcn-radix `` so keyboard a11y is free; the chip + itself flips `aria-checked`-style visual state via `data-` driven + styles. +- The user's selected set persists to localStorage at + `healthlog.onboarding.goals:` and survives tab close. The + `userId` is threaded as a prop from the server step page so the + hydration runs synchronously in the state initializer — required to + satisfy `react-hooks/set-state-in-effect`, which forbids the + "useEffect on userId arrival → setState" pattern. +- Back / Skip / Next CTAs all live inside the component; the + `OnboardingShell` drops its footer hrefs to avoid duplicate + controls. Both Skip and Next call + `POST /api/onboarding/step { step: 2 }`. +- No schema change — per the brief, goals are held in client state and + the eventual `User.onboardingGoals` column is deferred to v1.4.26. + +### Commit 3 — Source 4-card grid (step 2) + +- `src/components/onboarding/SourceCardGrid.tsx` — three cards on the + grid (Manual / Withings / Apple Health). Garmin omitted entirely per + the brief (not on the roadmap). +- Card states: + - **Manual** — `enabled-select`, default-selected with a + "Recommended" pill. The card body is a ` - ) : ( - - )} - - {step < TOTAL_STEPS ? ( - - ) : ( - - )} - - - {/* Single Skip per step. Step 3's "NONE" choice already covers - skipping notifications, so we hide the underline on step 3. */} - {step < TOTAL_STEPS ? ( - - ) : null} - - ); + const current = clampCurrentStep(user.onboardingStep); + redirect(`/onboarding/${current}`); +} + +function clampCurrentStep(value: number | null | undefined): 0 | 1 | 2 | 3 | 4 { + if (value == null || !Number.isFinite(value)) return 0; + const floor = Math.floor(value); + if (floor <= 0) return 0; + if (floor >= 4) return 4; + return floor as 0 | 1 | 2 | 3 | 4; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 4d557f97c..82d15cec8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import { Activity, Droplet, Footprints, + Gauge, Heart, Moon, Percent, @@ -47,6 +48,7 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { InsightsCardPreview } from "@/components/insights/insights-card"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; +import { Glp1Tile } from "@/components/dashboard/glp1-tile"; const HealthChart = dynamic( () => @@ -250,6 +252,10 @@ export default function DashboardPage() { const bf = data?.summaries?.BODY_FAT; const sleepSummary = data?.summaries?.SLEEP_DURATION; const stepsSummary = data?.summaries?.ACTIVITY_STEPS; + // v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics + // auto-populates this summary because the route iterates over the + // full measurementTypeEnum.options list; no backend change needed. + const vo2Summary = data?.summaries?.VO2_MAX; const moodSummary = moodData?.summary; // Resolve full dashboard layout — controls visibility + order of every widget @@ -306,6 +312,7 @@ export default function DashboardPage() { const hasMood = (moodSummary?.count ?? 0) > 0; const hasSleep = (sleepSummary?.count ?? 0) > 0; const hasSteps = (stepsSummary?.count ?? 0) > 0; + const hasVo2 = (vo2Summary?.count ?? 0) > 0; const hasBpInTarget = data?.bpInTargetPct != null; // Tile (strip) gates — controlled by the new `tileVisible` flag. @@ -316,6 +323,7 @@ export default function DashboardPage() { const showMoodTile = isTileVisible("mood") && hasMood; const showSleepTile = isTileVisible("sleep") && hasSleep; const showStepsTile = isTileVisible("steps") && hasSteps; + const showVo2Tile = isTileVisible("vo2Max") && hasVo2; const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; // Chart (lower row) gates — controlled by the legacy `visible` flag. @@ -563,7 +571,7 @@ export default function DashboardPage() { node: ( + ), + }); + } if (showBpInTargetTile) { /* v1.4.22 A2 — feature parity with every other tile. Synthesise a slope from the difference between the 7-day @@ -872,7 +909,7 @@ export default function DashboardPage() { node: ( ), }); @@ -1002,6 +1040,7 @@ export default function DashboardPage() { yAxisUnit="Hg" targetZones={bpTargetZones} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1021,6 +1060,7 @@ export default function DashboardPage() { unit="bpm" valueBands={pulseBands} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1040,6 +1080,7 @@ export default function DashboardPage() { unit="%" valueBands={bodyFatBands} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1054,6 +1095,7 @@ export default function DashboardPage() { key="mood-chart" compareBaseline={compareBaseline} chartKey="mood" + userTimezone={user?.timezone} /> ), }); @@ -1072,6 +1114,7 @@ export default function DashboardPage() { colors={["#8be9fd"]} unit="h" compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1089,6 +1132,7 @@ export default function DashboardPage() { title={t("dashboard.steps") ?? "Steps"} colors={["#50fa7b"]} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1106,6 +1150,7 @@ export default function DashboardPage() { ), }); @@ -1215,6 +1260,17 @@ export default function DashboardPage() { insight={advisor.payload?.insights ?? null} /> )} + {/* v1.4.25 W6 — GLP-1 status tile. The tile self-gates on + `Medication.treatmentClass === "GLP1"` (route returns + `data: null` when the user has no active GLP-1 med), so + we always mount it and let the tile suppress itself. + Slot is below the InsightsCardPreview and above the + chart row — high enough that a Mounjaro / Ozempic user + sees their dose-response chart without scrolling past + BP / pulse, but below the AI preview so the latter + stays the "scroll-stop hero" Marc anchored the dashboard + around. */} + {charts.map((entry) => (
{entry.node} diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 4b2571f3b..fda17e53f 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -11,6 +11,7 @@ import { ExportSection } from "@/components/settings/export-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; +import { SourcesSection } from "@/components/settings/sources-section"; import { ThresholdsSection } from "@/components/settings/thresholds-section"; import { SETTINGS_SECTION_SLUGS, @@ -45,6 +46,7 @@ const SECTION_COMPONENTS: Record< notifications: NotificationsSection, dashboard: DashboardSection, thresholds: ThresholdsSection, + sources: SourcesSection, api: ApiSection, export: ExportSection, advanced: AdvancedSection, diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index d2f817eb5..b36940b9e 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -1,58 +1,22 @@ "use client"; +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; +import { AlertCircle, Loader2 } from "lucide-react"; + import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - Scale, - Heart, - Activity, - Moon, - Percent, - Loader2, - TrendingUp, - TrendingDown, - Minus, - AlertCircle, - ExternalLink, - Smile, - Droplet, -} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { + TargetCard, + type TargetCardData, +} from "@/components/targets/target-card"; +import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header"; +import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; +import { useCoachHandoff } from "@/hooks/use-coach-handoff"; -interface TargetData { - type: string; - label: string; - current: number | null; - average30: number | null; - trend: "up" | "down" | "stable" | null; - unit: string; - range: { min: number; max: number } | null; - classification: { category: string; color: string } | null; - source: string; - /** - * v1.4.22 C1 — sparkline + vs.-last-month delta. Optional; the API - * skips both when a window has fewer than 3 readings so the comparison - * stays honest on a fresh account. - */ - points30d?: number[] | null; - deltaVsLastMonth?: number | null; - details?: { - medications?: Array<{ - name: string; - compliance7: number; - compliance30: number; - }>; - }; -} +type TargetData = TargetCardData; interface BpDiastolic { current: number | null; @@ -60,8 +24,15 @@ interface BpDiastolic { range: { min: number; max: number } | null; } +interface TargetPageSummary { + targetsMetThisWeek: number; + totalTargets: number; + streakHighlight: { metric: string; days: number } | null; +} + interface TargetsResponse { targets: TargetData[]; + pageSummary?: TargetPageSummary; bpDiastolic: BpDiastolic; profile: { heightCm: number | null; @@ -71,37 +42,22 @@ interface TargetsResponse { }; } -const TYPE_ICONS: Record = { - WEIGHT: Scale, - BLOOD_PRESSURE: Heart, - BLOOD_PRESSURE_IN_TARGET: Heart, - PULSE: Activity, - SLEEP_DURATION: Moon, - BODY_FAT: Percent, - BMI: Scale, - MOOD_SCORE: Smile, - MOOD_STABILITY: Smile, - BLOOD_GLUCOSE_FASTING: Droplet, - BLOOD_GLUCOSE_POSTPRANDIAL: Droplet, - BLOOD_GLUCOSE_RANDOM: Droplet, - BLOOD_GLUCOSE_BEDTIME: Droplet, -}; - -const TYPE_COLORS: Record = { - WEIGHT: "text-dracula-purple", - BLOOD_PRESSURE: "text-dracula-pink", - BLOOD_PRESSURE_IN_TARGET: "text-dracula-pink", - PULSE: "text-dracula-green", - SLEEP_DURATION: "text-dracula-cyan", - BODY_FAT: "text-dracula-orange", - BMI: "text-dracula-yellow", - MOOD_SCORE: "text-dracula-lavender", - MOOD_STABILITY: "text-dracula-lavender", - BLOOD_GLUCOSE_FASTING: "text-dracula-red", - BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red", - BLOOD_GLUCOSE_RANDOM: "text-dracula-red", - BLOOD_GLUCOSE_BEDTIME: "text-dracula-red", -}; +/** + * v1.4.25 W3e — provider-chain status used to gate the per-card Coach + * CTA. Returns true when at least one provider is configured AND + * enabled. The Settings → AI surface owns the same query + * (`useQuery({ queryKey: ["insights", "provider-chain"] })`) so the + * cache is shared. + */ +interface ProviderChainStatus { + activeProvider: string | null; + cachedActiveProvider: string | null; + configuredChain: Array<{ + providerType: string; + enabled: boolean; + available: boolean; + }>; +} const GLUCOSE_TYPES = new Set([ "BLOOD_GLUCOSE_FASTING", @@ -111,115 +67,45 @@ const GLUCOSE_TYPES = new Set([ ]); /** - * Map server-emitted classification.category strings to the i18n - * key under `targets.status.*`. The server is the source of truth - * for the category set (lib/analytics/classifications.ts + - * pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase - * A7 wires this map so the German locale stops surfacing English - * status pills like "Low / On Target / Stable / Moderate" — the - * exact regression the maintainer reported. + * Fixed card order per Marc directive — explicitly NOT status-sorted. + * Marc wants a stable visual hierarchy so the user's eye lands on the + * same metric in the same place every visit. Cards whose type is not + * in this list (glucose contexts, future metrics) sort after the + * core six in their server-emitted order. */ -const STATUS_CATEGORY_KEY: Record = { - // BMI - Underweight: "underweight", - Normal: "normal", - Overweight: "overweight", - "Obesity Grade I": "obesityGrade1", - "Obesity Grade II": "obesityGrade2", - "Obesity Grade III": "obesityGrade3", - // BP - Optimal: "optimal", - "High-normal": "highNormal", - "Hypertension Grade 1": "hypertensionGrade1", - "Hypertension Grade 2": "hypertensionGrade2", - "Hypertension Grade 3": "hypertensionGrade3", - // Pulse - Bradycardia: "bradycardia", - Elevated: "elevated", - Tachycardia: "tachycardia", - // Pulse target / sleep target - "Significantly low": "significantlyLow", - "Slightly low": "slightlyLow", - "On target": "onTarget", - "Slightly elevated": "slightlyElevated", - "Significantly elevated": "significantlyElevated", - // Sleep - "Far too short": "farTooShort", - "Too short": "tooShort", - "Slightly long": "slightlyLong", - "Far too long": "farTooLong", - // Body fat - "Below essential": "belowEssential", - Essential: "essential", - Athletic: "athletic", - Fitness: "fitness", - Acceptable: "acceptable", - Obese: "obese", - // Steps - "Very low": "veryLow", - "Low active": "lowActive", - "Moderately active": "moderatelyActive", - Active: "active", - "Very active": "veryActive", - // Generic / shared (BP-on-target, mood, medication compliance, glucose) - Good: "good", - Moderate: "moderate", - Low: "low", - High: "high", - "Very good": "veryGood", - "Very stable": "veryStable", - Stable: "stable", - Fluctuating: "fluctuating", +const FIXED_TARGET_ORDER: Record = { + BLOOD_PRESSURE: 0, + BLOOD_PRESSURE_IN_TARGET: 1, + WEIGHT: 2, + PULSE: 3, + BMI: 4, + MOOD_SCORE: 5, + MOOD_STABILITY: 6, + MEDICATION_COMPLIANCE: 7, + SLEEP_DURATION: 8, + BODY_FAT: 9, + ACTIVITY_STEPS: 10, }; -/** Resolve a server-emitted classification.category to its translated label. */ -function translateStatus( - category: string, - t: (key: string, vars?: Record) => string, -): string { - const key = STATUS_CATEGORY_KEY[category]; - if (!key) return category; // unmapped — fall back to verbatim string - return t(`targets.status.${key}`); +function sortKey(target: TargetData): number { + return FIXED_TARGET_ORDER[target.type] ?? 100; } -/** Resolve a target's display label. - * - * Glucose contexts are special-cased: the server emits an i18n key - * (e.g. `targets.glucoseFasting`) as its `label`, and the page's - * top-level `visibleTargets` mapping resolves that to a localised - * string before the card mounts. So when this helper runs, glucose - * targets already carry the localised label and the i18n lookup - * below misses (no `targets.label.BLOOD_GLUCOSE_FASTING` key) — the - * fallback returns the verbatim resolved label, which is correct. - * - * Every other target type carries a stable English label from the - * server. Look it up under `targets.label.` here so the card - * title respects the user's locale. +/** + * Source-citation URL for each target type. Kept on the page (rather + * than the card) so the card stays presentational and the routing + * stays declarative. */ -function translateTargetLabel( - target: TargetData, - t: (key: string, vars?: Record) => string, -): string { - const i18nKey = `targets.label.${target.type}`; - const localised = t(i18nKey); - if (localised && localised !== i18nKey) { - return localised; - } - return target.label; -} - function getTargetSourceLink(target: TargetData): string | null { if (target.type === "WEIGHT" || target.type === "BMI") { return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight"; } - - if (target.type === "BLOOD_PRESSURE") { + if ( + target.type === "BLOOD_PRESSURE" || + target.type === "BLOOD_PRESSURE_IN_TARGET" + ) { return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119"; } - if (target.type === "BLOOD_PRESSURE_IN_TARGET") { - return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119"; - } - if (target.type === "PULSE") { if (target.source.includes("CDC/NCHS")) { return "https://www.cdc.gov/nchs/data/nhsr/nhsr041.pdf"; @@ -228,453 +114,18 @@ function getTargetSourceLink(target: TargetData): string | null { return "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse"; } } - if (target.type === "SLEEP_DURATION") { return "https://aasm.org/seven-or-more-hours-of-sleep-per-night-a-health-necessity-for-adults/"; } - if (target.type === "BODY_FAT") { return "https://www.acefitness.org/resources/everyone/blog/6596/what-are-the-guidelines-for-percentage-of-body-fat-loss/"; } - if (target.type === "ACTIVITY_STEPS") { return "https://www.who.int/publications/i/item/9789240015128"; } - - if (target.type === "MOOD_SCORE" || target.type === "MOOD_STABILITY") { - return null; - } - return null; } -/** - * v1.4.22 C1 — tiny inline sparkline. Each target card paints the last - * 30 days of values beneath the range bar so the page reads as a - * "current state + recent journey" surface rather than a static - * reference card. The SVG is dependency-free and renders in <1ms; we - * deliberately avoid pulling Recharts in here because every card on - * the page would mount its own ResponsiveContainer. - * - * The path stays in the dracula-purple token so the colour reads as a - * neutral trace, not a status signal — the range bar above already - * carries the green/yellow/red semantics and a second status colour - * on the sparkline would compete for the user's eye. - */ -function Sparkline({ - points, - className, -}: { - points: number[]; - className?: string; -}) { - if (points.length < 2) return null; - const min = Math.min(...points); - const max = Math.max(...points); - const span = Math.max(max - min, 0.0001); - const w = 100; - const h = 24; - const stepX = w / (points.length - 1); - const path = points - .map((value, i) => { - const x = i * stepX; - const y = h - ((value - min) / span) * h; - return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`; - }) - .join(" "); - return ( - - ); -} - -function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) { - if (trend === "up") { - return ; - } - if (trend === "down") { - return ; - } - if (trend === "stable") { - return ; - } - return null; -} - -/** - * A horizontal range bar with green/yellow/red zones showing where - * the current value falls relative to a target range. - */ -function RangeBar({ - value, - min, - max, - unit, - orangeMin, - orangeMax, -}: { - value: number; - min: number; - max: number; - unit: string; - orangeMin?: number; - orangeMax?: number; -}) { - const { t } = useTranslations(); - - const span = max - min; - const defaultOrangeWidth = span * 0.3; - const computedOrangeMin = min - defaultOrangeWidth; - const computedOrangeMax = max + defaultOrangeWidth; - const effectiveOrangeMin = - orangeMin != null ? Math.min(orangeMin, min) : computedOrangeMin; - const effectiveOrangeMax = - orangeMax != null ? Math.max(orangeMax, max) : computedOrangeMax; - - const orangeSpan = Math.max(1, effectiveOrangeMax - effectiveOrangeMin); - const sidePadding = Math.max(1, orangeSpan * 0.18); - const visualMin = effectiveOrangeMin - sidePadding; - const visualMax = effectiveOrangeMax + sidePadding; - const visualSpan = visualMax - visualMin; - const clampedValue = Math.max(visualMin, Math.min(visualMax, value)); - const rawPosition = ((clampedValue - visualMin) / visualSpan) * 100; - const EDGE_PADDING_PERCENT = 4; - const position = Math.max( - EDGE_PADDING_PERCENT, - Math.min(100 - EDGE_PADDING_PERCENT, rawPosition), - ); - - // Zone boundaries (percent of visual bar) - const greenStart = Math.max(0, ((min - visualMin) / visualSpan) * 100); - const greenEnd = Math.min(100, ((max - visualMin) / visualSpan) * 100); - const yellowLeftStart = Math.max( - 0, - ((effectiveOrangeMin - visualMin) / visualSpan) * 100, - ); - const yellowRightEnd = Math.min( - 100, - ((effectiveOrangeMax - visualMin) / visualSpan) * 100, - ); - - // Determine marker color - const inGreen = value >= min && value <= max; - const inYellow = - !inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax; - - const markerColor = inGreen - ? "var(--dracula-green)" - : inYellow - ? "var(--dracula-orange)" - : "var(--dracula-red)"; - const minLabelPosition = Math.max(5, Math.min(95, greenStart)); - const maxLabelPosition = Math.max(5, Math.min(95, greenEnd)); - - // Delta to target range - const delta = value < min ? min - value : value > max ? value - max : 0; - const deltaText = - delta > 0 - ? value < min - ? t("targets.belowTarget", { delta: delta.toFixed(1), unit }) - : t("targets.aboveTarget", { delta: delta.toFixed(1), unit }) - : t("targets.inTarget"); - - return ( -
-
- {/* Red background (full bar) */} -
- {/* Yellow zones */} -
-
- {/* Green zone */} -
- {/* Current value marker with tooltip */} - - - -
- - -

- {t("targets.currentValue", { value: String(value), unit })} -

-

- {t("targets.targetRangeValue", { - min: String(min), - max: String(max), - unit, - })} -

-

{deltaText}

-
- - -
-
- - {min} {unit} - - - {max} {unit} - -
-
- ); -} - -function TargetCard({ - target, - bpDiastolic, -}: { - target: TargetData; - bpDiastolic?: BpDiastolic; -}) { - const { t } = useTranslations(); - - const Icon = TYPE_ICONS[target.type] ?? Activity; - const iconColor = TYPE_COLORS[target.type] ?? "text-primary"; - const isBp = target.type === "BLOOD_PRESSURE"; - const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE"; - const medicationBreakdown = target.details?.medications ?? []; - const sourceLink = getTargetSourceLink(target); - - const localisedLabel = translateTargetLabel(target, t); - - return ( - - -
-
- - - {localisedLabel} - -
- -
-
- - {/* Current value */} - {target.current != null ? ( -
-
- {isBp && bpDiastolic?.current != null ? ( - <> - - {Math.round(target.current)} - - / - - {Math.round(bpDiastolic.current)} - - - ) : ( - - {target.type === "BODY_FAT" - ? target.current.toFixed(1) - : Math.round(target.current * 10) / 10} - - )} - - {target.unit} - -
- {target.average30 != null && ( -

- {t("targets.average30d")}{" "} - {isBp && bpDiastolic?.average30 != null - ? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}` - : Math.round(target.average30 * 10) / 10}{" "} - {target.unit} -

- )} -
- ) : ( -
- - {t("targets.noMeasurementYet")} -
- )} - - {/* Range bar */} - {target.range && target.current != null && ( - - )} - - {/* BP diastolic range bar */} - {isBp && bpDiastolic?.range && bpDiastolic.current != null && ( -
-

- {t("targets.diastolic")} -

- -
- )} - - {/* v1.4.22 C1 — sparkline + Δ-vs-last-month caption. The - sparkline collapses to a thinner trace on mobile via the - shared 24px height, and the delta caption sits on its own - line so it stays scannable next to the range bar above. */} - {target.points30d && target.points30d.length >= 2 && ( -
- - {target.deltaVsLastMonth != null && ( -

- {Math.abs(target.deltaVsLastMonth) < 0.05 - ? t("targets.deltaVsLastMonthFlat", { unit: target.unit }) - : t("targets.deltaVsLastMonth", { - delta: - (target.deltaVsLastMonth > 0 ? "+" : "−") + - Math.abs(target.deltaVsLastMonth).toFixed(1), - unit: target.unit, - })} -

- )} -
- )} - - {isMedicationCompliance && medicationBreakdown.length > 0 && ( -
-

- {t("targets.compliancePerMedication")} -

-
- {medicationBreakdown.map((medication) => ( -
- {medication.name} - - {medication.compliance7.toFixed(1)}% (7T) ·{" "} - {medication.compliance30.toFixed(1)}% (30T) - -
- ))} -
-
- )} - - {/* Classification + source */} -
- {target.classification ? ( - - - - - {translateStatus(target.classification.category, t)} - - - - {target.range && ( -

- {t("targets.targetRangeValue", { - min: String(target.range.min), - max: String(target.range.max), - unit: target.unit, - })} -

- )} -

- {t("targets.sourceLabel", { source: target.source })} -

-
-
-
- ) : ( - placeholder - )} - {target.type !== "MEDICATION_COMPLIANCE" ? ( - sourceLink ? ( - - {target.source} - - - ) : ( - - {target.source} - - ) - ) : ( - - )} -
-
-
- ); -} - export default function TargetsPage() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); @@ -690,6 +141,58 @@ export default function TargetsPage() { enabled: isAuthenticated, }); + // v1.4.25 W3e — share the cache with Settings → AI section. When the + // user has no configured provider, `activeProvider` is null and the + // per-card Coach CTA hides entirely (no broken-button state). + const { data: chainStatus } = useQuery({ + queryKey: ["insights", "provider-chain"], + queryFn: async () => { + const res = await fetch("/api/insights/provider-chain"); + if (!res.ok) return null; + const json = await res.json(); + return json.data as ProviderChainStatus; + }, + enabled: isAuthenticated, + }); + const aiEnabled = chainStatus?.activeProvider != null; + + // v1.4.25 W3e — Coach drawer state owned by the page. The per-card + // CTA + summary header both feed the same drawer so the user only + // ever sees one drawer instance. + const { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach } = + useCoachHandoff(); + + const visibleTargets = useMemo(() => { + if (!data) return [] as TargetData[]; + const displayGlucoseUnit = resolveGlucoseUnit( + data.profile.glucoseUnit ?? null, + ); + return data.targets + .filter((target) => target.current != null) + .map((target) => { + if (!GLUCOSE_TYPES.has(target.type)) return target; + // Server label is an i18n key for glucose; resolve here. + const label = t(target.label); + // Convert mg/dL canonical values to the user's display unit. + const convert = (v: number | null) => + v == null ? null : convertGlucose(v, displayGlucoseUnit); + return { + ...target, + label, + unit: displayGlucoseUnit, + current: convert(target.current), + average30: convert(target.average30), + range: target.range + ? { + min: convertGlucose(target.range.min, displayGlucoseUnit), + max: convertGlucose(target.range.max, displayGlucoseUnit), + } + : null, + }; + }) + .sort((a, b) => sortKey(a) - sortKey(b)); + }, [data, t]); + if (isLoading) { return (
@@ -707,32 +210,6 @@ export default function TargetsPage() { } const profileIncomplete = !data.profile.heightCm || !data.profile.age; - const displayGlucoseUnit = resolveGlucoseUnit( - data.profile.glucoseUnit ?? null, - ); - const visibleTargets = data.targets - .filter((target) => target.current != null) - .map((target) => { - if (!GLUCOSE_TYPES.has(target.type)) return target; - // Server label is an i18n key for glucose; resolve here. - const label = t(target.label); - // Convert mg/dL canonical values to the user's display unit. - const convert = (v: number | null) => - v == null ? null : convertGlucose(v, displayGlucoseUnit); - return { - ...target, - label, - unit: displayGlucoseUnit, - current: convert(target.current), - average30: convert(target.average30), - range: target.range - ? { - min: convertGlucose(target.range.min, displayGlucoseUnit), - max: convertGlucose(target.range.max, displayGlucoseUnit), - } - : null, - }; - }); return ( // v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted @@ -766,9 +243,23 @@ export default function TargetsPage() { )} - {/* Target cards grid */} + {/* v1.4.25 W3e — page-level summary line. Renders nothing when + the API hasn't shipped pageSummary yet (older clients during + rollout / mocked test fixtures that pre-date this addition). */} + {data.pageSummary && ( + + )} + + {/* v1.4.25 W3e — responsive grid. Mobile (default): single + column. sm (640px+): two columns. lg (1024px+): three columns, + matching the dashboard / insights rhythm. Cards reflow + internally too — see . */} {visibleTargets.length > 0 ? ( -
+
{visibleTargets.map((target) => ( ))}
@@ -784,6 +278,16 @@ export default function TargetsPage() { {t("targets.noMeasurementData")}
)} + + {/* v1.4.25 W3e — Coach drawer mounted at the page level. The + drawer is fully-controlled; per-card CTAs flip `coachOpen` + and seed `coachPrefill` via the `askCoach()` hook callback. */} +
); } diff --git a/src/components/admin/__tests__/coach-feedback-section.test.tsx b/src/components/admin/__tests__/coach-feedback-section.test.tsx index 47b9d88e8..3d21af81b 100644 --- a/src/components/admin/__tests__/coach-feedback-section.test.tsx +++ b/src/components/admin/__tests__/coach-feedback-section.test.tsx @@ -107,4 +107,34 @@ describe("CoachFeedbackSection", () => { expect(html).toContain("Failed to load Coach feedback summary"); expect(html).toContain('role="alert"'); }); + + // v1.4.25 W8 regression — Marc reported a layout shift on + // /admin/coach-feedback. Root cause was the title only rendering in the + // empty + data branches, so the section grew taller (loading → data) + // and snapped down. Lock the heading + outer-card structure across + // every render state so the section maintains a constant top-of-card + // baseline. + it("keeps the section heading rendered across loading / error / empty states", () => { + for (const queryState of [ + { isLoading: true, isError: false, data: null }, + { isLoading: false, isError: true, data: null }, + { + isLoading: false, + isError: false, + data: { + generatedAt: "2026-05-10T04:00:00Z", + windowDays: 30, + coachBuckets: [], + }, + }, + ]) { + useQueryMock.mockReturnValue(queryState); + const html = render(); + // Sparkles icon + h2 title rendered in every state ⇒ constant + // header height ⇒ no layout shift when the query resolves. + expect(html).toContain("Coach Feedback"); + expect(html).toContain("bg-card"); + expect(html).toContain("rounded-xl"); + } + }); }); diff --git a/src/components/admin/__tests__/login-overview-csv.test.ts b/src/components/admin/__tests__/login-overview-csv.test.ts new file mode 100644 index 000000000..1aead558d --- /dev/null +++ b/src/components/admin/__tests__/login-overview-csv.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from "vitest"; +import { + auditLogCsvHeaderLabels, + buildAuditLogCsvRecords, + type AuditCsvEntry, + type AuditCsvLabels, +} from "../_shared"; +import { toCSV } from "@/lib/export"; + +/** + * v1.4.25 W8b — Login-Übersicht CSV export contract. + * + * The previous CSV emitted snake_case English headers and the raw UTC + * `createdAt` string, which Excel/LibreOffice misread once the `Z` + * suffix was stripped. This test pins: + * + * - The column order Marc requested: + * `timestamp → user → IP → location → provider → outcome` (email + * is absent from the audit-log API so it's intentionally skipped) + * followed by `action` + `details` for triage completeness. + * - The header row uses the translated labels from the active + * locale, not the structural keys. + * - The timestamp formatter is honoured (production passes + * `formatInUserTz` with `iso-with-offset`). + * - CSV special characters in `details` are RFC 4180-escaped. + */ + +const LABELS_EN: AuditCsvLabels = { + timestamp: "Time", + user: "Users", + ip: "IP", + location: "Location", + provider: "Provider", + outcome: "Outcome", + action: "Action", + details: "Details", + outcomeFailed: "Failed", + outcomeSuccess: "Success", + unknownUser: "Unknown", + providerLabels: { + password: "Password", + passkey: "Passkey", + api_token: "API token", + withings: "Withings", + unknown: "Unknown", + }, +}; + +const LABELS_DE: AuditCsvLabels = { + ...LABELS_EN, + timestamp: "Zeitpunkt", + user: "Benutzer", + ip: "IP", + location: "Standort", + provider: "Anbieter", + outcome: "Ergebnis", + action: "Aktion", + details: "Details", + outcomeFailed: "Fehlgeschlagen", + outcomeSuccess: "Erfolgreich", + unknownUser: "Unbekannt", + providerLabels: { + password: "Passwort", + passkey: "Passkey", + api_token: "API-Token", + withings: "Withings", + unknown: "Unbekannt", + }, +}; + +function deterministicFormatter(iso: string): string { + // Strip milliseconds, swap `Z` for `+00:00` so the test asserts the + // ISO-with-offset shape without being coupled to a specific zone. + return iso.replace(/\.\d{3}Z$/, "+00:00").replace(/Z$/, "+00:00"); +} + +describe("buildAuditLogCsvRecords", () => { + it("emits Marc's column order: timestamp → user → ip → location → provider → outcome (+ action, details)", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: "203.0.113.7", + location: "Berlin, DE", + details: null, + user: { id: "u1", username: "marc" }, + }, + ]; + const [record] = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + // Object.keys preserves insertion order in V8 / every modern JS engine + // — the column order is therefore guaranteed by `Object.keys(record)`. + expect(Object.keys(record)).toEqual([ + "timestamp", + "user", + "ip", + "location", + "provider", + "outcome", + "action", + "details", + ]); + }); + + it("maps the action to a coarse provider label using the i18n labels", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: null, + location: null, + details: null, + user: { id: "u1", username: "marc" }, + }, + { + createdAt: "2026-05-11T09:06:00.000Z", + action: "auth.bearer.success", + ipAddress: null, + location: null, + details: null, + user: { id: "u2", username: "ios" }, + }, + { + createdAt: "2026-05-11T09:07:00.000Z", + action: "auth.login.failed", + ipAddress: null, + location: null, + details: null, + user: null, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + expect(records[0].provider).toBe("Passkey"); + expect(records[1].provider).toBe("API token"); + expect(records[2].provider).toBe("Password"); + // Failed login records the failed outcome. + expect(records[0].outcome).toBe("Success"); + expect(records[2].outcome).toBe("Failed"); + // No user → unknown placeholder. + expect(records[2].user).toBe("Unknown"); + }); + + it("uses the German label set when the active locale is German", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: null, + location: null, + details: null, + user: null, + }, + ]; + const [record] = buildAuditLogCsvRecords( + entries, + LABELS_DE, + deterministicFormatter, + ); + expect(record.provider).toBe("Passkey"); + expect(record.outcome).toBe("Erfolgreich"); + expect(record.user).toBe("Unbekannt"); + }); + + it("applies the injected timestamp formatter (production uses formatInUserTz)", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.password", + ipAddress: null, + location: null, + details: null, + user: null, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_EN, + (iso) => `formatted:${iso}`, + ); + expect(records[0].timestamp).toBe("formatted:2026-05-11T09:05:00.000Z"); + }); +}); + +describe("auditLogCsvHeaderLabels", () => { + it("returns translated headers keyed by the record-key contract", () => { + const headers = auditLogCsvHeaderLabels(LABELS_EN); + expect(headers).toEqual({ + timestamp: "Time", + user: "Users", + ip: "IP", + location: "Location", + provider: "Provider", + outcome: "Outcome", + action: "Action", + details: "Details", + }); + }); +}); + +describe("toCSV(records, headerLabels) — audit-log integration", () => { + it("writes a translated header row + correct column order", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: "203.0.113.7", + location: "Berlin, DE", + details: null, + user: { id: "u1", username: "marc" }, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_DE, + deterministicFormatter, + ); + const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE)); + const lines = csv.split("\n"); + expect(lines[0]).toBe( + "Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details", + ); + expect(lines[1]).toBe( + '2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,', + ); + }); + + it("escapes commas, quotes, and newlines in the details column", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.failed", + ipAddress: null, + location: null, + // Three RFC 4180 escape triggers in one value: a comma, a + // double quote, and a newline. + details: 'reason="invalid_password", attempts: 3\nfollowup: lock', + user: null, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_EN)); + // The cell is wrapped in double quotes and embedded `"` is doubled. + expect(csv).toContain( + '"reason=""invalid_password"", attempts: 3\nfollowup: lock"', + ); + }); +}); diff --git a/src/components/admin/__tests__/login-overview-no-collapse.test.tsx b/src/components/admin/__tests__/login-overview-no-collapse.test.tsx new file mode 100644 index 000000000..ceb0488a3 --- /dev/null +++ b/src/components/admin/__tests__/login-overview-no-collapse.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.25 W8b — `/admin/login-overview` no longer renders a Collapse + * / Expand toggle. The toggle was a leftover from the v1.4 shared + * admin page where 13 sections lived on one route; collapsing the + * only card on the dedicated login-overview route hid the entire + * surface. Visiting the page is itself the opt-in. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/login-overview", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + entries: [], + meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "ADMIN", + timezone: "Europe/Berlin", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { LoginOverviewSection } from "../login-overview-section"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe("LoginOverviewSection — no collapse button", () => { + it("does not render a Collapse / Expand button (English)", () => { + const html = render("en"); + expect(html).not.toMatch(/\bCollapse\b/); + expect(html).not.toMatch(/\bExpand\b/); + }); + + it("does not render an Einklappen / Ausklappen button (German)", () => { + const html = render("de"); + expect(html).not.toMatch(/\bEinklappen\b/); + expect(html).not.toMatch(/\bAusklappen\b/); + }); + + it("never renders an `aria-expanded` button on the section header", () => { + const html = render(); + // The toggle previously surfaced as +
+ +
{t("admin.loginOverview")}
- {expanded && ( -
- {/* Quick-filter pills. Failed-only is the most common +
+ {/* Quick-filter pills. Failed-only is the most common admin "is something wrong" question — keep the one-tap shortcut. */} -
- - -
+
+ + +
- {/* Detailed filter row */} -
- { - setPage(1); - setActor(e.target.value); - }} - aria-label={t("admin.section.auditLog.filterActor")} - /> + {/* Detailed filter row */} +
+ { + setPage(1); + setActor(e.target.value); + }} + aria-label={t("admin.section.auditLog.filterActor")} + /> + + { + setPage(1); + setTarget(e.target.value); + }} + aria-label={t("admin.section.auditLog.filterTarget")} + /> + +
+ + {/* Toolbar row: per-page + export */} +
+
+ + {t("admin.section.auditLog.perPage")} + - { - setPage(1); - setTarget(e.target.value); - }} - aria-label={t("admin.section.auditLog.filterTarget")} - /> -
+ +
- {/* Toolbar row: per-page + export */} -
-
- - {t("admin.section.auditLog.perPage")} - - -
- + {isLoading ? ( +
+
- - {isLoading ? ( -
- -
- ) : !entries.length ? ( - } - title={ - filter === "failed" - ? t("admin.loginEmptyFailedTitle") - : t("admin.section.auditLog.empty") - } - description={ - filter === "failed" - ? t("admin.loginEmptyFailedDescription") - : t("admin.loginEmptyDescription") - } - action={ - filter === "failed" ? ( - - ) : undefined - } - /> - ) : ( -
- - - - - - - - + + + + + + ); + })} + +
- {t("admin.status")} - - {t("admin.users")} - - {t("admin.action")} - - {t("admin.ip")} - + ) : !entries.length ? ( + } + title={ + filter === "failed" + ? t("admin.loginEmptyFailedTitle") + : t("admin.section.auditLog.empty") + } + description={ + filter === "failed" + ? t("admin.loginEmptyFailedDescription") + : t("admin.loginEmptyDescription") + } + action={ + filter === "failed" ? ( + + ) : undefined + } + /> + ) : ( +
+ + + + + + + + + {/* + v1.4.25 W8b — Standort column. Previously rendered + `text-right`, which on wide audit tables let the + `Berlin, DE` label drift to the table edge and + blend into the timestamp gutter. Left-aligning it + (and dropping the `text-right` from IP for + consistency) makes Standort a first-class column + that admins actually notice. + */} + - - - - - {entries.map((entry, i) => { - const isFailed = entry.action === "auth.login.failed"; - return ( - + + + + + + {entries.map((entry, i) => { + const isFailed = entry.action === "auth.login.failed"; + const provider = providerForAction(entry.action); + const ProviderIcon = iconForAuthProvider(provider); + return ( + + + - - - - - - - ); - })} - -
+ {t("admin.status")} + + {t("admin.users")} + + {t("admin.action")} + + {t("admin.provider")} + + {t("admin.ip")} + + + - {t("admin.timestamp")} -
+ {t("admin.timestamp")} +
+ {isFailed ? ( + + ) : ( + + )} + - - {isFailed ? ( - - ) : ( - - )} - - {entry.user?.username ?? t("common.unknown")} - - {AUTH_ACTION_LABELS[entry.action] ?? entry.action} - - {entry.ipAddress ?? "—"} - - {entry.location ?? "—"} - - {formatDateTime(entry.createdAt)} -
-
+ {entry.user?.username ?? t("common.unknown")} + +
+ {AUTH_ACTION_LABELS[entry.action] ?? entry.action} + + + + + {entry.ipAddress ?? "—"} + + {entry.location ?? "—"} + + {formatDateTime(entry.createdAt)} +
+
+ + {t("admin.showingEntries", { + count: entries.length, + total, + })} + +
+ - {t("admin.showingEntries", { - count: entries.length, - total, + {t("admin.section.auditLog.pageOf", { + page, + total: lastPage, })} -
- - - {t("admin.section.auditLog.pageOf", { - page, - total: lastPage, - })} - - -
+
- )} -
- )} +
+ )} +
); } diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index ed7332cf9..504cea090 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -4,6 +4,7 @@ import { I18nProvider } from "@/lib/i18n/context"; import { ChartOverlayControls, + ChartOverlayControlsBody, DEFAULT_CHART_OVERLAY_PREFS, type ChartOverlayPrefs, } from "../chart-overlay-controls"; @@ -95,4 +96,135 @@ describe("", () => { expect(html).toContain('aria-label="Chart-Overlay-Einstellungen"'); }); + + /** + * v1.4.25 W3f — comparison-baseline grey-out when the parent chart + * reports `hasComparisonData=false`. The disabled state communicates + * "you selected a comparison but there's no prior-period series to + * paint" instead of silently hiding the overlay. + * + * The popover body is portalled by Radix at runtime — it's not in + * SSR markup. The tests target the extracted `` + * so the JSX inside the popover is renderable in `renderToStaticMarkup`. + */ + describe("comparison-baseline grey-out (v1.4.25 W3f)", () => { + it("renders comparison buttons in normal state when prior data exists", () => { + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastMonth", + }; + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + + expect(html).not.toContain('data-comparison-disabled="true"'); + expect(html).not.toContain('data-comparison-greyed="true"'); + expect(html).not.toContain( + 'data-slot="chart-overlay-comparison-unavailable-hint"', + ); + // The lastMonth button has no aria-disabled set when data is + // available. + expect(html).not.toMatch( + /data-slot="chart-overlay-comparison-lastMonth"[^>]*aria-disabled="true"/, + ); + }); + + it("greys out lastMonth + lastYear when prior period is empty AND a baseline is selected", () => { + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastMonth", + }; + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + + expect(html).toContain('data-comparison-disabled="true"'); + // The lastMonth + lastYear buttons grey out, "none" does not. + expect(html).toMatch( + /data-slot="chart-overlay-comparison-lastMonth"[^>]*data-comparison-greyed="true"/, + ); + expect(html).toMatch( + /data-slot="chart-overlay-comparison-lastYear"[^>]*data-comparison-greyed="true"/, + ); + // The "none" button stays enabled — it's the escape hatch. + expect(html).not.toMatch( + /data-slot="chart-overlay-comparison-none"[^>]*data-comparison-greyed="true"/, + ); + // aria-disabled + opacity-50 + tooltip surface. + expect(html).toMatch( + /data-slot="chart-overlay-comparison-lastMonth"[^>]*aria-disabled="true"/, + ); + expect(html).toContain("opacity-50"); + expect(html).toContain("No prior-period data yet"); + // The footer hint paragraph is also rendered so screen readers + // hear the explanation. + expect(html).toContain( + 'data-slot="chart-overlay-comparison-unavailable-hint"', + ); + }); + + it("keeps all buttons enabled when the baseline is 'none' even with no prior data", () => { + // The "none" baseline is the safe default — the grey-out only + // triggers once a user has opted in to comparing. + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + + expect(html).not.toContain('data-comparison-disabled="true"'); + expect(html).not.toContain('data-comparison-greyed="true"'); + expect(html).not.toContain( + 'data-slot="chart-overlay-comparison-unavailable-hint"', + ); + }); + + it("emits the localised tooltip + hint copy under de locale", () => { + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastYear", + }; + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("Kein Vorzeitraum verfügbar"); + }); + + it("defaults hasComparisonData to true when the prop is omitted", () => { + // Charts that haven't been updated to thread the flag keep the + // pre-W3f behaviour — every button stays enabled. + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastMonth", + }; + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + expect(html).not.toContain('data-comparison-disabled="true"'); + }); + }); }); diff --git a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts new file mode 100644 index 000000000..6babb1ac2 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { resolveVerticalMarkerPositions } from "../health-chart"; + +/** + * v1.4.25 W6 — GLP-1 injection-day vertical-marker pinning. + * + * The helper maps incoming `{date, label?, color?}[]` markers onto the + * bucketed chart series, returning the pointIndex of every match plus + * the marker's color/label so the chart paints exactly one + * `` per matched bucket. Differs from the storyboard + * `resolveAnnotationPositions` helper in two ways: it does an EXACT + * day-key match (no ±7-day snap, no truncation), and it defaults the + * color to the strip-tile green when omitted. + * + * Pure helper — the suite pins behaviour without mounting Recharts. + */ + +describe("resolveVerticalMarkerPositions", () => { + const dailyChartData = Array.from({ length: 10 }, (_, i) => ({ + date: `2026-04-${String(15 + i).padStart(2, "0")}`, + })); + + it("returns empty when markers is undefined", () => { + expect(resolveVerticalMarkerPositions(undefined, dailyChartData)).toEqual( + [], + ); + }); + + it("returns empty when markers is an empty array", () => { + expect(resolveVerticalMarkerPositions([], dailyChartData)).toEqual([]); + }); + + it("returns empty when chartData is undefined or empty", () => { + expect( + resolveVerticalMarkerPositions([{ date: "2026-04-15" }], undefined), + ).toEqual([]); + expect( + resolveVerticalMarkerPositions([{ date: "2026-04-15" }], []), + ).toEqual([]); + }); + + it("pins a single in-window marker to its exact day-key", () => { + const positions = resolveVerticalMarkerPositions( + [{ date: "2026-04-15" }], + dailyChartData, + ); + expect(positions).toHaveLength(1); + expect(positions[0].pointIndex).toBe(0); + expect(positions[0].color).toBe("#50fa7b"); + expect(positions[0].label).toBeUndefined(); + }); + + it("pins multiple markers in input order with the correct pointIndex", () => { + const positions = resolveVerticalMarkerPositions( + [{ date: "2026-04-15" }, { date: "2026-04-22" }, { date: "2026-04-19" }], + dailyChartData, + ); + expect(positions.map((p) => p.pointIndex)).toEqual([0, 7, 4]); + }); + + it("respects caller-supplied color + label overrides", () => { + const positions = resolveVerticalMarkerPositions( + [ + { date: "2026-04-17", label: "Mounjaro 7.5", color: "#ff79c6" }, + { date: "2026-04-19" }, + ], + dailyChartData, + ); + expect(positions[0].color).toBe("#ff79c6"); + expect(positions[0].label).toBe("Mounjaro 7.5"); + // Default color preserved for the second marker. + expect(positions[1].color).toBe("#50fa7b"); + expect(positions[1].label).toBeUndefined(); + }); + + it("drops markers whose date does not match any visible day-key", () => { + const positions = resolveVerticalMarkerPositions( + [ + { date: "2026-04-15" }, // exact match — index 0 + { date: "2026-03-01" }, // off-window + { date: "2026-05-15" }, // off-window + { date: "2026-04-24" }, // off-window (visible ends at 2026-04-24? series is 15..24) + ], + dailyChartData, + ); + // Series is 2026-04-15 .. 2026-04-24. 2026-04-24 is the last entry. + expect(positions.map((p) => p.pointIndex)).toEqual([0, 9]); + }); + + it("handles a sparse weekly-bucket series with exact-day matching only", () => { + // Weekly buckets — only the date keys that fall on a bucket day land. + const weeklyChartData = ["2026-04-15", "2026-04-22", "2026-04-29"].map( + (date) => ({ date }), + ); + const positions = resolveVerticalMarkerPositions( + [ + { date: "2026-04-22" }, // exact bucket day → matches + { date: "2026-04-23" }, // between buckets → drops (no ±7 snap) + ], + weeklyChartData, + ); + expect(positions).toHaveLength(1); + expect(positions[0].pointIndex).toBe(1); + }); +}); diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx new file mode 100644 index 000000000..20831365a --- /dev/null +++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Activity, Heart, Smile, Percent, TrendingUp } from "lucide-react"; + +import { TrendCard } from "../trend-card"; +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.4.25 W20a — regression coverage for the dashboard top-tile polish. + * + * Marc-direktive 2026-05-14 named three issues with the 5-tile top row: + * + * 1. Headings wrapped to two lines at narrow viewports (e.g. "Blood + * Pressure" on Pixel 5 / 393 px / 2-col grid). Locked the heading + * row to a deterministic `h-5` with `whitespace-nowrap` so even an + * unexpectedly long string never breaks the baseline of the value + * row below. + * 2. Trend arrow rendered as a wrapping flex child after the unit — + * sometimes wrapped to its own line, sometimes missing entirely + * when slope30 was null. Now sits in a fixed `h-4 w-4` slot with + * `items-baseline` alignment; when there's no slope yet a muted + * "—" placeholder keeps the slot reserved. + * 3. Value rows of two adjacent tiles (e.g. "Weight 80 kg" vs. + * "Blood Pressure 122 mmHg") landed at different y-coordinates + * because heading-row height was variable. The fixed heading + * height plus `text-3xl leading-none` on the value forces every + * tile in the strip to share the same baseline. + * + * These tests pin the load-bearing class names so a future refactor + * that drops `h-5` / `items-baseline` / `whitespace-nowrap` triggers a + * red CI before it reaches the dashboard. + */ + +const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 }; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" W20a baseline alignment", () => { + it("locks the heading row to a deterministic height", () => { + const html = render( + , + ); + + // h-5 is the load-bearing class — keeps the value row below at the + // same y-coordinate regardless of the label string's actual width. + expect(html).toContain("h-5"); + expect(html).toContain('data-slot="trend-card-label"'); + }); + + it("forbids wrapping on the heading row even with a long label", () => { + const html = render( + , + ); + + expect(html).toContain("whitespace-nowrap"); + expect(html).toContain("truncate"); + }); + + it("renders the value row with baseline alignment", () => { + const html = render( + , + ); + + expect(html).toContain('data-slot="trend-card-value-row"'); + expect(html).toContain("items-baseline"); + // Headline font-size + zero line-height — across-tile baseline. + expect(html).toContain("text-3xl"); + expect(html).toContain("leading-none"); + }); + + it("places the trend arrow inline at the end of the value row", () => { + const html = render( + , + ); + + expect(html).toContain('data-slot="trend-card-arrow"'); + // The arrow slot sits inside the value row, so the value-row data + // slot precedes it in the rendered HTML. + const valueRowIdx = html.indexOf('data-slot="trend-card-value-row"'); + const arrowIdx = html.indexOf('data-slot="trend-card-arrow"'); + expect(valueRowIdx).toBeGreaterThan(-1); + expect(arrowIdx).toBeGreaterThan(valueRowIdx); + }); + + it("keeps the arrow slot reserved when slope is missing", () => { + const html = render( + , + ); + + // Slot still rendered (deterministic width across the strip). + expect(html).toContain('data-slot="trend-card-arrow"'); + // Placeholder dash + muted opacity so the slot reads as "no data". + expect(html).toMatch(/opacity-30[^"]*"[^>]*>—/); + }); + + it("renders consistent layout across the five top-row tile metrics", () => { + // Snapshot-style smoke check — every tile in the top row should + // share the same heading-row height + value-row baseline class + // contract so the strip aligns visually on Pixel 5. + const tiles = [ + { label: "Weight", value: 80, unit: "kg", icon: Activity }, + { label: "BP (Sys)", value: 122, unit: "mmHg", icon: Heart }, + { label: "Pulse", value: 68, unit: "bpm", icon: TrendingUp }, + { label: "Body Fat", value: 18.5, unit: "%", icon: Percent }, + { label: "Mood", value: 4, unit: "/ 5", icon: Smile }, + ]; + + for (const tile of tiles) { + const html = render( + , + ); + + // Same heading-row height contract — `h-5` + `whitespace-nowrap`. + expect(html).toContain("h-5"); + expect(html).toContain("whitespace-nowrap"); + // Same value-row baseline contract — `items-baseline` + `text-3xl` + // + `leading-none`. Identical font geometry across all five tiles + // means the digits land at the same y-coordinate inside the grid. + expect(html).toContain("items-baseline"); + expect(html).toContain("text-3xl"); + expect(html).toContain("leading-none"); + // Same arrow slot contract — fixed width, end-anchored via `ml-auto`. + expect(html).toContain('data-slot="trend-card-arrow"'); + expect(html).toContain("ml-auto"); + } + }); +}); + +describe("dashboard.*Short keys exist across all six locales", () => { + // Defensive integrity check — the i18n-locale-integrity test guards + // top-level parity across messages/*.json, but a hand-rolled list of + // the exact short keys the dashboard tile strip pulls makes a missing + // key fail with a precise diagnostic rather than a generic "key X is + // missing in locale Y" surface. + const REQUIRED_KEYS = [ + "weightShort", + "bloodPressureSysShort", + "bloodPressureDiaShort", + "pulseShort", + "bodyFatShort", + "moodShort", + "sleepShort", + "stepsShort", + "vo2MaxShort", + "bpInTargetShort", + ]; + + // Resolved at test time so the assertion runs against the current + // bundle, not a stale cached snapshot. + const locales = ["en", "de", "fr", "es", "it", "pl"] as const; + + it.each(locales)("%s carries every dashboard.*Short key", async (locale) => { + const messages = (await import(`../../../../messages/${locale}.json`)) + .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] as string).length, + `dashboard.${key} empty in ${locale}.json`, + ).toBeGreaterThan(0); + } + }); +}); diff --git a/src/components/charts/chart-overlay-controls.tsx b/src/components/charts/chart-overlay-controls.tsx index c2d674bc6..e7b53a692 100644 --- a/src/components/charts/chart-overlay-controls.tsx +++ b/src/components/charts/chart-overlay-controls.tsx @@ -70,28 +70,27 @@ export interface ChartOverlayControlsProps { * wrapper align the cog with its title row. */ triggerClassName?: string; + /** + * v1.4.25 W3f — when the parent chart already knows the prior period + * carries no overlay-able rows, it threads `hasComparisonData=false` + * here so the comparison-baseline buttons render in a visibly + * disabled state (reduced opacity + `aria-disabled` + tooltip) + * instead of letting the user pick a value that paints nothing on + * the chart. + * + * Default `true` (i.e. enabled) so charts that never compute the + * flag (or compute it asynchronously) keep their existing behaviour. + */ + hasComparisonData?: boolean; } export function ChartOverlayControls({ prefs, onChange, triggerClassName, + hasComparisonData = true, }: ChartOverlayControlsProps): ReactElement { const { t } = useTranslations(); - const trendIndicatorId = useId(); - const trendArrowId = useId(); - const targetRangeId = useId(); - - const setToggle = ( - key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange", - value: boolean, - ): void => { - onChange({ ...prefs, [key]: value }); - }; - - const setComparisonBaseline = (value: ComparisonBaseline): void => { - onChange({ ...prefs, comparisonBaseline: value }); - }; return ( @@ -113,70 +112,135 @@ export function ChartOverlayControls({ className="w-[240px] p-3" data-slot="chart-overlay-controls-content" > - - {t("chart.overlay.controls.title")} - - -
-
- - - setToggle("showTrendIndicator", Boolean(value)) - } - data-slot="chart-overlay-toggle-trend-indicator" - /> -
-
- - - setToggle("showTrendArrow", Boolean(value)) - } - data-slot="chart-overlay-toggle-trend-arrow" - /> -
-
- - - setToggle("showTargetRange", Boolean(value)) - } - data-slot="chart-overlay-toggle-target-range" - /> -
-
- -
- -
+ + + ); +} + +/** + * v1.4.25 W3f — the dropdown body is extracted so unit tests can render + * it without spinning up Radix's portalled popover (which is invisible + * to `renderToStaticMarkup`). Production callers always go through + * `` above; the body export is purely a test + * affordance + a future hook for in-line (non-popover) renders. + */ +export interface ChartOverlayControlsBodyProps { + prefs: ChartOverlayPrefs; + onChange: (next: ChartOverlayPrefs) => void; + hasComparisonData?: boolean; +} + +export function ChartOverlayControlsBody({ + prefs, + onChange, + hasComparisonData = true, +}: ChartOverlayControlsBodyProps): ReactElement { + const { t } = useTranslations(); + const trendIndicatorId = useId(); + const trendArrowId = useId(); + const targetRangeId = useId(); + + // v1.4.25 W3f — when the underlying prior-period series is empty for + // BOTH lastMonth and lastYear (we only know about the currently + // selected baseline), grey out the non-"none" buttons. We can only + // verify the active baseline truthfully, so the disabled state is + // gated on `comparisonBaseline !== "none" && !hasComparisonData`: + // the user has already opted in to comparing and we discovered the + // prior period is empty. From the "none" baseline we keep all + // buttons enabled so the user can still toggle in for the chart to + // re-evaluate. + const comparisonGreyOut = + prefs.comparisonBaseline !== "none" && !hasComparisonData; + const disabledTooltip = t("chart.overlay.controls.comparisonUnavailable"); + + const setToggle = ( + key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange", + value: boolean, + ): void => { + onChange({ ...prefs, [key]: value }); + }; + + const setComparisonBaseline = (value: ComparisonBaseline): void => { + onChange({ ...prefs, comparisonBaseline: value }); + }; + + return ( + <> + + {t("chart.overlay.controls.title")} + + +
+
+ + + setToggle("showTrendIndicator", Boolean(value)) + } + data-slot="chart-overlay-toggle-trend-indicator" + /> +
+
+ + + setToggle("showTrendArrow", Boolean(value)) + } + data-slot="chart-overlay-toggle-trend-arrow" + /> +
+
+ + + setToggle("showTargetRange", Boolean(value)) + } + data-slot="chart-overlay-toggle-target-range" + /> +
+
+ +
+ +
+ {COMPARISON_BASELINES.map((value) => { + // The "none" choice never greys out — it's the safe + // escape hatch when prior data is missing. + const isNoneOption = value === "none"; + const isGreyed = comparisonGreyOut && !isNoneOption; + return ( - ))} -
+ ); + })}
- - + {comparisonGreyOut && ( +

+ {disabledTooltip} +

+ )} +
+ ); } diff --git a/src/components/charts/compliance-charts.tsx b/src/components/charts/compliance-charts.tsx deleted file mode 100644 index 2af43b6bb..000000000 --- a/src/components/charts/compliance-charts.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; -import { Loader2 } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; -import { ComplianceHeatmap } from "./compliance-heatmap"; -import { ComplianceLineChart } from "./compliance-line-chart"; - -interface DailyData { - expected: number; - taken: number; - skipped: number; - onTime?: number; - late?: number; - veryLate?: number; -} - -interface ComplianceData { - dailyCompliance: Record; -} - -interface Medication { - id: string; - name: string; - dose: string; - active: boolean; -} - -interface ComplianceChartsProps { - medications: Medication[]; -} - -export function ComplianceCharts({ medications }: ComplianceChartsProps) { - const { t } = useTranslations(); - const [selectedId, setSelectedId] = useState(medications[0]?.id ?? ""); - const [rangePoints, setRangePoints] = useState<30 | 90 | 0>(30); - - const { data, isLoading } = useQuery({ - queryKey: ["compliance-chart", selectedId], - queryFn: async () => { - const res = await fetch(`/api/medications/${selectedId}/compliance`); - if (!res.ok) return null; - const json = await res.json(); - return json.data as ComplianceData; - }, - enabled: !!selectedId, - staleTime: 60 * 1000, - }); - - if (medications.length === 0) return null; - - return ( -
-
- -
- - {isLoading ? ( -
- -
- ) : !data?.dailyCompliance ? ( -
- {t("charts.noComplianceData")} -
- ) : ( -
-
-
-

- {t("charts.complianceDays90")} -

-
- -
-
-
-

- {t("charts.history")} -

-
- - - -
-
- -
-
- )} -
- ); -} diff --git a/src/components/charts/compliance-line-chart.tsx b/src/components/charts/compliance-line-chart.tsx index cbba86eec..9932c2c89 100644 --- a/src/components/charts/compliance-line-chart.tsx +++ b/src/components/charts/compliance-line-chart.tsx @@ -12,9 +12,10 @@ import { ReferenceLine, } from "recharts"; import { Button } from "@/components/ui/button"; -import { formatDateShort } from "@/lib/format"; import { useViewportWidth } from "@/hooks/use-viewport-width"; import { chooseTickInterval } from "@/lib/charts/x-axis-density"; +import { useTranslations } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; interface DailyData { expected: number; @@ -30,6 +31,12 @@ interface ComplianceLineChartProps { rangePoints?: 30 | 90 | 0; onRangePointsChange?: (value: 30 | 90 | 0) => void; showRangeControls?: boolean; + /** + * v1.4.25 W7b — per-user display timezone for the X-axis labels. + * Defaults to "Europe/Berlin" so older callers keep their previous + * rendering bit-for-bit. + */ + userTimezone?: string; } const TIME_RANGES = [ @@ -43,6 +50,7 @@ export function ComplianceLineChart({ rangePoints, onRangePointsChange, showRangeControls = true, + userTimezone = "Europe/Berlin", }: ComplianceLineChartProps) { const [internalRangePoints, setInternalRangePoints] = useState<30 | 90 | 0>( 30, @@ -50,6 +58,13 @@ export function ComplianceLineChart({ const activeRangePoints = rangePoints ?? internalRangePoints; const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints; const viewportWidth = useViewportWidth(); + const { locale } = useTranslations(); + // v1.4.25 W7b — tz-aware date label for the X-axis. Falls back to + // legacy Europe/Berlin when the prop is omitted. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); const chartData = useMemo(() => { const points = Object.entries(dailyCompliance) @@ -58,14 +73,14 @@ export function ComplianceLineChart({ .map(([dateKey, data]) => { const date = new Date(`${dateKey}T12:00:00.000Z`); return { - date: formatDateShort(date, true), + date: tzFmt.date(date), rate: Math.min(100, Math.round((data.taken / data.expected) * 100)), timestamp: date.getTime(), }; }); return activeRangePoints > 0 ? points.slice(-activeRangePoints) : points; - }, [dailyCompliance, activeRangePoints]); + }, [dailyCompliance, activeRangePoints, tzFmt]); return (
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 8fd90f54e..5da206e54 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -13,6 +13,7 @@ import { Legend, ReferenceLine, ReferenceArea, + ReferenceDot, } from "recharts"; import { Loader2 } from "lucide-react"; import { useState, useMemo } from "react"; @@ -20,8 +21,8 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { Button } from "@/components/ui/button"; -import { formatDateShort } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import { bucketTimeSeries, pickBucket, @@ -138,6 +139,37 @@ interface HealthChartProps { * that needs the same behaviour). */ annotations?: Array<{ date: string; label: string; color: string }>; + /** + * v1.4.25 W6 — vertical injection-day markers. + * + * Each entry pins a thin dashed vertical reference line on the chart + * at the data point whose Berlin/user-tz day-key matches `date` + * (`YYYY-MM-DD`). Designed for the GLP-1 dashboard tile + the + * Insights /medikamente sub-page so users can see "I injected on + * these days; here's how my weight responded". Differs from + * `annotations` in three ways: + * 1. No text label by default — these markers represent events + * the user already saw the chart-line shape for; an inline + * label per marker would crowd the canvas. + * 2. A small filled dot sits at the x-axis intersection (mood- + * chart style emoji-on-axis pattern repurposed) so a row of + * green dots reads as "injection cadence" at a glance. + * 3. Default green (`#50fa7b`) to match the strip-tile palette + * for active medications; callers can override per marker. + * + * Off-window markers silently drop via `ifOverflow="discard"`, + * matching the existing annotations contract. + */ + verticalMarkers?: Array<{ date: string; label?: string; color?: string }>; + /** + * v1.4.25 W7b — per-user display timezone. When passed (mount sites + * thread `useAuth().user?.timezone`), x-axis tick labels and the + * per-day bucket keys both render in the user's zone instead of the + * legacy Europe/Berlin pin. Defaults to "Europe/Berlin" so older + * callers that haven't yet adopted the prop keep their previous + * behaviour bit-for-bit. + */ + userTimezone?: string; } interface ChartDataPoint { @@ -191,15 +223,22 @@ function getTypeLabel( return key ? t(key) : type; } -const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", -}); +/** + * Build a `YYYY-MM-DD` day-key formatter pinned to the requested + * timezone. Memoised per-tz inside the component so we don't allocate a + * new `Intl.DateTimeFormat` on every measurement-row pass. + */ +function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat { + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} -function toBerlinDayKey(value: string): string { - const parts = BERLIN_DAY_FORMATTER.formatToParts(new Date(value)); +function toDayKey(value: string, formatter: Intl.DateTimeFormat): string { + const parts = formatter.formatToParts(new Date(value)); const year = parts.find((part) => part.type === "year")?.value; const month = parts.find((part) => part.type === "month")?.value; const day = parts.find((part) => part.type === "day")?.value; @@ -275,6 +314,53 @@ export interface ResolvedAnnotation { truncatedLabel: string; } +/** + * v1.4.25 W6 — pure helper for the GLP-1 injection-day vertical + * markers. Exported so the unit suite can pin behaviour without + * spinning Recharts. The shape mirrors `resolveAnnotationPositions` + * minus the truncated-label slot. + * + * `chartData[i].date` is the `YYYY-MM-DD` day-key the chart already + * computed when bucketing measurements; we match by exact key so a + * marker only lands on a day the chart actually has a data point for. + * This is intentional — drawing a vertical line through a gap in the + * x-axis paints a hanging line with no data context, which reads as a + * rendering bug not as orientation. + * + * Off-window markers silently drop (the chart's `` also + * uses `ifOverflow="discard"` as a belt-and-braces guard). + */ +export interface ResolvedVerticalMarker { + pointIndex: number; + color: string; + label: string | undefined; +} + +export function resolveVerticalMarkerPositions( + markers: Array<{ date: string; label?: string; color?: string }> | undefined, + chartData: Array<{ date: string }> | undefined, +): ResolvedVerticalMarker[] { + if (!markers || !chartData || chartData.length === 0) return []; + const indexByDate = new Map(); + for (const [i, point] of chartData.entries()) { + // Last-write-wins — multiple bucket-aggregated points should never + // share the same day key, but defensively keep the latest if they + // do. + indexByDate.set(point.date, i); + } + const out: ResolvedVerticalMarker[] = []; + for (const marker of markers) { + const idx = indexByDate.get(marker.date); + if (idx === undefined) continue; + out.push({ + pointIndex: idx, + color: marker.color ?? "#50fa7b", + label: marker.label, + }); + } + return out; +} + export function resolveAnnotationPositions( annotations: | Array<{ date: string; label: string; color: string }> @@ -367,10 +453,27 @@ export function HealthChart({ compareBaseline = "none", chartKey, annotations, + verticalMarkers, + userTimezone = "Europe/Berlin", }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); + // v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip + // date strings. `useFormatters()` reads the active UI locale only; + // this builds a locale + userTz pair so a Pacific/Auckland user reads + // their own day on the axis. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); + // v1.4.25 W7b — per-row day-key formatter used to bucket measurement + // rows by the user's local calendar day. Memoised on userTimezone so + // the inner per-row loop reuses a single Intl.DateTimeFormat. + const dayKeyFormatter = useMemo( + () => makeDayKeyFormatter(userTimezone), + [userTimezone], + ); // v1.4.16 B5c: when a windowOverride is supplied, seed the range // state from it so the chart pins to that window. Mini mode also // hides the range tabs, so the user can't change it. @@ -407,6 +510,10 @@ export function HealthChart({ types.join(","), valueMode, bmiDivisor ?? "no-bmi", + // v1.4.25 W7b — bucket keys + tick labels depend on the active + // user timezone, so re-key the cache when it changes. Without + // this, a tz change inside a session would render stale buckets. + userTimezone, ], queryFn: async () => { const params = new URLSearchParams(); @@ -451,7 +558,7 @@ export function HealthChart({ continue; } - const dayKey = toBerlinDayKey(measurement.measuredAt); + const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter); const bucket = dailyAggregates.get(dayKey) ?? { timestamp: dayKeyToTimestamp(dayKey), values: {}, @@ -475,7 +582,7 @@ export function HealthChart({ const allData: ChartDataPoint[] = Array.from(dailyAggregates.values()) .map((bucket) => { const point: ChartDataPoint = { - date: formatDateShort(new Date(bucket.timestamp)), + date: tzFmt.dateShort(new Date(bucket.timestamp)), timestamp: bucket.timestamp, }; @@ -525,7 +632,7 @@ export function HealthChart({ ).points.map((point) => { const date = new Date(point.timestamp); const out: ChartDataPoint = { - date: formatDateShort(date), + date: tzFmt.dateShort(date), timestamp: point.timestamp, }; for (const [type, value] of Object.entries(point.values)) { @@ -590,7 +697,7 @@ export function HealthChart({ } return enriched; - }, [data, rangePoints, showMA, showTrend, types]); + }, [data, rangePoints, showMA, showTrend, types, tzFmt]); // v1.4.16 phase B8 — comparison overlay. // @@ -624,7 +731,7 @@ export function HealthChart({ // Index shifted rows by the same day-key the chart already uses. const shiftedByDay = new Map>(); for (const row of shifted) { - const dayKey = formatDateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShort(new Date(row.timestamp)); const slot = shiftedByDay.get(dayKey) ?? {}; for (const [type, value] of Object.entries(row.values)) { if (typeof value === "number" && Number.isFinite(value)) { @@ -646,7 +753,7 @@ export function HealthChart({ } return merged; }); - }, [chartData, effectiveCompareBaseline, data, types]); + }, [chartData, effectiveCompareBaseline, data, types, tzFmt]); /** * v1.4.16 phase B8 — true when at least one visible day has a prior- @@ -918,6 +1025,16 @@ export function HealthChart({ chartData, ); + // v1.4.25 W6 — GLP-1 injection-day vertical markers. Pure-helper + // pattern lets the chart-tests pin the marker resolution without + // mounting Recharts. Off-window markers silently drop here so the + // below never paints a line for an out-of-range + // day. + const verticalMarkerPositions = resolveVerticalMarkerPositions( + verticalMarkers, + chartData, + ); + const showContextDetails = showMA || showTrend || showBands; const animationsEnabled = !prefersReducedMotion(); @@ -1019,6 +1136,7 @@ export function HealthChart({ ) : null}
@@ -1101,11 +1219,10 @@ export function HealthChart({ tickLine={false} axisLine={false} tickFormatter={(value) => - formatDateShort( + tzFmt.date( new Date( chartData?.[Math.round(value)]?.timestamp ?? Date.now(), ), - true, ) } interval={chooseTickInterval( @@ -1203,6 +1320,47 @@ export function HealthChart({ }} /> ))} + {/* v1.4.25 W6 — GLP-1 injection-day vertical markers. + Thin dashed line + a small filled dot at the x-axis + intersection so a row of markers reads as "injection + cadence" without crowding the canvas. Optional label + rendered only when the caller passed one; the + dashboard tile leaves it undefined (the date is + redundant — the chart's x-axis already shows it). */} + {verticalMarkerPositions.map((marker, i) => ( + + ))} + {yDomain && + verticalMarkerPositions.map((marker, i) => ( + + ))} {/* v1.4.18 — personal-baseline reference line is now opt-in via the Trend toggle. the maintainer rejected the always-on dashed mean line; it now only paints when @@ -1267,9 +1425,7 @@ export function HealthChart({ (typeof rechartsLabel === "number" ? chartData?.[Math.round(rechartsLabel)]?.timestamp : undefined); - const dateLabel = ts - ? formatDateShort(new Date(ts), true) - : ""; + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; const rows: RichTooltipRow[] = []; // Build a quick lookup of compare values for this // hover-day so the current-period row can attach diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index b1eedb56a..9eb63a74b 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -49,6 +49,7 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { formatDateShort } from "@/lib/format"; +import { makeFormatters } from "@/lib/format-locale"; import { cn } from "@/lib/utils"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; @@ -86,21 +87,35 @@ type RangeDays = (typeof RANGE_DAYS)[number]; * intakes are skipped — compliance is undefined when nothing was due. * * Pure & deterministic so the unit test pins exact rates. + * + * v1.4.25 W7b — the `dateFormatter` argument lets the chart pass a + * tz-aware formatter so a Pacific/Auckland user's X-axis tick label + * for "2026-05-15" reads as 15.05. in their tz. Default keeps the + * legacy `formatDateShort` so the existing unit test (which calls the + * exported helper without arguments) stays byte-identical. */ export function aggregateMedicationCompliance( points: DailyCompliancePoint[], + dateFormatter: (date: Date) => string = (d) => formatDateShort(d, false), ): ChartPoint[] { return points .filter((p) => p.scheduled > 0) .sort((a, b) => a.date.localeCompare(b.date)) .map((p) => { const [y, m, d] = p.date.split("-").map(Number); - // Anchor the timestamp at noon UTC of the Berlin day so a tooltip - // and tick formatter never disagree across DST boundaries. + // Anchor the timestamp at noon UTC of the (server-side) calendar + // day so a tooltip and tick formatter never disagree across DST + // boundaries. const ts = Date.UTC(y, m - 1, d, 12); const rate = Math.min(100, Math.round((p.taken / p.scheduled) * 100)); return { - date: formatDateShort(new Date(ts), true), + // v1.4.25 W3b — short-form day label ("10.05." / "10. May") so + // the X-axis ticks read cleanly on mobile even when the tick + // density helper steps to every-7th or every-14th day. The + // tooltip below still derives its label from `timestamp` with + // `includeYear=true`, so the full date is preserved for the + // tooltip read. + date: dateFormatter(new Date(ts)), rate, timestamp: ts, }; @@ -165,15 +180,28 @@ interface MedicationComplianceChartProps { * thinking the toggle is broken. */ compareBaseline?: "none" | "lastMonth" | "lastYear"; + /** + * v1.4.25 W7b — per-user display timezone for x-axis tick + tooltip + * date strings. Defaults to "Europe/Berlin" so older callers stay + * byte-identical. + */ + userTimezone?: string; } export function MedicationComplianceChart({ title, + userTimezone = "Europe/Berlin", }: MedicationComplianceChartProps) { const { isAuthenticated } = useAuth(); - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const [days, setDays] = useState(30); + // v1.4.25 W7b — tz-aware date formatter for the x-axis labels and + // the tooltip's `dateLabel`. Same pattern as health-chart + mood-chart. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); // v1.4.18 — three overlay toggles persisted per chart. The 7-day // trend chip and the goal/threshold reference lines used to render @@ -204,8 +232,11 @@ export function MedicationComplianceChart({ }); const chartData = useMemo( - () => (data ? aggregateMedicationCompliance(data) : []), - [data], + () => + data + ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShort(d)) + : [], + [data, tzFmt], ); // v1.4.16 A6 — 7-day trend chip. Computed off the *full* range so a @@ -306,10 +337,16 @@ export function MedicationComplianceChart({ ))} {/* v1.4.18 — overlay-controls dropdown next to the range - tabs. */} + tabs. + v1.4.25 W3f — the compliance card never paints a + prior-period overlay (the heatmap doesn't support it), + so the comparison buttons grey out when a baseline is + already selected to signal "this metric has no + comparison available". */}
@@ -389,9 +426,7 @@ export function MedicationComplianceChart({ }; if (!active || !payload?.length) return null; const ts = payload[0]?.payload?.timestamp; - const dateLabel = ts - ? formatDateShort(new Date(ts), true) - : ""; + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; const rate = payload[0]?.value; if (typeof rate !== "number") return null; // Delta vs. the 100 % goal — a positive delta means diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index a1c98d459..bdb5648cd 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -17,8 +17,8 @@ import { Loader2 } from "lucide-react"; import { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { formatDateShort } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import type { DataSummary } from "@/lib/analytics/trends"; import { bucketTimeSeries, @@ -84,6 +84,12 @@ interface MoodChartProps { * only surface — the cog stays hidden and overlays are clean. */ chartKey?: ChartOverlayKey; + /** + * v1.4.25 W7b — per-user display timezone. When passed, the x-axis + * tick labels and tooltip date render in the user's tz. Defaults to + * "Europe/Berlin" so older mount sites stay byte-identical. + */ + userTimezone?: string; } // --- Constants --- @@ -275,9 +281,16 @@ export function MoodChart({ windowOverride, compareBaseline = "none", chartKey, + userTimezone = "Europe/Berlin", }: MoodChartProps) { const { isAuthenticated } = useAuth(); - const { t } = useTranslations(); + const { t, locale } = useTranslations(); + // v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip + // date strings. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); const initialRangePoints = windowOverride ? MINI_RANGE_POINTS[windowOverride] : 30; @@ -313,7 +326,7 @@ export function MoodChart({ const allPoints: ChartDataPoint[] = data.entries .map((entry) => ({ - date: formatDateShort(new Date(dayKeyToTimestamp(entry.date))), + date: tzFmt.dateShort(new Date(dayKeyToTimestamp(entry.date))), timestamp: dayKeyToTimestamp(entry.date), pointIndex: 0, score: entry.score, @@ -349,7 +362,7 @@ export function MoodChart({ })), { bucket: bucketType }, ).points.map((point) => ({ - date: formatDateShort(new Date(point.timestamp)), + date: tzFmt.dateShort(new Date(point.timestamp)), timestamp: point.timestamp, pointIndex: 0, score: point.values.score, @@ -401,7 +414,7 @@ export function MoodChart({ } return enriched; - }, [data, rangePoints, showMA, showTrend]); + }, [data, rangePoints, showMA, showTrend, tzFmt]); /** * v1.4.16 phase B8 — comparison overlay merged into chartData. @@ -426,7 +439,7 @@ export function MoodChart({ const shiftedByDay = new Map(); for (const row of shifted) { - const dayKey = formatDateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShort(new Date(row.timestamp)); if (typeof row.score === "number" && Number.isFinite(row.score)) { shiftedByDay.set(dayKey, row.score); } @@ -439,7 +452,7 @@ export function MoodChart({ } return point; }); - }, [chartData, effectiveCompareBaseline, data]); + }, [chartData, effectiveCompareBaseline, data, tzFmt]); const hasComparisonData = useMemo(() => { if (effectiveCompareBaseline === "none" || !chartDataWithCompare) @@ -591,6 +604,7 @@ export function MoodChart({ ) : null}
@@ -621,10 +635,32 @@ export function MoodChart({ data={chartDataWithCompare ?? chartData} margin={{ top: 10, right: 8, bottom: 8, left: 8 }} > + {/* v1.4.25 W3 — the mood chart's YAxis is pinned to + five mood-score ticks ([1,2,3,4,5]), which Recharts + syncs with CartesianGrid by default. That left the + mini variant painting five horizontal lines while + the BP / Weight / Pulse minis painted six (their + auto-generated YAxis ticks land on six bands in + typical health ranges). The explicit coordinates + generator below produces six evenly-spaced lines + so the trends row reads as a single rhythm. */} { + // `offset` carries the chart's plot-area metrics. + // Six evenly-spaced y-coordinates inside the plot + // area (top + 4 intermediate + bottom) match the + // density of the BP/Weight/Pulse minis. + const top = offset.top ?? 0; + const height = offset.height ?? 0; + if (height <= 0) return []; + const lines = 6; + return Array.from({ length: lines }, (_, i) => + Math.round(top + (height * i) / (lines - 1)), + ); + }} /> {showBands && VALUE_BANDS.map((band) => ( @@ -667,11 +703,10 @@ export function MoodChart({ tickLine={false} axisLine={false} tickFormatter={(value: number) => - formatDateShort( + tzFmt.date( new Date( chartData[Math.round(value)]?.timestamp ?? Date.now(), ), - true, ) } interval={chooseTickInterval( @@ -719,9 +754,7 @@ export function MoodChart({ (typeof rechartsLabel === "number" ? chartData?.[Math.round(rechartsLabel)]?.timestamp : undefined); - const dateLabel = ts - ? formatDateShort(new Date(ts), true) - : ""; + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; const rows: RichTooltipRow[] = []; const hoverPoint = payload[0]?.payload as | ChartDataPoint diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx index 4ae92e7a0..e6bc21039 100644 --- a/src/components/charts/scatter-correlation-chart.tsx +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -86,7 +86,7 @@ export function ScatterCorrelationChart>({
- + >({ /> -
- + {/* v1.4.25 W20a — single-line discipline + deterministic height for + the heading row so the value row below sits at the same baseline + across every tile. The label is locale-abbreviated upstream (see + `dashboard.*Short` keys) so `truncate` is a layout safeguard + rather than the primary defence against wraps. `h-5` matches + `text-xs leading-5` ≈ 20 px; identical across every tile. */} +
+ {label}
-
- + {/* v1.4.25 W20a — baseline-aligned value row. The headline value, + unit, and trend arrow share a single flex line with + `items-baseline` so the digits line up at the same y-coordinate + across every tile in the strip (Weight 80 kg vs. BP 122 mmHg used + to land at different heights when one tile had a one-line heading + and another had a two-line wrap). The arrow slot is always + present — when there is no slope yet, a muted "—" placeholder + renders so the value row keeps a deterministic width. Marc's + ask: "rechts neben der Zahl … der Pfeil [ist], wie gerade der + Trend ist". */} +
+ {latest !== null ? renderPair(latest, secondary?.latest) : "—"} - + {unit} - {slope30 && } +
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own line below the latest value so the tile stays scannable on diff --git a/src/components/dashboard/__tests__/glp1-tile.test.tsx b/src/components/dashboard/__tests__/glp1-tile.test.tsx new file mode 100644 index 000000000..0123f6da2 --- /dev/null +++ b/src/components/dashboard/__tests__/glp1-tile.test.tsx @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.4.25 W6 — `` rendering contract. + * + * Suite pins three scenarios: + * 1. No active GLP-1 medication → the tile suppresses itself + * entirely (returns null, no DOM output). + * 2. Single active GLP-1 medication (Mounjaro 7.5 mg, weekly) → + * drug + dose caption, last + next injection lines, weight + * delta caption, vertical injection markers wired to the chart. + * 3. Dose-changed mid-period (Mounjaro 5 mg → 7.5 mg titration) → + * the displayed current-dose follows the latest titration row + * (the snapshot already does this server-side; the tile just + * surfaces it). + * + * Approach: mock the React Query hook + the auth hook so the suite + * can render `` to static markup and assert the visible + * DOM. The chart itself is dynamic-imported so it's a `
` shell + * in SSR — we assert the data-slot wires by reading the surrounding + * markup rather than the rendered chart canvas. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { id: "marc", timezone: "Europe/Berlin" }, + }), +})); + +let queryReturn: { data: unknown; isPending: boolean } = { + data: null, + isPending: false, +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => queryReturn, +})); + +// `next/dynamic` returns the lazy-loaded component reference; for +// renderToStaticMarkup the dynamic shell is a sufficient placeholder +// (we never render the chart canvas — only assert the tile wires). +vi.mock("next/dynamic", () => ({ + __esModule: true, + default: () => { + const Mock = () => null; + Mock.displayName = "DynamicChart"; + return Mock; + }, +})); + +import { Glp1Tile } from "../glp1-tile"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + queryReturn = { data: null, isPending: false }; +}); + +describe("", () => { + it("renders nothing when the user has no active GLP-1 medication", () => { + queryReturn = { data: null, isPending: false }; + const html = render(); + // The route returns `data: null` for users with no active GLP-1. + // The tile must suppress itself entirely so the dashboard layout + // doesn't paint a hollow card. + expect(html).toBe(""); + }); + + it("renders nothing when the medications array is empty", () => { + queryReturn = { + data: { active: false, medications: [] }, + isPending: false, + }; + const html = render(); + expect(html).toBe(""); + }); + + it("renders a skeleton while the fetch is pending", () => { + queryReturn = { data: undefined, isPending: true }; + const html = render(); + expect(html).toContain("glp1-tile-skeleton"); + }); + + it("renders drug + dose + injection captions for a single active GLP-1", () => { + queryReturn = { + data: { + active: true, + medications: [ + { + name: "Mounjaro", + genericName: "tirzepatide", + medicationId: "med-1", + currentDose: { + value: 7.5, + unit: "mg", + since: "2026-04-01", + weeksOnDose: 6, + }, + doseHistory: [ + { + value: 2.5, + unit: "mg", + effectiveFrom: "2026-01-01", + note: null, + }, + { + value: 5, + unit: "mg", + effectiveFrom: "2026-02-01", + note: null, + }, + { + value: 7.5, + unit: "mg", + effectiveFrom: "2026-04-01", + note: null, + }, + ], + lastInjection: { + date: "2026-05-10", + site: "ABDOMEN_LEFT", + weeksAgo: 0, + }, + nextInjection: { date: "2026-05-17", daysAway: 3 }, + startWeight: 92.0, + currentWeight: 87.8, + weightDeltaKg: -4.2, + weightSeries: [ + { date: "2026-01-01", weight: 92.0 }, + { date: "2026-05-13", weight: 87.8 }, + ], + injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"], + }, + ], + }, + isPending: false, + }; + const html = render(); + expect(html).toContain('data-slot="glp1-tile"'); + expect(html).toContain('data-medication-id="med-1"'); + // Drug-and-dose caption. + expect(html).toContain("Mounjaro 7.5mg"); + // The "−4.2 kg since start" delta caption with the loss tone. + expect(html).toContain('data-tone="loss"'); + expect(html).toContain("−4.2 kg since start"); + // Last + next injection slots wired into the DOM. + expect(html).toContain('data-slot="glp1-tile-last"'); + expect(html).toContain('data-slot="glp1-tile-next"'); + expect(html).toContain('data-slot="glp1-tile-chart"'); + }); + + it("reflects the LATEST titration step when the dose changed mid-period", () => { + // Mounjaro titration: 2.5 → 5 → 7.5 mg. The most recent dose-change + // row dictates the tile's headline dose. The route's snapshot + // already does this server-side; this test pins the tile's + // dependence on `currentDose.value` rather than e.g. the first + // entry in doseHistory. + queryReturn = { + data: { + active: true, + medications: [ + { + name: "Mounjaro", + genericName: "tirzepatide", + medicationId: "med-1", + currentDose: { + value: 10, + unit: "mg", + since: "2026-05-01", + weeksOnDose: 1, + }, + doseHistory: [ + { + value: 2.5, + unit: "mg", + effectiveFrom: "2026-01-01", + note: null, + }, + { + value: 5, + unit: "mg", + effectiveFrom: "2026-02-01", + note: null, + }, + { + value: 7.5, + unit: "mg", + effectiveFrom: "2026-03-01", + note: null, + }, + { + value: 10, + unit: "mg", + effectiveFrom: "2026-05-01", + note: "tolerated 10 mg step-up", + }, + ], + lastInjection: null, + nextInjection: null, + startWeight: 95.0, + currentWeight: 88.0, + weightDeltaKg: -7.0, + weightSeries: [ + { date: "2026-01-01", weight: 95.0 }, + { date: "2026-05-13", weight: 88.0 }, + ], + injectionDates: [], + }, + ], + }, + isPending: false, + }; + const html = render(); + // Latest titration step (10 mg) wins — not the first entry (2.5). + expect(html).toContain("Mounjaro 10mg"); + expect(html).not.toContain("Mounjaro 2.5mg"); + // weightDelta = -7.0 → loss tone with the "since start" suffix. + expect(html).toContain('data-tone="loss"'); + }); + + it("falls back to the medication name alone when no current dose is set", () => { + // Some legacy meds have no titration history; the tile should + // still render the rest of the info without crashing on the + // missing dose row. + queryReturn = { + data: { + active: true, + medications: [ + { + name: "Ozempic", + genericName: "semaglutide", + medicationId: "med-1", + currentDose: null, + doseHistory: [], + lastInjection: null, + nextInjection: null, + startWeight: null, + currentWeight: null, + weightDeltaKg: null, + weightSeries: [], + injectionDates: [], + }, + ], + }, + isPending: false, + }; + const html = render(); + expect(html).toContain("Ozempic"); + // No "mg" suffix because we have no dose value. + expect(html).not.toContain("Ozempic 0mg"); + // No weight delta caption when both sides are null. + expect(html).not.toContain('data-slot="glp1-tile-delta"'); + // No chart when the series is empty. + expect(html).not.toContain('data-slot="glp1-tile-chart"'); + }); + + it("renders German copy under the de locale", () => { + queryReturn = { + data: { + active: true, + medications: [ + { + name: "Mounjaro", + genericName: "tirzepatide", + medicationId: "med-1", + currentDose: { + value: 7.5, + unit: "mg", + since: "2026-04-01", + weeksOnDose: 6, + }, + doseHistory: [], + lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 }, + nextInjection: { date: "2026-05-17", daysAway: 3 }, + startWeight: 92.0, + currentWeight: 87.8, + weightDeltaKg: -4.2, + weightSeries: [{ date: "2026-05-13", weight: 87.8 }], + injectionDates: [], + }, + ], + }, + isPending: false, + }; + const html = render("de"); + expect(html).toContain("GLP-1-Therapie"); + expect(html).toContain("Letzte Injektion"); + expect(html).toContain("Nächste Injektion"); + expect(html).toContain("seit Beginn"); + }); +}); diff --git a/src/components/dashboard/glp1-tile.tsx b/src/components/dashboard/glp1-tile.tsx new file mode 100644 index 000000000..5c2597fdc --- /dev/null +++ b/src/components/dashboard/glp1-tile.tsx @@ -0,0 +1,338 @@ +"use client"; + +/** + * v1.4.25 W6 — GLP-1 status tile. + * + * Renders only when the user has at least one active GLP-1 medication + * (`Medication.treatmentClass = "GLP1"`). The tile shows: + * - drug name + current dose ("Mounjaro 7.5mg") + * - last injection date + weekday + * - next injection date + weekday + countdown + * - weight delta since starting the medication ("−4.2 kg seit Beginn") + * - a compact weight chart with vertical injection-day markers so the + * user sees the dose-response visually + * + * Self-hides when: + * - no GLP-1 medication is active (route returns `data: null`) + * - the fetch is still pending (renders a skinny skeleton so the + * dashboard layout doesn't jump) + * - the fetch errored (silently — the tile is enrichment, not a + * hard requirement, and the rest of the dashboard works without + * it) + * + * The chart lives INSIDE the tile per Marc's directive 2026-05-14: + * "NO chart on the Medications page itself. The chart goes on the + * Dashboard tile and into the Insights /medikamente sub-page". + */ +import { useQuery } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; +import { useMemo } from "react"; +import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; + +const HealthChart = dynamic( + () => + import("@/components/charts/health-chart").then((mod) => ({ + default: mod.HealthChart, + })), + { ssr: false }, +); + +interface DoseHistoryEntry { + value: number; + unit: string; + effectiveFrom: string; + note: string | null; +} + +interface CurrentDose { + value: number; + unit: string; + since: string; + weeksOnDose: number; +} + +interface LastInjection { + date: string; + site: string | null; + weeksAgo: number; +} + +interface NextInjection { + date: string; + daysAway: number; +} + +interface Glp1MedicationPayload { + name: string; + genericName: string; + medicationId: string | null; + currentDose: CurrentDose | null; + doseHistory: DoseHistoryEntry[]; + lastInjection: LastInjection | null; + nextInjection: NextInjection | null; + startWeight: number | null; + currentWeight: number | null; + weightDeltaKg: number | null; + weightSeries: Array<{ date: string; weight: number }>; + injectionDates: string[]; +} + +interface Glp1Payload { + active: boolean; + medications: Glp1MedicationPayload[]; +} + +interface Glp1ApiResponse { + data: Glp1Payload | null; +} + +/** + * Compose a single-line drug+dose caption ("Mounjaro 7.5mg"). The + * route exposes the parsed dose value + unit on `currentDose` (from + * `MedicationDoseChange` — the source of record). When the user + * hasn't logged a titration history we fall back to the medication + * name alone — better blank than wrong. + */ +function formatDrugAndDose(med: Glp1MedicationPayload): string { + if (!med.currentDose) return med.name; + const dose = `${med.currentDose.value}${med.currentDose.unit}`; + return `${med.name} ${dose}`; +} + +/** + * Format a `YYYY-MM-DD` date string as "Mon 12 May" / "Mo, 12 Mai" + * via the user's active locale formatters. The weekday is part of + * the caption Marc asked for ("Last injection: ()"). + */ +function useDateWithWeekday(): (iso: string) => string { + const fmt = useFormatters(); + return useMemo(() => { + return (iso: string) => { + const d = new Date(`${iso}T12:00:00Z`); + return fmt.dateWithWeekday(d); + }; + }, [fmt]); +} + +interface DeltaDisplay { + text: string; + icon: typeof ArrowDown; + tone: "loss" | "gain" | "flat"; +} + +/** + * The weight-delta caption ("−4.2 kg seit Beginn") is the headline + * outcome of GLP-1 therapy. We carve it out as a small helper so the + * sign formatting + icon choice stays in one place: + * - delta < 0 → loss (green arrow-down). The clinically desired + * direction for weight-management indications. + * - delta > 0 → gain (orange arrow-up). Not necessarily a bad + * outcome (e.g. recovering from underweight), so we use the + * muted warning palette instead of red. + * - delta ≈ 0 → flat (muted minus). Within ±0.1 kg counts as flat + * so the caption doesn't flicker on rounding noise. + */ +function useDeltaDisplay(): (deltaKg: number, suffix: string) => DeltaDisplay { + const fmt = useFormatters(); + return useMemo(() => { + return (deltaKg: number, suffix: string) => { + const rounded = Math.round(deltaKg * 10) / 10; + if (Math.abs(rounded) < 0.1) { + return { + text: `${fmt.number(0, 1)} kg ${suffix}`, + icon: Minus, + tone: "flat" as const, + }; + } + const sign = rounded > 0 ? "+" : "−"; + const display = `${sign}${fmt.number(Math.abs(rounded), 1)} kg ${suffix}`; + return { + text: display, + icon: rounded < 0 ? ArrowDown : ArrowUp, + tone: rounded < 0 ? ("loss" as const) : ("gain" as const), + }; + }; + }, [fmt]); +} + +export function Glp1Tile() { + const { isAuthenticated, user } = useAuth(); + const { t } = useTranslations(); + const dateWithWeekday = useDateWithWeekday(); + const deltaDisplay = useDeltaDisplay(); + + const { data, isPending } = useQuery({ + queryKey: queryKeys.dashboardGlp1(), + enabled: isAuthenticated, + queryFn: async (): Promise => { + const res = await fetch("/api/dashboard/glp1"); + if (!res.ok) throw new Error("glp1 fetch failed"); + const body = (await res.json()) as Glp1ApiResponse; + return body.data; + }, + // Conservative — the tile reads cheap server-side aggregates so + // an aggressive stale-time would mask a fresh injection logged + // a few minutes earlier. 60 s mirrors the rest of the dashboard + // query lifecycle. + staleTime: 60_000, + }); + + // Self-hide everywhere except when we have an active payload. + if (isPending && isAuthenticated) { + return ( +
+
+
+
+ ); + } + + if (!data || data.medications.length === 0) { + return null; + } + + // The brief mentions "renders ONLY when the user has at least one + // active medication with category=GLP1". The route already gates on + // `treatmentClass === "GLP1"` and `active === true`, so `data` here + // is guaranteed to have at least one med. We render the FIRST med — + // multiple concurrent GLP-1 prescriptions are clinically unusual; if + // a future user has two, the Insights /medikamente sub-page will be + // the place to see both side-by-side. + const med = data.medications[0]; + const drugLine = formatDrugAndDose(med); + + return ( +
+
+
+ +
+ + {drugLine} + + {med.weightDeltaKg !== null && ( + + )} +
+ +
+ {med.lastInjection && ( +
+
+ )} + {med.nextInjection && ( +
+
+ )} +
+ + {/* Compact weight chart with the vertical injection-day markers + the v1.4.25 W6 chart-extension adds. Skipped when we have no + weight readings since the GLP-1 was started — the empty + chart would draw nothing useful. */} + {med.weightSeries.length > 0 && ( +
+ ({ date }))} + userTimezone={user?.timezone} + /> +
+ )} +
+ ); +} + +/** + * Carve the delta caption pill into its own component so the + * loss/gain/flat tone selection is colocated with the rendering. The + * pill itself is the small green/orange/muted chip that sits next to + * the drug name on the second tile row. + */ +function DeltaCaption({ display }: { display: DeltaDisplay }) { + const Icon = display.icon; + const toneClass = + display.tone === "loss" + ? "bg-dracula-green/15 text-dracula-green" + : display.tone === "gain" + ? "bg-dracula-orange/15 text-dracula-orange" + : "bg-muted/50 text-muted-foreground"; + return ( + + + ); +} diff --git a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx index 5f50901a3..1f6ffac63 100644 --- a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx +++ b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx @@ -65,6 +65,36 @@ describe("doctor-report dialog — i18n contract", () => { expect(typeof dialog![key]).toBe("string"); } }); + + // v1.4.25 W6c — per-section toggle strings. + it(`exposes every doctor-report section-toggle key in ${locale}.json`, () => { + const messages = loadMessages(locale); + const root = messages.doctorReport as Record | undefined; + const sections = root!.sections as Record | undefined; + expect( + sections, + `doctorReport.sections missing in ${locale}.json`, + ).toBeDefined(); + const sectionKeys = [ + "title", + "bp", + "weight", + "pulse", + "bmi", + "mood", + "moodSensitive", + "compliance", + "sleep", + "empty", + ]; + for (const key of sectionKeys) { + expect( + sections![key], + `doctorReport.sections.${key} missing in ${locale}.json`, + ).toBeTruthy(); + expect(typeof sections![key]).toBe("string"); + } + }); } it("EN dialog uses the expected English title + 90d preset", () => { diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index 9f9417fc2..75416295d 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -1,14 +1,28 @@ "use client"; /** - * Doctor-report export dialog (v1.4.15 phase B6). + * Doctor-report export dialog (v1.4.15 phase B6 + v1.4.25 W6c). * - * Wraps the trigger button so the user picks a reporting period and - * (optionally) a practice / clinic name before the PDF is generated. + * Wraps the trigger button so the user picks: + * 1. Reporting period (date range, with three presets). + * 2. Practice / clinic name (optional, persisted between exports). + * 3. Which data sections appear in the PDF (per-user persisted toggles). + * + * Hide-when-empty (Marc 2026-05-14): a section's toggle is only shown + * when the selected date range actually has data for it — checking a box + * for an empty section would produce a silently-empty PDF page. The + * availability probe runs on every range change via + * `/api/doctor-report/availability`. + * + * Privacy default: mood is OFF by default per Marc — mental-health data + * is opt-in even within a single user's own surface. The API layer + * filters mood out of the report payload server-side when the toggle is + * off, so the data never leaves the DB row. * * Defaults: * - end = today * - start = today − 90 days + * - sections = persisted prefs OR documented defaults * * Validation runs in the client so the dialog can show inline errors * without bouncing through the server. The server still re-validates via @@ -16,7 +30,7 @@ * messages are UX, not security. */ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -31,16 +45,53 @@ import { import { Input } from "@/components/ui/input"; import { DateInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + DEFAULT_DOCTOR_REPORT_PREFS, + type DoctorReportPrefs, +} from "@/lib/validations/doctor-report-prefs"; import { useTranslations } from "@/lib/i18n/context"; const ONE_DAY_MS = 86_400_000; const MAX_RANGE_DAYS = 730; const PRACTICE_NAME_MAX_LENGTH = 120; +/** + * Order in which section toggles render. Matches the order the sections + * appear in the generated PDF so the dialog's mental model lines up with + * the printed artefact. `mood` is intentionally last because its + * privacy-sensitive footnote needs prime visual placement. + */ +const SECTION_ORDER: ReadonlyArray = [ + "bp", + "weight", + "pulse", + "bmi", + "compliance", + "sleep", + "mood", +] as const; + +interface SectionAvailability { + bp: boolean; + weight: boolean; + pulse: boolean; + bmi: boolean; + mood: boolean; + compliance: boolean; + sleep: boolean; +} + export interface DoctorReportSubmitPayload { startDate: string; endDate: string; practiceName: string | null; + /** + * Per-section toggles. The caller forwards this to + * `/api/doctor-report` so the aggregator drops disabled sections + * (mood specifically is filtered at the data layer for privacy). + */ + sections: DoctorReportPrefs; } interface DoctorReportDialogProps { @@ -75,6 +126,22 @@ function defaultStartIso(): string { return formatLocalDate(d); } +function rangeIsoFromLocal( + startDate: string, + endDate: string, +): { + startIso: string; + endIso: string; +} { + // Send dates as ISO timestamps anchored to the local day's start/end + // so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]` captures everything + // logged on each boundary day. + return { + startIso: new Date(`${startDate}T00:00:00`).toISOString(), + endIso: new Date(`${endDate}T23:59:59.999`).toISOString(), + }; +} + export function DoctorReportDialog({ open, onOpenChange, @@ -88,6 +155,22 @@ export function DoctorReportDialog({ const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // Per-user section preferences. Loaded lazily when the dialog opens — + // skips a roundtrip on every render of the export card. + const [prefs, setPrefs] = useState( + DEFAULT_DOCTOR_REPORT_PREFS, + ); + const [prefsLoaded, setPrefsLoaded] = useState(false); + + // Section availability for the currently-selected range. `null` = + // "we haven't probed yet" so the section group renders a skeleton row + // rather than flashing an empty state. + const [availability, setAvailability] = useState( + null, + ); + const [availabilityLoading, setAvailabilityLoading] = useState(false); + const availabilityRequestId = useRef(0); + // Reset on open: re-anchor dates + practice-name to defaults each time // the dialog appears so a follow-up export doesn't reuse the previous // custom range. Using the React-recommended "track-the-trigger" pattern @@ -106,6 +189,74 @@ export function DoctorReportDialog({ setLastOpen(false); } + // Load persisted section prefs when the dialog opens for the first + // time. Best-effort: a failure falls back to documented defaults so + // the dialog stays usable even if the preferences endpoint is down. + useEffect(() => { + if (!open || prefsLoaded) return; + let cancelled = false; + fetch("/api/auth/me/doctor-report-prefs", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + if (cancelled) return; + const incoming = json?.data; + if (incoming && typeof incoming === "object") { + setPrefs({ ...DEFAULT_DOCTOR_REPORT_PREFS, ...incoming }); + } + setPrefsLoaded(true); + }) + .catch(() => { + if (!cancelled) setPrefsLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [open, prefsLoaded]); + + // Re-probe availability whenever the range changes. setState calls + // live inside the promise handlers (microtask-deferred), satisfying + // the strict `react-hooks/set-state-in-effect` rule while keeping the + // loading indicator promptly visible. A generation counter prevents + // a slow earlier response from stomping on a faster later response + // (classic stale-write race). + useEffect(() => { + if (!open) return; + if (!startDate || !endDate) return; + const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate); + const requestId = ++availabilityRequestId.current; + let cancelled = false; + + Promise.resolve() + .then(() => { + if (cancelled) return null; + setAvailabilityLoading(true); + return fetch("/api/doctor-report/availability", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ startDate: startIso, endDate: endIso }), + }); + }) + .then(async (res) => { + if (cancelled || !res || !res.ok) return; + const json = (await res.json()) as { data: SectionAvailability }; + if (requestId !== availabilityRequestId.current) return; + setAvailability(json.data); + }) + .catch(() => { + // Best-effort — keep the previous availability snapshot. + }) + .finally(() => { + if (!cancelled && requestId === availabilityRequestId.current) { + setAvailabilityLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [open, startDate, endDate]); + const validation = useMemo(() => { if (!startDate || !endDate) { return { @@ -146,6 +297,36 @@ export function DoctorReportDialog({ setError(null); } + // Sections that have data in the current range. Drives both the + // rendered checkbox list AND the canonical payload — toggles for + // empty sections are stripped before submission so the server doesn't + // see a "yes, render this empty thing" instruction. + const activeSections = useMemo>(() => { + if (!availability) return []; + return SECTION_ORDER.filter((key) => availability[key]); + }, [availability]); + + function toggleSection(key: keyof DoctorReportPrefs, value: boolean) { + setPrefs((current) => ({ ...current, [key]: value })); + } + + async function persistPrefs(next: DoctorReportPrefs) { + // Best-effort PUT — a network blip mustn't block PDF generation. The + // current dialog still submits with the chosen `next` value either + // way, so the user gets the PDF they asked for even if persistence + // failed (they'll just see the defaults next time). + try { + await fetch("/api/auth/me/doctor-report-prefs", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(next), + }); + } catch { + // ignore + } + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); if (!validation.ok) { @@ -155,12 +336,20 @@ export function DoctorReportDialog({ setSubmitting(true); setError(null); try { - // Send dates as ISO timestamps anchored to the local day's - // start/end so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]` - // captures everything logged on each boundary day. - const startIso = new Date(`${startDate}T00:00:00`).toISOString(); - const endIso = new Date(`${endDate}T23:59:59.999`).toISOString(); + const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate); const trimmedPractice = practiceName.trim(); + + // Compose the section payload: keep the persisted prefs for keys + // that ARE available in the range; force `false` for keys that + // aren't (so the server never tries to render an empty section). + const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS }; + for (const key of SECTION_ORDER) { + sections[key] = (availability?.[key] ?? false) && prefs[key] === true; + } + + // Fire-and-forget persistence so the user doesn't wait on it. + void persistPrefs(prefs); + await onSubmit({ startDate: startIso, endDate: endIso, @@ -168,6 +357,7 @@ export function DoctorReportDialog({ trimmedPractice.length > 0 ? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH) : null, + sections, }); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); @@ -263,6 +453,15 @@ export function DoctorReportDialog({ />
+ {/* ── Section toggles ──────────────────────────────────────── */} + + {error && (

); } + +// ────────────────────────────── Subviews ────────────────────────────── + +interface SectionTogglesProps { + activeSections: Array; + availability: SectionAvailability | null; + availabilityLoading: boolean; + prefs: DoctorReportPrefs; + onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void; +} + +const SECTION_LABEL_KEYS: Record = { + bp: "doctorReport.sections.bp", + weight: "doctorReport.sections.weight", + pulse: "doctorReport.sections.pulse", + bmi: "doctorReport.sections.bmi", + mood: "doctorReport.sections.mood", + compliance: "doctorReport.sections.compliance", + sleep: "doctorReport.sections.sleep", +}; + +function SectionToggles({ + activeSections, + availability, + availabilityLoading, + prefs, + onToggle, +}: SectionTogglesProps) { + const { t } = useTranslations(); + + // First open: show a quiet skeleton row instead of the full toggle + // list. Keeps the dialog from "flashing" between empty and populated + // states while the probe is in-flight. + if (availability === null && availabilityLoading) { + return ( +

+

+ {t("doctorReport.sections.title")} +

+
+
+
+
+
+ ); + } + + // No data in the selected range — render an empty-state hint so the + // user understands why the toggle list is missing. The PDF still + // generates (cover + period + an empty body) so they get something. + if (availability && activeSections.length === 0) { + return ( +
+

+ {t("doctorReport.sections.title")} +

+

+ {t("doctorReport.sections.empty")} +

+
+ ); + } + + return ( +
+
+

+ {t("doctorReport.sections.title")} +

+ {availabilityLoading && ( + + )} +
+
    + {activeSections.map((key) => { + const id = `dr-section-${key}`; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/i18n/__tests__/maintainership-banner.test.tsx b/src/components/i18n/__tests__/maintainership-banner.test.tsx new file mode 100644 index 000000000..ad6f63f4b --- /dev/null +++ b/src/components/i18n/__tests__/maintainership-banner.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { MaintainershipBanner } from "../maintainership-banner"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; + +/** + * v1.4.25 W9e — contract: + * 1. Renders nothing on maintained locales (DE / EN). + * 2. Renders a notice strip on AI-initial locales (FR / ES / IT / PL). + * 3. The notice carries a GitHub-issue CTA so feedback has a clear + * home. + * + * Dismissal-state behaviour (localStorage round-trip + button click) + * is covered by the component implementation; SSR markup is what the + * project's other component tests pin (see `error-details.test.tsx`), + * so we mirror that idiom here. The mount-time `useEffect` that reads + * localStorage runs client-side only — the SSR output we test here + * reflects the pre-mount "dismissed by default" branch, which is the + * exact contract for first paint with a stored flag. + * + * The "banner visible on first paint when no flag stored" path is + * verified end-to-end by the dev server + Playwright lane (W9f); + * here we pin the locale gating and the markup contract. + */ + +function ssr(locale: Locale): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + beforeEach(() => { + // Fresh localStorage so each test starts from the "never dismissed" + // state. The SSR path doesn't read it, but keeping the slate clean + // documents the contract for the client-side branch. + try { + globalThis.localStorage?.clear(); + } catch { + /* SSR — localStorage absent */ + } + }); + + it("renders nothing on the EN locale (maintained)", () => { + const html = ssr("en"); + expect(html).not.toContain("maintainership-banner"); + }); + + it("renders nothing on the DE locale (maintained)", () => { + const html = ssr("de"); + expect(html).not.toContain("maintainership-banner"); + }); + + // SSR path renders the dismissed-by-default state to avoid a + // hydration mismatch. Client mount reads localStorage and reveals + // the banner when no flag exists. The SSR-only assertion below + // documents that the strip is NOT painted before the first effect + // runs — exactly the contract that prevents the flash-of-banner + // problem. + it("paints nothing in the SSR pass for AI-initial locales", () => { + for (const locale of ["fr", "es", "it", "pl"] as const) { + const html = ssr(locale); + expect(html).not.toContain("maintainership-banner"); + } + }); + + it("the component module exports the locale gate as a named symbol", async () => { + // Sanity check: the gate (`isMaintainedLocale`) must stay re-exported + // from the same `config` module so admin tooling / the future + // settings switch can read the source of truth without importing + // the banner itself. + const cfg = await import("@/lib/i18n/config"); + expect(typeof cfg.isMaintainedLocale).toBe("function"); + expect(cfg.isMaintainedLocale("en")).toBe(true); + expect(cfg.isMaintainedLocale("de")).toBe(true); + expect(cfg.isMaintainedLocale("fr")).toBe(false); + expect(cfg.isMaintainedLocale("es")).toBe(false); + expect(cfg.isMaintainedLocale("it")).toBe(false); + expect(cfg.isMaintainedLocale("pl")).toBe(false); + }); + + // v1.4.25 W14c — banner copy upgraded to acknowledge that the + // FR/ES/IT/PL Coach prompts are AI-drafted including safety-critical + // instructions. Pin the new wording so a copy regression cannot + // quietly soften the disclosure. + it("EN banner copy names safety-critical AI-drafted Coach content", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const en = JSON.parse( + readFileSync(join(process.cwd(), "messages", "en.json"), "utf8"), + ) as { i18n: { maintainershipBanner: { notice: string } } }; + const notice = en.i18n.maintainershipBanner.notice; + expect(notice).toMatch(/AI-drafted/); + expect(notice).toMatch(/safety-critical/); + expect(notice).toMatch(/GitHub/); + expect(notice).toMatch(/Coach/); + }); + + it("every AI-initial locale's banner notice acknowledges the AI-drafted Coach content", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const aiDraftPatterns: Record = { + fr: /rédigée par IA/, + es: /redactado por IA/, + it: /redatta tramite IA/, + pl: /opracowana przez AI/, + }; + for (const [locale, pattern] of Object.entries(aiDraftPatterns)) { + const data = JSON.parse( + readFileSync( + join(process.cwd(), "messages", `${locale}.json`), + "utf8", + ), + ) as { i18n: { maintainershipBanner: { notice: string } } }; + const notice = data.i18n.maintainershipBanner.notice; + expect(notice, `${locale} banner notice`).toMatch(pattern); + expect(notice, `${locale} banner notice mentions Coach`).toMatch( + /Coach/, + ); + } + }); +}); diff --git a/src/components/i18n/maintainership-banner.tsx b/src/components/i18n/maintainership-banner.tsx new file mode 100644 index 000000000..2779d0783 --- /dev/null +++ b/src/components/i18n/maintainership-banner.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useSyncExternalStore } from "react"; +import { Languages, X } from "lucide-react"; +import { isMaintainedLocale, type Locale } from "@/lib/i18n/config"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.4.25 W9e — notice strip for AI-initial locales. + * + * HealthLog ships German + English as maintainer-curated translations + * (the maintainer, Marc, owns both bodies). French, Spanish, Italian + * and Polish ship as AI-initial translations — they pass the + * locale-integrity test (full key parity, no empty values, no TODOs) + * but the prose has not been hand-reviewed for register, regional + * vocabulary, or domain-specific nuance. + * + * This banner renders ONLY on non-maintained locales (FR/ES/IT/PL + * today) and surfaces a small, dismissible strip at the top of the + * authenticated app shell. It tells the user the translation is + * AI-maintained and links to the GitHub translation-feedback issue + * template so they can contribute fixes back. + * + * Dismissal is persisted to localStorage per-locale so a user who + * dismisses the FR banner does not see it again on next visit, but + * still sees the IT banner when they switch language. + * + * The component returns null (renders nothing) when: + * - the active locale is in MAINTAINED_LOCALES, or + * - the user has dismissed this locale's banner, or + * - we are server-side rendering (no localStorage) — the client + * re-evaluates on mount, so the banner appears on second paint + * without a hydration mismatch warning. + */ + +const STORAGE_PREFIX = "healthlog-i18n-banner-dismissed:"; +// v1.4.26 P6-8 — `.github/ISSUE_TEMPLATE/translation.yml` now exists. +// GitHub matches the `?template=` query against the filename; the old +// `.md` filename never resolved (the form file was missing), so the +// link landed users on a blank issue body. +const GITHUB_ISSUE_URL = + "https://github.com/MBombeck/HealthLog/issues/new?template=translation.yml"; + +function storageKey(locale: Locale): string { + return `${STORAGE_PREFIX}${locale}`; +} + +function readDismissed(locale: Locale): boolean { + if (typeof window === "undefined") return true; + try { + return window.localStorage.getItem(storageKey(locale)) === "1"; + } catch { + // localStorage unavailable (private mode, quota) — fail open so the + // banner appears. The user can still hide it for the session. + return false; + } +} + +// Subscribe is a no-op: we only need React to re-read storage on the +// initial client render, not to react to outside writes. Returning a +// noop unsubscriber is the React-recommended pattern for one-shot +// hydration of a browser-only value (mirrors next/router's locale +// snapshot trick). +const subscribeNoop = () => () => undefined; + +export function MaintainershipBanner() { + const { locale, t } = useTranslations(); + // SSR snapshot: server treats the banner as dismissed so the static + // markup never paints it. Client mount swaps in the real flag from + // localStorage during the same render via useSyncExternalStore — + // no setState-in-effect, no hydration mismatch warning. + const dismissedFromStorage = useSyncExternalStore( + subscribeNoop, + () => readDismissed(locale), + () => true, + ); + const [sessionDismissed, setSessionDismissed] = useState(false); + const dismissed = dismissedFromStorage || sessionDismissed; + + if (isMaintainedLocale(locale)) return null; + if (dismissed) return null; + + const handleDismiss = () => { + setSessionDismissed(true); + try { + window.localStorage.setItem(storageKey(locale), "1"); + } catch { + // Best-effort persistence — the in-memory `sessionDismissed` + // flag still hides the banner for the rest of the session. + } + }; + + return ( +
+ +

+ {t("i18n.maintainershipBanner.notice")}{" "} + + {t("i18n.maintainershipBanner.cta")} + + . +

+ +
+ ); +} diff --git a/src/components/insights/__tests__/health-score-card-provenance.test.tsx b/src/components/insights/__tests__/health-score-card-provenance.test.tsx new file mode 100644 index 000000000..d65d364cf --- /dev/null +++ b/src/components/insights/__tests__/health-score-card-provenance.test.tsx @@ -0,0 +1,377 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + HealthScoreCard, + type HealthScoreCardProps, +} from "../health-score-card"; + +/** + * v1.4.25 W8e — Health-Score provenance accordion. + * + * The card now grows a tap-to-expand accordion under the four + * component sub-bars. The accordion surfaces: + * - per-component value-bar + numeric value + * - effective weight share (second, narrower bar) + * - source pill (manual / withings / appleHealth / mixed / none) + * - a "mixed sources" `role="status"` banner when at least one row + * has `source === "mixed"` + * - a "Provisional" badge in the header when < 50% of inputs are + * present + * + * SSR is the test convention in this project (no `@testing-library/react` + * dependency). The card exposes `initiallyExpanded` so the markup of + * the expanded panel renders inside `renderToStaticMarkup`. Interactive + * behaviour (click toggles `aria-expanded`) is verified at the contract + * level: collapsed state pins `aria-expanded="false"`, expanded state + * (via `initiallyExpanded`) pins `aria-expanded="true"` + renders the + * panel. + */ + +const componentsAllPresent: HealthScoreCardProps["components"] = { + bp: { + value: 82, + weight: 0.3, + source: "withings", + asOf: "2026-05-14T08:00:00.000Z", + }, + weight: { + value: 75, + weight: 0.2, + source: "manual", + asOf: "2026-05-13T18:00:00.000Z", + }, + mood: { + value: 90, + weight: 0.2, + source: "manual", + asOf: "2026-05-14T07:00:00.000Z", + }, + compliance: { + value: 95, + weight: 0.3, + source: "manual", + asOf: "2026-05-14T00:00:00.000Z", + }, +}; + +function ssr(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" — provenance accordion (W8e)", () => { + it("renders the toggle button with aria-expanded=false when collapsed", () => { + const html = ssr( + , + ); + expect(html).toMatch( + /data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="false"/, + ); + // Toggle text uses the localised "Driven by" copy. + expect(html).toContain("Driven by"); + // Panel is NOT rendered when collapsed. + expect(html).not.toContain( + 'data-slot="health-score-card-provenance-panel"', + ); + }); + + it("renders the panel + sets aria-expanded=true when initiallyExpanded", () => { + const html = ssr( + , + ); + expect(html).toMatch( + /data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="true"/, + ); + expect(html).toContain('data-slot="health-score-card-provenance-panel"'); + }); + + it("aria-controls on the toggle points at the rendered panel's id", () => { + const html = ssr( + , + ); + // Extract aria-controls value from the toggle and confirm the + // panel section uses the same id. + const controlsMatch = html.match( + /data-slot="health-score-card-provenance-toggle"[^>]*aria-controls="([^"]+)"/, + ); + expect(controlsMatch).not.toBeNull(); + const id = controlsMatch![1]; + // The panel section must carry the same id; we don't pin the exact + // attribute order because additional ARIA attributes (e.g. + // `aria-labelledby`) may sit between `id` and `data-slot`. + const panelMatch = html.match( + new RegExp(`]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`), + ); + expect(panelMatch).not.toBeNull(); + }); + + it("sorts rows by effective weight descending (null components sink to bottom)", () => { + const components: HealthScoreCardProps["components"] = { + bp: { value: null, weight: 0, source: "none" }, + weight: { value: 60, weight: 0.4, source: "manual" }, + mood: { value: 80, weight: 0.3, source: "manual" }, + compliance: { value: 70, weight: 0.3, source: "manual" }, + }; + const html = ssr( + , + ); + // Pull every `data-component=` token from the provenance rows. + const rowMatches = Array.from( + html.matchAll( + /data-slot="health-score-card-provenance-row"[^>]*data-component="([^"]+)"/g, + ), + ).map((m) => m[1]); + // Weight (0.4) > mood (0.3) > compliance (0.3) — mood vs compliance + // tie-break is the alphabetical key order. BP at weight=0 sinks + // to the bottom. + expect(rowMatches).toEqual(["weight", "compliance", "mood", "bp"]); + }); + + it("renders source pills with locale-resolved labels (EN)", () => { + const html = ssr( + , + ); + // Withings + Manual both present in the data. + expect(html).toContain("Withings"); + expect(html).toContain("Manual"); + }); + + it("renders source pills with locale-resolved labels (DE)", () => { + const html = ssr( + , + "de", + ); + // Manuell is the DE label for "manual". + expect(html).toContain("Manuell"); + expect(html).toContain("Withings"); + expect(html).toContain("Zusammensetzung"); + }); + + it("renders the mixed-source banner with role=status when any row is mixed", () => { + const components: HealthScoreCardProps["components"] = { + ...componentsAllPresent, + bp: { value: 82, weight: 0.3, source: "mixed" }, + }; + const html = ssr( + , + ); + expect(html).toMatch( + /data-slot="health-score-card-provenance-mixed-banner"[^>]*role="status"/, + ); + // The pill on the BP row reads "Mixed" (EN). + expect(html).toContain("Mixed"); + }); + + it("does NOT render the mixed-source banner when no row is mixed", () => { + const html = ssr( + , + ); + expect(html).not.toContain( + 'data-slot="health-score-card-provenance-mixed-banner"', + ); + }); + + it("dims empty-state rows when source is `none`", () => { + const components: HealthScoreCardProps["components"] = { + ...componentsAllPresent, + mood: { value: null, weight: 0, source: "none" }, + }; + const html = ssr( + , + ); + // The dimmed row carries opacity-50 alongside the `data-source="none"` + // attribute. + expect(html).toMatch( + /data-component="mood"[^>]*data-source="none"[^>]*class="[^"]*opacity-50/, + ); + // The source pill reads "No data" (EN). + expect(html).toContain("No data"); + }); + + it("renders the provisional badge when fewer than 50% of inputs are present", () => { + // Three out of four components null → 1 of 4 = 25 % present. + const components: HealthScoreCardProps["components"] = { + bp: { + value: 80, + weight: 1, + source: "withings", + asOf: "2026-05-14T08:00:00.000Z", + }, + weight: { value: null, weight: 0, source: "none" }, + mood: { value: null, weight: 0, source: "none" }, + compliance: { value: null, weight: 0, source: "none" }, + }; + const html = ssr( + , + ); + expect(html).toContain( + 'data-slot="health-score-card-provisional-badge"', + ); + // EN badge copy. + expect(html).toContain("Provisional"); + }); + + it("does NOT render the provisional badge when at least 50% of inputs are present", () => { + // 2 of 4 = 50 % → NOT provisional. + const components: HealthScoreCardProps["components"] = { + bp: { + value: 80, + weight: 0.6, + source: "withings", + asOf: "2026-05-14T08:00:00.000Z", + }, + weight: { + value: 70, + weight: 0.4, + source: "manual", + asOf: "2026-05-13T08:00:00.000Z", + }, + mood: { value: null, weight: 0, source: "none" }, + compliance: { value: null, weight: 0, source: "none" }, + }; + const html = ssr( + , + ); + expect(html).not.toContain( + 'data-slot="health-score-card-provisional-badge"', + ); + }); + + it("renders the source-pill aria-label with localised source name", () => { + const html = ssr( + , + ); + // BP row uses Withings; the aria-label uses the localised + // "Source: Withings" form. + expect(html).toContain('aria-label="Source: Withings"'); + }); + + it("renders the value bar at 0% width for empty-state rows", () => { + const components: HealthScoreCardProps["components"] = { + ...componentsAllPresent, + compliance: { value: null, weight: 0, source: "none" }, + }; + const html = ssr( + , + ); + // Every row inside the panel carries an inline-styled width. + // We confirm the compliance (none) row's value-bar block paints + // 0% by searching for the matching row. + const complianceRowMatch = html.match( + /data-slot="health-score-card-provenance-row"[^>]*data-component="compliance"[\s\S]*?width:\s*([0-9.]+%)/, + ); + expect(complianceRowMatch).not.toBeNull(); + expect(complianceRowMatch?.[1]).toBe("0%"); + }); + + it("forwards the asOf timestamp into the source-pill `title` tooltip", () => { + const html = ssr( + , + ); + // The BP row's pill carries the title formatted via Intl.DateTimeFormat; + // we only need to confirm `as of` appears (the exact date depends on + // the JS engine's locale data). + expect(html).toContain("as of"); + // The data-as-of attribute carries the raw ISO timestamp. + expect(html).toContain('data-as-of="2026-05-14T08:00:00.000Z"'); + }); + + it("renders the footnote inside the expanded panel", () => { + const html = ssr( + , + ); + expect(html).toContain( + 'data-slot="health-score-card-provenance-footnote"', + ); + }); +}); diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index e2f9c4fcb..3dcbbb327 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -143,34 +143,14 @@ describe("", () => { expect(coachTag?.[0]).not.toContain('title="Coming soon"'); }); - it("renders the regenerate button when onRegenerate is supplied", () => { - const html = render( - {}} />, - ); - expect(html).toMatch(/data-slot="insights-hero-strip-action-rerun"/); - expect(html).toContain("Re-run analysis"); - }); - - it("hides the regenerate button when no handler is supplied", () => { + // v1.4.25 W3 — the regenerate button moved from the hero action row + // to the dedicated `` component. The hero strip no + // longer renders the rerun slot under any prop combination. + it("does NOT render a regenerate button in the hero action row", () => { const html = render(); expect(html).not.toContain('data-slot="insights-hero-strip-action-rerun"'); }); - it("disables the regenerate button while regenerating", () => { - const html = render( - {}} - regenerating - />, - ); - expect(html).toMatch( - /data-slot="insights-hero-strip-action-rerun"[^>]*disabled/, - ); - expect(html).toContain("Regenerating"); - }); - // ── Suggested prompts strip ──────────────────────────────────────── it("mounts the SuggestedPrompts strip below the action band", () => { const html = render(); diff --git a/src/components/insights/__tests__/insights-page-hero.test.tsx b/src/components/insights/__tests__/insights-page-hero.test.tsx deleted file mode 100644 index 513f2163e..000000000 --- a/src/components/insights/__tests__/insights-page-hero.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { InsightsPageHero } from "../insights-page-hero"; - -/** - * v1.4.16 phase B1b — Apple-Health-style page hero. - * - * The /insights page entry-point gets a gradient hero shell: - * - h1 + subtitle (kept from the old layout, just polished) - * - "Generated " caption when an updatedAt is supplied - * - regenerate button when an onRegenerate handler is supplied - * - "Based on your last 90 days" personal-baseline indicator - * - smooth fade-in (CSS animation, gated by prefers-reduced-motion) - * - * The hero is a pure presentational shell; the page passes the values - * in. This test pins the slot positions so a future polish pass can't - * silently drop one (e.g. dropping the personal-baseline caption). - */ - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe("", () => { - it("renders the heading + overview subtitle", () => { - const html = render(); - expect(html).toContain("Insights"); - expect(html).toContain("Trends, risks, progress at a glance"); - }); - - it("paints a gradient background using Dracula tokens", () => { - const html = render(); - // The hero wrapper should carry a gradient + Dracula tokens so the - // page header reads as the Apple-Health-style band. - expect(html).toMatch(/data-slot="insights-page-hero"/); - expect(html).toMatch(/bg-gradient-to/); - }); - - it("renders the personal-baseline caption", () => { - const html = render(); - expect(html).toContain("Based on your last 90 days"); - }); - - it("renders the German personal-baseline caption", () => { - const html = render(, "de"); - expect(html).toContain("Basierend auf deinen letzten 90 Tagen"); - }); - - it("renders the generated-time caption when updatedAt is supplied", () => { - const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); - const html = render(); - // Either "just now" / "5 minutes ago" / "vor 5 Minuten" — we just - // assert the "Generated" label preceeds the relative-time string so - // a future Intl.RelativeTimeFormat tweak can't dodge the slot. - expect(html).toMatch(/data-slot="insights-page-hero-generated"/); - expect(html).toContain("Generated"); - }); - - it("does NOT render the generated caption when updatedAt is missing", () => { - const html = render(); - expect(html).not.toContain('data-slot="insights-page-hero-generated"'); - }); - - it("renders a regenerate button when onRegenerate is supplied", () => { - // The button is a real + + + {/* Body — three columns on lg+, single column on smaller. @@ -297,13 +407,27 @@ export function CoachDrawer({ } sourcesRail={ sourcesRail ?? ( - + { + setScope((prev) => ({ ...prev, sources: next.sources })); + setWindowOverride( + next.window === savedDefaultWindow ? null : next.window, + ); + }} + /> ) } thread={ } composer={ @@ -373,7 +497,18 @@ export function CoachDrawer({
{sourcesRail ?? ( - + { + setScope((prev) => ({ ...prev, sources: next.sources })); + setWindowOverride( + next.window === savedDefaultWindow ? null : next.window, + ); + }} + /> )}
diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 58791024f..05e7338d2 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -1,23 +1,23 @@ "use client"; -import { type FormEvent, type KeyboardEvent, useCallback } from "react"; -import { Loader2, Mic, Send } from "lucide-react"; +import { + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useRef, +} from "react"; +import { Loader2, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.20 phase B2b — Coach composer. * - * `